//! SMTP client for sending email messages. //! //! Supports password and OAuth2 (XOAUTH2) authentication, with //! optional STARTTLS. Used by the email commands to send replies //! and compose new messages through the user's configured account. use goingson_core::EmailAccount; use lettre::{ message::{ header::ContentType, Attachment, MultiPart, SinglePart, }, transport::smtp::authentication::{Credentials, Mechanism}, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; /// Process a plain text body for format=flowed (RFC 3676). /// /// - Wraps long lines at 72 chars with trailing space (soft line break) /// - Space-stuffs lines starting with `>`, `From `, or a space /// - Preserves intentional line breaks (no trailing space) /// - Leaves quoted lines and signature separator unchanged fn format_flowed(body: &str) -> String { const MAX_LINE: usize = 72; let mut out = String::with_capacity(body.len() + body.len() / 8); for line in body.split('\n') { // Signature separator: preserve exactly as-is if line == "-- " { out.push_str(line); out.push('\n'); continue; } // Quoted lines: space-stuff but don't rewrap (already formatted by frontend) if line.starts_with('>') { out.push(' '); out.push_str(line); out.push('\n'); continue; } // Space-stuff lines starting with "From " or a space let needs_stuffing = line.starts_with("From ") || line.starts_with(' '); // Short lines: no wrapping needed let effective_len = line.len() + if needs_stuffing { 1 } else { 0 }; if effective_len <= MAX_LINE || line.trim().is_empty() { if needs_stuffing { out.push(' '); } out.push_str(line); out.push('\n'); continue; } // Wrap long lines with soft breaks (trailing space) let prefix = if needs_stuffing { " " } else { "" }; let mut remaining = line; while !remaining.is_empty() { let budget = MAX_LINE - prefix.len(); if remaining.len() <= budget { // Last segment: no trailing space (hard break) out.push_str(prefix); out.push_str(remaining); out.push('\n'); break; } // `budget` is a byte count, but `remaining` may contain multibyte // characters, so slicing at `budget` can land mid-character and // panic. Back off to the largest char boundary at or below budget. let mut boundary = budget; while boundary > 0 && !remaining.is_char_boundary(boundary) { boundary -= 1; } // A single character wider than the budget would leave boundary at // 0 and stall the loop; take that whole first character instead so // we always make forward progress. if boundary == 0 { boundary = remaining .char_indices() .nth(1) .map_or(remaining.len(), |(i, _)| i); } // Find the last space within the boundary for a clean word break. // `rfind(' ')` returns an ASCII byte index, so `+ 1` stays on a // char boundary; the fallback `boundary` is a boundary by construction. let break_at = remaining[..boundary] .rfind(' ') .map(|i| i + 1) // include the space in this line .unwrap_or(boundary); // no space found, hard break at boundary let (chunk, rest) = remaining.split_at(break_at); out.push_str(prefix); out.push_str(chunk); // Trailing space signals a soft line break (format=flowed) if !chunk.ends_with(' ') { out.push(' '); } out.push('\n'); remaining = rest; } } // Remove the final trailing newline if the original didn't have one if !body.ends_with('\n') && out.ends_with('\n') { out.pop(); } out } /// A file to attach to an outbound email. pub struct AttachmentFile { pub filename: String, pub mime_type: String, pub data: Vec, } /// Parameters for sending an email message. pub struct SendParams<'a> { pub to: &'a str, pub cc: Option<&'a str>, pub bcc: Option<&'a str>, pub subject: &'a str, pub body: &'a str, pub in_reply_to: Option<&'a str>, pub references: Option<&'a str>, pub attachments: Vec, } /// Authentication method for SMTP #[derive(Debug, Clone)] pub enum SmtpAuth { /// Traditional username/password Password { username: String, password: String }, /// OAuth2 XOAUTH2 mechanism XOAuth2 { email: String, access_token: String, }, } pub struct SmtpClient { server: String, port: u16, auth: SmtpAuth, from_address: String, } impl SmtpClient { /// Create an SMTP client with explicit password authentication. /// /// Use this when retrieving credentials from secure storage (keychain). pub fn with_password(account: &EmailAccount, password: &str) -> Self { Self { server: account.smtp_server.clone(), port: account.smtp_port as u16, auth: SmtpAuth::Password { username: account.username.clone(), password: password.to_string(), }, from_address: account.email_address.clone(), } } /// Create an SMTP client with OAuth2 XOAUTH2 authentication pub fn with_oauth( server: &str, port: u16, email: &str, access_token: &str, ) -> Self { Self { server: server.to_string(), port, auth: SmtpAuth::XOAuth2 { email: email.to_string(), access_token: access_token.to_string(), }, from_address: email.to_string(), } } /// Builds an SMTP transport with a mandatory encryption floor. /// /// The transport always negotiates STARTTLS, so credentials and OAuth /// access tokens are never transmitted over an unencrypted connection. A /// server that does not offer STARTTLS fails the handshake rather than /// silently downgrading to cleartext (the previous `builder_dangerous` /// path, which leaked secrets whenever the account had TLS unticked). fn build_mailer(&self) -> Result, String> { let builder = AsyncSmtpTransport::::starttls_relay(&self.server) .map_err(|e| format!("SMTP relay error: {}", e))? .port(self.port); let mailer = match &self.auth { SmtpAuth::Password { username, password } => builder .credentials(Credentials::new(username.clone(), password.clone())) .build(), SmtpAuth::XOAuth2 { email, access_token } => builder // For XOAUTH2 the email is the username and the access token is // the secret, negotiated with the Xoauth2 mechanism. .credentials(Credentials::new(email.clone(), access_token.clone())) .authentication(vec![Mechanism::Xoauth2]) .build(), }; Ok(mailer) } #[tracing::instrument(skip_all)] pub async fn send_message(&self, params: &SendParams<'_>) -> Result { // Generate message ID before building so sent email and local DB agree let message_id = format!( "<{}.{}@{}>", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp(), self.server ); let mut builder = Message::builder() .message_id(Some(message_id.clone())) .from( self.from_address .parse() .map_err(|e| format!("Invalid from address: {}", e))?, ) .subject(params.subject); // To recipients (comma-separated) for addr in params.to.split(',').map(str::trim).filter(|a| !a.is_empty()) { builder = builder.to(addr.parse().map_err(|e| format!("Invalid to address '{}': {}", addr, e))?); } // CC recipients if let Some(cc) = params.cc { for addr in cc.split(',').map(str::trim).filter(|a| !a.is_empty()) { builder = builder.cc(addr.parse().map_err(|e| format!("Invalid CC address '{}': {}", addr, e))?); } } // BCC recipients if let Some(bcc) = params.bcc { for addr in bcc.split(',').map(str::trim).filter(|a| !a.is_empty()) { builder = builder.bcc(addr.parse().map_err(|e| format!("Invalid BCC address '{}': {}", addr, e))?); } } if let Some(irt) = params.in_reply_to { builder = builder.in_reply_to(irt.to_string()); } if let Some(refs) = params.references { builder = builder.references(refs.to_string()); } let flowed_body = format_flowed(params.body); let flowed_ct = ContentType::parse("text/plain; charset=UTF-8; format=flowed") .unwrap_or(ContentType::TEXT_PLAIN); let email = if params.attachments.is_empty() { builder .header(flowed_ct) .body(flowed_body) .map_err(|e| format!("Failed to build email: {}", e))? } else { let body_part = SinglePart::builder() .content_type(flowed_ct) .body(flowed_body); let mut multipart = MultiPart::mixed().singlepart(body_part); for file in ¶ms.attachments { let content_type = file.mime_type.parse::() .or_else(|_| "application/octet-stream".parse::()) .map_err(|e| format!("Invalid attachment content type: {}", e))?; let attachment = Attachment::new(file.filename.clone()) .body(file.data.clone(), content_type); multipart = multipart.singlepart(attachment); } builder .multipart(multipart) .map_err(|e| format!("Failed to build email: {}", e))? }; let mailer = self.build_mailer()?; mailer .send(email) .await .map_err(|e| format!("Failed to send email: {}", e))?; Ok(message_id) } #[tracing::instrument(skip_all)] pub async fn test_connection(&self) -> Result<(), String> { let mailer = self.build_mailer()?; mailer .test_connection() .await .map_err(|e| format!("SMTP connection test failed: {}", e))?; Ok(()) } } #[cfg(test)] mod tests { use super::format_flowed; #[test] fn short_ascii_line_unchanged() { assert_eq!(format_flowed("hello world"), "hello world"); } #[test] fn signature_separator_preserved() { assert_eq!(format_flowed("body\n-- \nsig"), "body\n-- \nsig"); } #[test] fn long_ascii_line_wraps_with_soft_break() { let line = "word ".repeat(20); // 100 chars, spaces every 5 let out = format_flowed(line.trim_end()); // Content per wrapped segment stays within 72; format=flowed may append // one trailing soft-break space, so the emitted line is content + 1. for seg in out.split('\n') { assert!(seg.len() <= 73, "segment too long: {:?}", seg); } } #[test] fn long_multibyte_line_does_not_panic() { // A line of CJK characters (3 bytes each) well over 72 bytes with no // ASCII space: the old byte-index slice panicked on the char boundary. let line = "の".repeat(60); // 180 bytes let out = format_flowed(&line); // Output must reconstruct to the same characters (sans soft-break spaces // and newlines) and never split a character. let stripped: String = out.chars().filter(|c| *c != '\n' && *c != ' ').collect(); assert_eq!(stripped, line); } #[test] fn long_emoji_line_does_not_panic() { // Emoji are 4 bytes; a long run with no space is the worst case. let line = "😀".repeat(40); // 160 bytes let out = format_flowed(&line); let stripped: String = out.chars().filter(|c| *c != '\n' && *c != ' ').collect(); assert_eq!(stripped, line); } #[test] fn mixed_multibyte_with_spaces_word_breaks() { let line = format!("{} {} {}", "あ".repeat(30), "い".repeat(30), "う".repeat(30)); let out = format_flowed(&line); // Content per segment <= 72 bytes; allow up to 2 extra bytes for a // trailing soft-break space plus the word-break space included in chunk. for seg in out.split('\n') { assert!(seg.len() <= 74, "segment too long: {} bytes", seg.len()); } // No character was split: reconstruction yields the original text. let stripped: String = out.replace('\n', ""); assert!(stripped.contains(&"あ".repeat(24))); } }