//! 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; } // Find the last space within budget for a clean word break let break_at = remaining[..budget] .rfind(' ') .map(|i| i + 1) // include the space in this line .unwrap_or(budget); // no space found, hard break at budget 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, use_tls: bool, } impl SmtpClient { /// Create an SMTP client with password authentication. /// /// Note: Prefer `with_password()` for secure credential handling. /// This method reads the password from the account struct (legacy). pub fn new(account: &EmailAccount) -> Self { Self { server: account.smtp_server.clone(), port: account.smtp_port as u16, auth: SmtpAuth::Password { username: account.username.clone(), password: account.password.clone(), }, from_address: account.email_address.clone(), use_tls: account.use_tls, } } /// 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(), use_tls: account.use_tls, } } /// Create an SMTP client with OAuth2 XOAUTH2 authentication pub fn with_oauth( server: &str, port: u16, email: &str, access_token: &str, use_tls: bool, ) -> 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(), use_tls, } } fn build_mailer(&self) -> Result, String> { match &self.auth { SmtpAuth::Password { username, password } => { let creds = Credentials::new(username.clone(), password.clone()); if self.use_tls { Ok(AsyncSmtpTransport::::starttls_relay(&self.server) .map_err(|e| format!("SMTP relay error: {}", e))? .credentials(creds) .port(self.port) .build()) } else { Ok(AsyncSmtpTransport::::builder_dangerous(&self.server) .credentials(creds) .port(self.port) .build()) } } SmtpAuth::XOAuth2 { email, access_token } => { // For XOAUTH2, we use the email as username and access_token as password // with the Xoauth2 mechanism let creds = Credentials::new(email.clone(), access_token.clone()); if self.use_tls { Ok(AsyncSmtpTransport::::starttls_relay(&self.server) .map_err(|e| format!("SMTP relay error: {}", e))? .credentials(creds) .authentication(vec![Mechanism::Xoauth2]) .port(self.port) .build()) } else { Ok(AsyncSmtpTransport::::builder_dangerous(&self.server) .credentials(creds) .authentication(vec![Mechanism::Xoauth2]) .port(self.port) .build()) } } } } 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) } 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(()) } }