//! smolmail -> email direction. //! //! Polls the gateway's own smolmail inbox and relays any message that //! carries the Email-To frontmatter field (SPEC.md S5.5) to a real mailbox //! over SMTP. A message without that field is left in the local inbox //! rather than dropped, so it stays visible to `fumi --db list/read` //! against the gateway's own store for manual follow-up. //! //! By default (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=1), only the main tier //! is relayed: a first-contact sender with no accept token (SPEC.md S5.8) //! lands in the requests tier and is left alone, so the gateway can't be //! used as an open relay by anyone who merely learns its smolmail address. //! Setting the flag to false restores the earlier "relay anyone" behavior //! for a deliberately open/public bridge. //! //! Unblocking a sender takes two steps, not one -- confirmed live, since //! it is easy to assume `accept` alone is enough: //! 1. `fumi --key gateway.key --db gateway.db accept ` //! records the acceptance locally and uploads the token to the server, //! but does NOT retroactively reclassify a message already sitting in //! this gateway's local inbox (its tier was decided at fetch time) or //! do anything the sender can see. //! 2. The sender only learns the token when it arrives in a reply's Accept //! frontmatter field (SPEC.md S5.8) -- which pneumatube itself never //! sends, since this direction only relays outward to SMTP. The //! operator must send at least one message back from the gateway's own //! identity (e.g. `echo hi | fumi --key gateway.key --db gateway.db //! send
`) before the sender's *next* message will actually //! carry a valid token. use std::sync::Arc; use fumi_core::account::Account; use fumi_core::client::{self, Described}; use fumi_core::error::Error; use fumi_core::store::{Store, Stored}; use crate::config::{Config, FRONTMATTER_EMAIL_KEY}; use crate::email_to_smol::TIMEOUT; use crate::smtp_client::{self, SendError}; use crate::{log_error, log_info, log_warn}; pub struct SmolToEmail { store: Arc, account: Arc, config: Arc, } impl SmolToEmail { pub fn new(store: Arc, account: Arc, config: Arc) -> Self { SmolToEmail { store, account, config, } } /// Fetch new mail, then relay everything relatable in the tier(s) this /// gateway is configured to look at. pub fn poll_once(&self) { if let Err(error) = client::fetch(&self.store, &self.account, false, false, TIMEOUT) { log_warn!("smolmail fetch failed: {error}"); return; } let Ok(messages) = self.store.mail(self.folder()) else { return; }; drain(messages, |stored| self.relay(stored)); } /// "inbox" (main tier only) when gating on acceptance; "all" includes /// the requests tier too, for a deliberately open bridge. pub fn folder(&self) -> &'static str { if self.config.require_accepted_sender { "inbox" } else { "all" } } fn relay(&self, stored: &Stored) -> Result<(), Error> { let id = hex_prefix(&stored.id); let opened = match client::describe(&self.store, &self.account, stored) { Ok(opened) => opened, Err(error) => { // Undecryptable here means it will never relay: drop the // local copy so it does not sort first on every poll. log_warn!("dropping unreadable {id}: {error}"); self.store.delete_local("inbox", &stored.id)?; return Ok(()); } }; let to_addr = match opened.fields.get(&FRONTMATTER_EMAIL_KEY.to_lowercase()) { Some(addr) => addr, None => { log_info!( "{id} from {from} has no {FRONTMATTER_EMAIL_KEY} field; left for manual review", from = opened.from ); return Ok(()); } }; match smtp_client::send( &self.config, to_addr, subject_of(&opened), &opened.text, &opened.from, ) { Ok(()) => {} // An invalid recipient or an unreachable relay is no reason to // drop or keep retrying forever: the message stays for manual // review, like the Python version's (InvalidAddress, OSError) // catch. Err(SendError::Invalid(address)) => { log_warn!("could not relay {id} to {address:?}: invalid address"); return Ok(()); } Err(SendError::Relay(error)) => { log_warn!("could not relay {id} to {to_addr:?}: {error}"); return Ok(()); } // A CR/LF in a header value means the sender tried to inject // headers into the outbound message: refuse outright, so the // message is left for manual review rather than rewritten. Err(SendError::InvalidHeader(value)) => { return Err(Error::Other(format!( "refusing to relay {id}: header value {value:?} carries CR/LF" ))) } } self.store.delete_local("inbox", &stored.id)?; log_info!("relayed {id} to {to_addr}"); Ok(()) } } fn subject_of(opened: &Described) -> &str { if opened.subject.is_empty() { "(no subject)" } else { &opened.subject } } /// Relays every message, one failure at a time: a message that keeps /// failing is only ever deleted on success, so it would otherwise sort /// first on every poll and jam this entire direction forever. This loop is /// the regression-tested guarantee that it cannot. pub fn drain(messages: Vec, mut relay: impl FnMut(&Stored) -> Result<(), Error>) { for stored in messages { if let Err(error) = relay(&stored) { log_error!( "failed to relay {id}: {error}; left for manual review", id = hex_prefix(&stored.id) ); } } } /// First 16 hex chars of a message id, the form the Python version logged. pub fn hex_prefix(id: &[u8; 32]) -> String { data_encoding::HEXLOWER.encode(&id[..8]) } #[cfg(test)] mod tests { use super::*; fn stub_config(require_accepted_sender: bool) -> Config { Config { smolmail_key: "k".into(), smolmail_db: "d".into(), smolmail_domain: "smol.example".into(), email_domain: "gateway.example".into(), fetch_interval: 30, listen_protocol: crate::config::ListenProtocol::Lmtp, listen_host: "127.0.0.1".into(), listen_port: 2424, lookup_host: "127.0.0.1".into(), lookup_port: 2526, smtp_relay_host: "127.0.0.1".into(), smtp_relay_port: 587, smtp_relay_user: None, smtp_relay_password: None, smtp_relay_starttls: true, gateway_from_address: "gateway@gateway.example".into(), require_accepted_sender, require_accepted_recipient: true, } } fn bridge(require_accepted_sender: bool) -> SmolToEmail { let store = Store::open_in_memory().unwrap(); let account = Account::new([1u8; 32], 0).unwrap(); SmolToEmail::new( Arc::new(store), Arc::new(account), Arc::new(stub_config(require_accepted_sender)), ) } #[test] fn poll_folder_is_main_tier_only_by_default() { // require_accepted_sender=true (the default) must poll "inbox" // (main tier), not "all" -- "all" would include requests-tier mail // from first-contact senders with no accept token (SPEC.md S5.8), // which is exactly what this setting exists to keep the gateway // from relaying. assert_eq!(bridge(true).folder(), "inbox"); } #[test] fn poll_folder_is_all_tiers_when_open_bridge_enabled() { assert_eq!(bridge(false).folder(), "all"); } #[test] fn drain_does_not_stop_after_one_message_fails() { // A message that keeps raising out of relay() (e.g. a header value // smtp_client rejects that relay()'s own handling doesn't cover) // must not block every message behind it -- it always sorts first // on the next poll, since only a successful relay deletes it. let poison = Stored { id: [1u8; 32], envelope: vec![], at: 0, recipient: None, kept: false, }; let ok = Stored { id: [2u8; 32], envelope: vec![], at: 1, recipient: None, kept: false, }; let mut relayed = Vec::new(); drain(vec![poison, ok], |stored| { if stored.id == [1u8; 32] { // A failure relay()'s own handling would not swallow. Err(Error::Other("simulated failure".into())) } else { relayed.push(stored.id); Ok(()) } }); assert_eq!(relayed, vec![[2u8; 32]]); } #[test] fn hex_prefix_is_16_hex_chars() { assert_eq!(hex_prefix(&[0xab; 32]), "abababababababab"); } }