Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ jobs:
- name: Clippy email,lark features
run: cargo clippy --all-targets --features email,lark -- -D warnings

# `-p` and `--no-default-features` are both load-bearing. The lanes above
# run at the workspace root, where `tinychannels-module` depends on this
# crate with `features = ["email", "lark", "whatsapp-web"]`; cargo unifies
# those in, so `--features email` at the root is really an all-features
# build and cannot observe a gate being off. Scoping to the package is
# what makes this lane test the send-only surface it names.
- name: Clippy email-send only (send half, no IMAP stack)
run: cargo clippy -p tinychannels --all-targets --no-default-features --features email-send -- -D warnings

- name: Build
run: cargo build --all-targets

Expand All @@ -69,6 +78,9 @@ jobs:
- name: Build email,lark features
run: cargo build --all-targets --features email,lark

- name: Build email-send only (send half, no IMAP stack)
run: cargo build -p tinychannels --all-targets --no-default-features --features email-send

- name: Test
run: cargo test

Expand All @@ -83,3 +95,6 @@ jobs:

- name: Test email,lark features
run: cargo test --features email,lark

- name: Test email-send only (send half, no IMAP stack)
run: cargo test -p tinychannels --no-default-features --features email-send
21 changes: 17 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,24 @@ default = []
# `tokio/net` are now unconditional dependencies (the ported channel providers
# use them directly), so this feature no longer needs to pull them in.
relay-websocket = []
# Email provider (`providers::email_channel`) — IMAP receive + SMTP send.
# SMTP send only (`EmailChannel::new` + `send_message` / `build_*_message`).
#
# Split out of `email` because sending and receiving have very different costs
# and very different consumers. A host that only ever *delivers* mail — OpenHuman
# emails a generated podcast as an attachment and never reads a mailbox — needs
# `lettre` and nothing else. Bundling the two meant that host also linked the
# IMAP receive stack, 13 crates it could never call, and once the channel
# providers move into the `tinychannels` bus module that would have been the
# whole of the shed the move was supposed to buy.
#
# This carries no `Channel` impl: a send-only build cannot `listen`, so
# advertising the trait would promise a half-working channel.
email-send = ["dep:lettre"]
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
# Full email provider (`providers::email_channel`) — IMAP receive + SMTP send.
# Off by default: it is the single heaviest provider in the crate, pulling the
# lettre/async-imap/mail-parser stack (18 crates). Downstreams that expose an
# email channel opt in.
email = ["dep:lettre", "dep:async-imap", "dep:mail-parser"]
# lettre/async-imap/mail-parser stack. Downstreams that expose an email channel
# opt in; downstreams that only send want `email-send` above.
email = ["email-send", "dep:async-imap", "dep:mail-parser"]
# Lark/Feishu provider (`providers::lark`) — runs its own axum webhook receiver
# and decodes protobuf events, so it owns both `axum` (5 crates) and `prost`
# (4 crates). Off by default for the same reason.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ TinyChannels includes optional provider implementations that must be explicitly

