From 3e3e8b1fc1b14812ab48a7419ef8b93d4349d1e3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:45:15 +0300 Subject: [PATCH 1/9] chore: files changed src/providers/email_channel.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/email_channel.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index abaaab6..5154a43 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -9,31 +9,51 @@ #![allow(clippy::unnecessary_map_or)] use anyhow::{Result, 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; 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; use std::collections::HashSet; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +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}; +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>; /// Email channel — IMAP IDLE for instant push notifications, SMTP for outbound From 451d08db8762d3961749b0e50b9676f76562a709 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:45:43 +0300 Subject: [PATCH 2/9] chore: files changed src/providers/email_channel.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/email_channel.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index 5154a43..25e2b0d 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -116,6 +116,7 @@ impl EmailChannel { } /// Extract the sender address from a parsed email + #[cfg(feature = "email")] fn extract_sender(parsed: &mail_parser::Message) -> String { parsed .from() @@ -126,6 +127,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(); @@ -147,6 +149,7 @@ impl EmailChannel { } /// Connect to IMAP server with TLS and authenticate + #[cfg(feature = "email")] async fn connect_imap(&self) -> Result { let addr = format!("{}:{}", self.config.imap_host, self.config.imap_port); debug!("Connecting to IMAP server at {}", addr); @@ -179,6 +182,7 @@ impl EmailChannel { } /// Fetch and process unseen messages from the selected mailbox + #[cfg(feature = "email")] async fn fetch_unseen(&self, session: &mut ImapSession) -> Result> { // Search for unseen messages let uids = session.uid_search("UNSEEN").await?; @@ -262,6 +266,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, @@ -307,6 +312,7 @@ impl EmailChannel { } /// Main IDLE-based listen loop with automatic reconnection + #[cfg(feature = "email")] async fn listen_with_idle(&self, tx: mpsc::Sender) -> Result<()> { let mut backoff = Duration::from_secs(1); let max_backoff = Duration::from_secs(60); @@ -331,6 +337,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) -> Result<()> { // Connect and authenticate let mut session = self.connect_imap().await?; @@ -371,6 +378,7 @@ impl EmailChannel { } /// Fetch unseen messages and send to channel + #[cfg(feature = "email")] async fn process_unseen( &self, session: &mut ImapSession, @@ -474,6 +482,7 @@ impl EmailChannel { } /// Internal struct for parsed email data +#[cfg(feature = "email")] struct ParsedEmail { _uid: u32, msg_id: String, @@ -483,12 +492,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 { @@ -543,11 +554,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. From 6c4635ea781be97e8be7e71f37a593a365384e78 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:46:21 +0300 Subject: [PATCH 3/9] chore: files changed Cargo.toml,src/providers/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 21 +++++++++++++++++---- src/providers/mod.rs | 4 ++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 314fe3f..b4b9f70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] +# 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. diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 1f6ea50..123ddda 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -2,7 +2,7 @@ pub mod dingtalk; pub mod discord; -#[cfg(feature = "email")] +#[cfg(feature = "email-send")] pub mod email_channel; pub mod imessage; pub mod irc; @@ -20,7 +20,7 @@ pub mod yuanbao; pub use dingtalk::DingTalkChannel; pub use discord::DiscordChannel; -#[cfg(feature = "email")] +#[cfg(feature = "email-send")] pub use email_channel::EmailChannel; pub use imessage::IMessageChannel; pub use irc::{IrcChannel, IrcChannelConfig}; From 8020abd026e2313c5e9632c4ec9c57bc5d3d9d7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:50:16 +0300 Subject: [PATCH 4/9] chore: files changed .github/workflows/ci.yml Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 236d488..ae2027f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 From af42991c6686f998ded4604e54d4fa15d1be8290 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:51:35 +0300 Subject: [PATCH 5/9] chore: files changed src/providers/email_channel.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/email_channel.rs | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index 25e2b0d..17cd725 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -581,3 +581,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")))] +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")); + } +} From 280711505063abc7be038a8c8add7eef9c52c952 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:54:24 +0300 Subject: [PATCH 6/9] chore: files changed src/providers/email_channel.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/email_channel.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index 17cd725..c7234f1 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -8,13 +8,16 @@ #![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; @@ -29,6 +32,7 @@ use rustls::{ClientConfig, RootCertStore}; use rustls_pki_types::DnsName; use std::collections::HashSet; use std::sync::Arc; +#[cfg(feature = "email")] use std::time::Duration; #[cfg(feature = "email")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -59,6 +63,10 @@ type ImapSession = Session>; /// 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>>, } @@ -66,6 +74,7 @@ impl EmailChannel { pub fn new(config: EmailConfig) -> Self { Self { config, + #[cfg(feature = "email")] seen_messages: Arc::new(Mutex::new(HashSet::new())), } } From fac79caaa04029f9a280ed4e76faaa533bb6787a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 16:55:04 +0300 Subject: [PATCH 7/9] chore: files changed src/providers/email_channel.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/email_channel.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index c7234f1..cb8b7dc 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -30,7 +30,9 @@ use mail_parser::{MessageParser, MimeHeaders}; 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; #[cfg(feature = "email")] use std::time::Duration; @@ -38,6 +40,7 @@ use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(feature = "email")] use tokio::net::TcpStream; +#[cfg(feature = "email")] use tokio::sync::Mutex; #[cfg(feature = "email")] use tokio::sync::mpsc; From 0e24dd739373859d1a1ceff5c8741f74da344a9e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 17:04:07 +0300 Subject: [PATCH 8/9] feat(providers): add send-only email feature Split the existing `email` feature into a lighter `email-send` variant that only requires SMTP dependencies, allowing consumers that only need to send email to avoid pulling in the heavier IMAP and mail-parser crates. The `EmailChannel` type is now re-exported under the `email-send` feature gate, and the compile-time smoke test has been updated to verify the export works with the new feature name. Auto-committed-on: dragonfly Co-authored-by: Medulla --- README.md | 1 + src/lib.rs | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bbc5277..8713bb2 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/lib.rs b/src/lib.rs index 25bd02b..aca975d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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::(); } } From e820acac1fa3b6d8821ad1c8c469626dd817218f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 17:06:12 +0300 Subject: [PATCH 9/9] chore: files changed README.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 8713bb2..0c956ff 100644 --- a/README.md +++ b/README.md @@ -54,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