//! Outgoing mail: the SMTP settings, the outbox states, the retry schedule and the send error. //! The store functions live in store/mail.rs; the transports live in forge-web (mail/). use std::fmt; use std::time::Duration; /// Tries per message: one first try and five retries. Once the last try fails, the message gives up. pub const MAX_ATTEMPTS: u32 = 6; /// The wait after the first, second, ... failed try. Five waits serve five retries. const BACKOFF: [Duration; 5] = [ Duration::from_secs(60), Duration::from_secs(5 * 60), Duration::from_secs(30 * 60), Duration::from_secs(2 * 60 * 60), Duration::from_secs(12 * 60 * 60), ]; /// The wait before the next try, given that `attempts` tries have failed. `None` means give up. pub fn retry_delay(attempts: u32) -> Option { if attempts == 0 || attempts >= MAX_ATTEMPTS { return None; } BACKOFF.get((attempts - 1) as usize).copied() } /// How the SMTP connection is secured. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Security { /// Plain connection upgraded with STARTTLS (port 587 usually). StartTls, /// TLS from the first byte (port 465 usually). Tls, /// No encryption. Only for a relay on a trusted network. Plain, } impl Security { pub fn as_str(self) -> &'static str { match self { Security::StartTls => "starttls", Security::Tls => "tls", Security::Plain => "none", } } pub fn parse(s: &str) -> Option { match s { "starttls" => Some(Security::StartTls), "tls" => Some(Security::Tls), "none" => Some(Security::Plain), _ => None, } } } /// The stored SMTP settings. The password is never part of this value; `has_password` says whether /// one is stored. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MailSettings { pub host: String, pub port: u16, pub security: Security, pub username: String, pub from_addr: String, pub from_name: String, pub enabled: bool, pub has_password: bool, pub updated_at: Option, } /// A change to the SMTP settings. `password: None` keeps the stored password; `Some` replaces it. pub struct MailSettingsUpdate<'a> { pub host: &'a str, pub port: u16, pub security: Security, pub username: &'a str, pub from_addr: &'a str, pub from_name: &'a str, pub enabled: bool, pub password: Option<&'a str>, } impl fmt::Debug for MailSettingsUpdate<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("MailSettingsUpdate") .field("host", &self.host) .field("port", &self.port) .field("security", &self.security) .field("username", &self.username) .field("from_addr", &self.from_addr) .field("from_name", &self.from_name) .field("enabled", &self.enabled) .field("password", &self.password.map(|_| "")) .finish() } } /// The decrypted SMTP password. Its Debug output is redacted. #[derive(Clone, PartialEq, Eq)] pub struct MailPassword(String); impl MailPassword { pub fn new(value: String) -> MailPassword { MailPassword(value) } pub fn expose(&self) -> &str { &self.0 } } impl fmt::Debug for MailPassword { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("MailPassword()") } } /// One message to send. Its Debug output omits the body, which can carry one-time links. #[derive(Clone, PartialEq, Eq)] pub struct OutgoingMail { pub to: String, pub subject: String, pub body: String, /// `Message-ID` header, in `<...>` form. `None` for mail that is not part of a thread. pub message_id: Option, /// `In-Reply-To` and `References` headers, in `<...>` form. pub in_reply_to: Option, } impl fmt::Debug for OutgoingMail { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("OutgoingMail") .field("to", &self.to) .field("subject", &self.subject) .field("body", &"") .finish() } } /// One outbox row. The body is kept for the worker; the admin list never shows it. #[derive(Clone, PartialEq, Eq)] pub struct OutboxEntry { pub id: String, pub idem_key: String, pub to_addr: String, pub subject: String, pub body: String, pub message_id: Option, pub in_reply_to: Option, pub created_at: String, /// Tries made so far. pub attempts: u32, pub next_attempt_at: Option, pub sent_at: Option, pub last_error: Option, } impl fmt::Debug for OutboxEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("OutboxEntry") .field("id", &self.id) .field("to_addr", &self.to_addr) .field("subject", &self.subject) .field("body", &"") .field("attempts", &self.attempts) .field("next_attempt_at", &self.next_attempt_at) .field("sent_at", &self.sent_at) .field("last_error", &self.last_error) .finish() } } /// Where a message stands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutboxStatus { Sent, Queued, GaveUp, } impl OutboxStatus { pub fn label(self) -> &'static str { match self { OutboxStatus::Sent => "sent", OutboxStatus::Queued => "queued", OutboxStatus::GaveUp => "gave up", } } } impl OutboxEntry { pub fn status(&self) -> OutboxStatus { if self.sent_at.is_some() { OutboxStatus::Sent } else if self.next_attempt_at.is_some() { OutboxStatus::Queued } else { OutboxStatus::GaveUp } } } /// Why a send failed. A transient failure is retried on the schedule; a permanent one gives up now. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MailError { Transient(String), Permanent(String), } impl fmt::Display for MailError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { MailError::Transient(m) | MailError::Permanent(m) => f.write_str(m), } } } /// Checks the SMTP settings before they are stored. `enabled` needs a host and a sender. pub fn validate_settings(update: &MailSettingsUpdate<'_>) -> crate::Result<()> { use crate::Error; let bad = |m: &str| Err(Error::Invalid(m.to_owned())); if update.port == 0 { return bad("the SMTP port must be between 1 and 65535"); } if update.host.chars().count() > 253 || update .host .chars() .any(|c| c.is_whitespace() || c.is_control() || c == '/') { return bad("the SMTP host is not a host name"); } if update.username.chars().count() > 256 || update.username.chars().any(char::is_control) { return bad("the SMTP username is too long or has control characters"); } if update.from_name.chars().count() > 100 || update.from_name.chars().any(char::is_control) { return bad("the sender name must be at most 100 characters with no control characters"); } if !update.from_addr.is_empty() { crate::store::validate_email(update.from_addr)?; } if update.enabled { if update.host.is_empty() { return bad("set the SMTP host before turning mail on"); } if update.from_addr.is_empty() { return bad("set the sender address before turning mail on"); } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn retry_schedule_is_five_waits_then_give_up() { let mins = |n: u32| retry_delay(n).map(|d| d.as_secs() / 60); assert_eq!(retry_delay(0), None, "no try has failed yet"); assert_eq!(mins(1), Some(1)); assert_eq!(mins(2), Some(5)); assert_eq!(mins(3), Some(30)); assert_eq!(mins(4), Some(120)); assert_eq!(mins(5), Some(720)); assert_eq!(mins(6), None, "the sixth failed try gives up"); assert_eq!(MAX_ATTEMPTS, 6); } #[test] fn security_round_trips_and_rejects_unknown_names() { for s in [Security::StartTls, Security::Tls, Security::Plain] { assert_eq!(Security::parse(s.as_str()), Some(s)); } assert_eq!(Security::parse("ssl"), None); } #[test] fn debug_output_never_shows_the_password_or_the_body() { let pw = MailPassword::new("hunter2-smtp".into()); assert!(!format!("{pw:?}").contains("hunter2")); let update = MailSettingsUpdate { host: "smtp.example", port: 587, security: Security::StartTls, username: "u", from_addr: "forge@example.com", from_name: "", enabled: true, password: Some("hunter2-smtp"), }; let shown = format!("{update:?}"); assert!(!shown.contains("hunter2")); assert!(shown.contains("")); let mail = OutgoingMail { to: "a@example.com".into(), subject: "Reset".into(), body: "https://forge.test/reset/SECRET-TOKEN".into(), message_id: None, in_reply_to: None, }; assert!(!format!("{mail:?}").contains("SECRET-TOKEN")); } #[test] fn validation_requires_host_and_sender_before_enabling() { let base = MailSettingsUpdate { host: "smtp.example", port: 587, security: Security::StartTls, username: "", from_addr: "forge@example.com", from_name: "forge", enabled: true, password: None, }; assert!(validate_settings(&base).is_ok()); let no_host = MailSettingsUpdate { host: "", ..base }; assert!(validate_settings(&no_host).is_err()); let off = MailSettingsUpdate { host: "", enabled: false, ..base }; assert!( validate_settings(&off).is_ok(), "settings may be saved while off" ); let no_port = MailSettingsUpdate { port: 0, ..base }; assert!(validate_settings(&no_port).is_err()); let bad_host = MailSettingsUpdate { host: "smtp.example/evil", ..base }; assert!(validate_settings(&bad_host).is_err()); let bad_from = MailSettingsUpdate { from_addr: "not-an-address", ..base }; assert!(validate_settings(&bad_from).is_err()); } #[test] fn status_follows_sent_at_and_next_attempt() { let mut entry = OutboxEntry { id: "1".into(), idem_key: "k".into(), to_addr: "a@example.com".into(), subject: "s".into(), body: "b".into(), message_id: None, in_reply_to: None, created_at: "t".into(), attempts: 0, next_attempt_at: Some("t".into()), sent_at: None, last_error: None, }; assert_eq!(entry.status(), OutboxStatus::Queued); entry.next_attempt_at = None; assert_eq!(entry.status(), OutboxStatus::GaveUp); entry.sent_at = Some("t".into()); assert_eq!(entry.status(), OutboxStatus::Sent); } }