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 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/README.md b/README.md index bbc5277..0c956ff 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` | @@ -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 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::(); } } diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index abaaab6..cb8b7dc 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -8,37 +8,68 @@ #![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>; /// 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>>, } @@ -46,6 +77,7 @@ impl EmailChannel { pub fn new(config: EmailConfig) -> Self { Self { config, + #[cfg(feature = "email")] seen_messages: Arc::new(Mutex::new(HashSet::new())), } } @@ -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() @@ -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(); @@ -127,6 +161,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); @@ -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> { // Search for unseen messages let uids = session.uid_search("UNSEEN").await?; @@ -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, @@ -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) -> Result<()> { let mut backoff = Duration::from_secs(1); let max_backoff = Duration::from_secs(60); @@ -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) -> Result<()> { // Connect and authenticate let mut session = self.connect_imap().await?; @@ -351,6 +390,7 @@ impl EmailChannel { } /// Fetch unseen messages and send to channel + #[cfg(feature = "email")] async fn process_unseen( &self, session: &mut ImapSession, @@ -454,6 +494,7 @@ impl EmailChannel { } /// Internal struct for parsed email data +#[cfg(feature = "email")] struct ParsedEmail { _uid: u32, msg_id: String, @@ -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 { @@ -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. @@ -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")))] +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")); + } +} 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};