| Provider | Feature | Channels | Dependencies |
|----------|---------|----------|--------------|
| **Email (send only)** | `email-send` | `EmailChannel` (SMTP send) | `lettre` |
| **Email** | `email` | `EmailChannel` (SMTP + IMAP) | `lettre`, `async-imap`, `mail-parser` |
| **Lark/Feishu** | `lark` | `LarkChannel` (webhook receiver + Protobuf decoder) | `axum`, `prost` |
| **WhatsApp Web** | `whatsapp-web` | `WhatsAppWebChannel` (multi-device via whatsapp-rust) | `whatsapp-rust`, `whatsapp-rust-tokio-transport`, `whatsapp-rust-ureq-http-client`, `wacore` |
Expand All @@ -53,6 +54,19 @@ Or enable them individually as needed:
tinychannels = { version = "0.1", features = ["email"] }
```

If you only ever *send* mail — no mailbox is polled — take `email-send` instead.
It gives you `EmailChannel::new`, `send_message` and the `build_*_message`
helpers on `lettre` alone, without the IMAP receive stack (18 fewer packages):

```toml
[dependencies]
tinychannels = { version = "0.1", features = ["email-send"] }
```

`email-send` carries no `Channel` impl — a send-only build cannot `listen`, so
the trait is gated on the full `email` feature rather than promising a
half-working channel.

All other providers (Telegram, Discord, Slack, Signal, iMessage, IRC, Yuanbao/钉钉, etc.) are included in the default build.

## Development
Expand Down
11 changes: 7 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,22 @@ pub use tinychannels_bus::{
outbound_intent_from_send_message,
};
// Re-exported separately so each can follow its provider's feature gate.
#[cfg(feature = "email")]
#[cfg(feature = "email-send")]
pub use providers::EmailChannel;
#[cfg(feature = "lark")]
pub use providers::LarkChannel;

#[cfg(all(test, feature = "email"))]
#[cfg(all(test, feature = "email-send"))]
mod email_feature_smoke_tests {
use crate::EmailChannel;

#[test]
fn email_channel_is_available_with_email_feature() {
// Verify EmailChannel is exported when `email` feature is enabled.
// This test ensures the export is reachable at compile time.
// Verify EmailChannel is exported whenever the send half is enabled.
// Gated on `email-send`, not `email`: a send-only consumer keeps the
// established `tinychannels::EmailChannel` path, and gating the
// crate-root export on the full feature would silently remove the type
// from the root for exactly the build this split exists to serve.
let _ = std::any::type_name::<EmailChannel>();
}
}
Expand Down
111 changes: 105 additions & 6 deletions src/providers/email_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,76 @@
#![allow(clippy::too_many_lines)]
#![allow(clippy::unnecessary_map_or)]

use anyhow::{Result, anyhow};
use anyhow::Result;
#[cfg(feature = "email")]
use anyhow::anyhow;
#[cfg(feature = "email")]
use async_imap::Session;
#[cfg(feature = "email")]
use async_imap::extensions::idle::IdleResponse;
#[cfg(feature = "email")]
use async_imap::types::Fetch;
#[cfg(feature = "email")]
use async_trait::async_trait;
#[cfg(feature = "email")]
use futures::TryStreamExt;
use lettre::message::{Attachment, MultiPart, SinglePart, header::ContentType};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
#[cfg(feature = "email")]
use mail_parser::{MessageParser, MimeHeaders};
#[cfg(feature = "email")]
use rustls::{ClientConfig, RootCertStore};
#[cfg(feature = "email")]
use rustls_pki_types::DnsName;
#[cfg(feature = "email")]
use std::collections::HashSet;
#[cfg(feature = "email")]
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[cfg(feature = "email")]
use std::time::Duration;
#[cfg(feature = "email")]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(feature = "email")]
use tokio::net::TcpStream;
use tokio::sync::{Mutex, mpsc};
#[cfg(feature = "email")]
use tokio::sync::Mutex;
#[cfg(feature = "email")]
use tokio::sync::mpsc;
#[cfg(feature = "email")]
use tokio::time::{sleep, timeout};
#[cfg(feature = "email")]
use tokio_rustls::TlsConnector;
#[cfg(feature = "email")]
use tokio_rustls::client::TlsStream;
use tracing::{debug, error, info, warn};
use tracing::info;
#[cfg(feature = "email")]
use tracing::{debug, error, warn};
#[cfg(feature = "email")]
use uuid::Uuid;

pub use crate::config::EmailConfig;
#[cfg(feature = "email")]
use crate::traits::{Channel, ChannelMessage, SendMessage};

#[cfg(feature = "email")]
type ImapSession = Session<TlsStream<TcpStream>>;

/// Email channel — IMAP IDLE for instant push notifications, SMTP for outbound
pub struct EmailChannel {
pub config: EmailConfig,
/// Dedupe set for IMAP IDLE, which can re-report a message across
/// re-establishes. A send-only build never opens a mailbox, so the field
/// would be dead weight and a `never read` denial there.
#[cfg(feature = "email")]
seen_messages: Arc<Mutex<HashSet<String>>>,
}

impl EmailChannel {
pub fn new(config: EmailConfig) -> Self {
Self {
config,
#[cfg(feature = "email")]
seen_messages: Arc::new(Mutex::new(HashSet::new())),
}
}
Expand Down Expand Up @@ -96,6 +128,7 @@ impl EmailChannel {
}

/// Extract the sender address from a parsed email
#[cfg(feature = "email")]
fn extract_sender(parsed: &mail_parser::Message) -> String {
parsed
.from()
Expand All @@ -106,6 +139,7 @@ impl EmailChannel {
}

/// Extract readable text from a parsed email
#[cfg(feature = "email")]
fn extract_text(parsed: &mail_parser::Message) -> String {
if let Some(text) = parsed.body_text(0) {
return text.to_string();
Expand All @@ -127,6 +161,7 @@ impl EmailChannel {
}

/// Connect to IMAP server with TLS and authenticate
#[cfg(feature = "email")]
async fn connect_imap(&self) -> Result<ImapSession> {
let addr = format!("{}:{}", self.config.imap_host, self.config.imap_port);
debug!("Connecting to IMAP server at {}", addr);
Expand Down Expand Up @@ -159,6 +194,7 @@ impl EmailChannel {
}

/// Fetch and process unseen messages from the selected mailbox
#[cfg(feature = "email")]
async fn fetch_unseen(&self, session: &mut ImapSession) -> Result<Vec<ParsedEmail>> {
// Search for unseen messages
let uids = session.uid_search("UNSEEN").await?;
Expand Down Expand Up @@ -242,6 +278,7 @@ impl EmailChannel {

/// Run the IDLE loop, returning when a new message arrives or timeout
/// Note: IDLE consumes the session and returns it via done()
#[cfg(feature = "email")]
async fn wait_for_changes(
&self,
session: ImapSession,
Expand Down Expand Up @@ -287,6 +324,7 @@ impl EmailChannel {
}

/// Main IDLE-based listen loop with automatic reconnection
#[cfg(feature = "email")]
async fn listen_with_idle(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(60);
Expand All @@ -311,6 +349,7 @@ impl EmailChannel {
}

/// Run a single IDLE session until error or clean shutdown
#[cfg(feature = "email")]
async fn run_idle_session(&self, tx: &mpsc::Sender<ChannelMessage>) -> Result<()> {
// Connect and authenticate
let mut session = self.connect_imap().await?;
Expand Down Expand Up @@ -351,6 +390,7 @@ impl EmailChannel {
}

/// Fetch unseen messages and send to channel
#[cfg(feature = "email")]
async fn process_unseen(
&self,
session: &mut ImapSession,
Expand Down Expand Up @@ -454,6 +494,7 @@ impl EmailChannel {
}

/// Internal struct for parsed email data
#[cfg(feature = "email")]
struct ParsedEmail {
_uid: u32,
msg_id: String,
Expand All @@ -463,12 +504,14 @@ struct ParsedEmail {
}

/// Result from waiting on IDLE
#[cfg(feature = "email")]
enum IdleWaitResult {
NewMail,
Timeout,
Interrupted,
}

#[cfg(feature = "email")]
#[async_trait]
impl Channel for EmailChannel {
fn name(&self) -> &str {
Expand Down Expand Up @@ -523,11 +566,11 @@ impl Channel for EmailChannel {
}
}

#[cfg(test)]
#[cfg(all(test, feature = "email"))]
#[path = "email_channel_tests.rs"]
mod tests;

#[cfg(any(test, debug_assertions))]
#[cfg(all(feature = "email", any(test, debug_assertions)))]
pub mod test_support {
//! Debug-build helpers for raw integration tests. They exercise the email
//! parser without opening IMAP or SMTP sockets.
Expand All @@ -550,3 +593,59 @@ pub mod test_support {
})
}
}

/// The send-only surface, exercised in a build that has no IMAP stack.
///
/// This is the half of the split a compile check cannot state on its own: with
/// `email-send` on and `email` off the crate builds either way, so nothing
/// would notice if the send path quietly grew a dependency on the receive half
/// and had to be gated along with it. `voice` in OpenHuman reaches for exactly
/// these three items and nothing else, so this is the contract to keep.
#[cfg(all(test, feature = "email-send", not(feature = "email")))]
Comment thread
senamakel marked this conversation as resolved.
mod send_only_tests {
use super::EmailChannel;
use crate::config::EmailConfig;

fn config() -> EmailConfig {
EmailConfig {
from_address: "bot@example.com".to_string(),
username: "bot@example.com".to_string(),
password: "secret".to_string(),
smtp_host: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_tls: true,
..Default::default()
}
}

/// `EmailChannel::new` + `build_plain_message` + `send_message` are what a
/// send-only host links. Building a message must not need a mailbox.
#[test]
fn a_plain_message_can_be_built_without_the_receive_half() {
let channel = EmailChannel::new(config());
let message = channel
.build_plain_message("someone@example.com", "Subject", "Body")
.expect("a well-formed plain message should build");
let raw = String::from_utf8(message.formatted()).expect("message should be UTF-8");
assert!(raw.contains("someone@example.com"));
assert!(raw.contains("Subject"));
}

/// The attachment builder is the one OpenHuman's podcast delivery uses.
#[test]
fn an_attachment_message_can_be_built_without_the_receive_half() {
let channel = EmailChannel::new(config());
let message = channel
.build_message_with_attachment(
"someone@example.com",
"Your podcast",
"Attached.",
"podcast.mp3",
"audio/mpeg".parse().expect("a valid content type"),
vec![0u8, 1, 2, 3],
)
.expect("a well-formed attachment message should build");
let raw = String::from_utf8(message.formatted()).expect("message should be UTF-8");
assert!(raw.contains("podcast.mp3"));
}
}
4 changes: 2 additions & 2 deletions src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

pub mod dingtalk;
pub mod discord;
#[cfg(feature = "email")]
#[cfg(feature = "email-send")]
Comment thread
senamakel marked this conversation as resolved.
pub mod email_channel;
pub mod imessage;
pub mod irc;
Expand All @@ -20,7 +20,7 @@ pub mod yuanbao;

pub use dingtalk::DingTalkChannel;
pub use discord::DiscordChannel;
#[cfg(feature = "email")]
#[cfg(feature = "email-send")]
Comment thread
senamakel marked this conversation as resolved.
pub use email_channel::EmailChannel;
Comment thread
senamakel marked this conversation as resolved.
pub use imessage::IMessageChannel;
pub use irc::{IrcChannel, IrcChannelConfig};
Expand Down