Skip to main content

oxios_kernel/
email.rs

1//! SMTP email client — lettre wrapper.
2//!
3//! Sends HTML/plain emails via SMTP. The client is configured once from
4//! `config.toml` `[email]` section and reused across all `send_email` calls.
5//!
6//! ## Providers
7//!
8//! Preset providers auto-fill SMTP host/port/TLS settings:
9//! - `gmail` → smtp.gmail.com:465 / TLS
10//! - `icloud` → smtp.mail.me.com:587 / STARTTLS
11//! - `fastmail` → smtp.fastmail.com:465 / TLS
12//! - `resend` → smtp.resend.com:587 / STARTTLS (API key as password)
13//! - `custom` → manual host/port/tls required
14
15use std::sync::Arc;
16
17use chrono::Utc;
18use lettre::message::header::ContentType;
19use lettre::message::MultiPart;
20use lettre::message::SinglePart;
21use lettre::transport::smtp::authentication::Credentials;
22use lettre::AsyncTransport;
23use lettre::Message;
24use lettre::Tokio1Executor;
25use serde::{Deserialize, Serialize};
26
27use crate::config::EmailConfig;
28
29/// SMTP transport type.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SmtpTls {
33    /// Implicit TLS (port 465).
34    Tls,
35    /// STARTTLS upgrade (port 587).
36    StartTls,
37}
38
39/// Preset SMTP provider configurations.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum SmtpProvider {
43    /// Gmail (smtp.gmail.com:465, TLS).
44    Gmail,
45    /// iCloud (smtp.mail.me.com:587, STARTTLS).
46    Icloud,
47    /// Fastmail (smtp.fastmail.com:465, TLS).
48    Fastmail,
49    /// Resend (smtp.resend.com:587, STARTTLS).
50    /// Uses API key as SMTP password; username is always `resend`.
51    Resend,
52    /// Custom SMTP server (manual host/port/tls).
53    Custom,
54}
55
56impl SmtpProvider {
57    /// Return the default host, port, and TLS mode for this provider.
58    pub fn defaults(&self) -> (&'static str, u16, SmtpTls) {
59        match self {
60            SmtpProvider::Gmail => ("smtp.gmail.com", 465, SmtpTls::Tls),
61            SmtpProvider::Icloud => ("smtp.mail.me.com", 587, SmtpTls::StartTls),
62            SmtpProvider::Fastmail => ("smtp.fastmail.com", 465, SmtpTls::Tls),
63            SmtpProvider::Resend => ("smtp.resend.com", 587, SmtpTls::StartTls),
64            SmtpProvider::Custom => ("", 0, SmtpTls::Tls),
65        }
66    }
67}
68
69/// SMTP transport wrapper.
70type SmtpTransport = lettre::AsyncSmtpTransport<Tokio1Executor>;
71
72/// Receipt returned after a successful send.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SendReceipt {
75    /// SMTP message ID (from server response).
76    pub message_id: String,
77    /// Timestamp of successful send.
78    pub sent_at: chrono::DateTime<Utc>,
79}
80
81/// SMTP client — wraps lettre for sending emails.
82///
83/// Thread-safe via `Arc<SmtpTransport>`. Created once during kernel init
84/// from `[email]` config and stored in `EmailApi`.
85pub struct SmtpClient {
86    transport: Arc<SmtpTransport>,
87    from: String,
88    default_to: String,
89}
90
91impl std::fmt::Debug for SmtpClient {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("SmtpClient")
94            .field("from", &self.from)
95            .field("default_to", &self.default_to)
96            .finish()
97    }
98}
99
100impl SmtpClient {
101    /// Build an `SmtpClient` from config and credentials.
102    ///
103    /// `password` is the SMTP auth password (app password for Gmail).
104    /// It is never stored beyond the lettre transport internals.
105    pub fn from_config(config: &EmailConfig, password: &str) -> anyhow::Result<Self> {
106        let (default_host, default_port, default_tls) = config.provider().defaults();
107
108        let host = if config.host.is_empty() {
109            default_host
110        } else {
111            &config.host
112        };
113        let port = if config.port == 0 {
114            default_port
115        } else {
116            config.port
117        };
118        let tls_mode = config.tls.unwrap_or(default_tls);
119
120        let user = match config.provider {
121            SmtpProvider::Resend => "resend".to_string(),
122            _ => {
123                if config.user.is_empty() {
124                    config.my_email.clone()
125                } else {
126                    config.user.clone()
127                }
128            }
129        };
130
131        anyhow::ensure!(!host.is_empty(), "SMTP host is required");
132        anyhow::ensure!(port > 0, "SMTP port is required");
133
134        let creds = Credentials::new(user.clone(), password.to_string());
135
136        let transport = match tls_mode {
137            SmtpTls::Tls => {
138                // Implicit TLS (port 465)
139                lettre::AsyncSmtpTransport::<Tokio1Executor>::relay(host)?
140                    .port(port)
141                    .credentials(creds)
142                    .build()
143            }
144            SmtpTls::StartTls => {
145                // STARTTLS (port 587)
146                lettre::AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)?
147                    .port(port)
148                    .credentials(creds)
149                    .build()
150            }
151        };
152
153        Ok(Self {
154            transport: Arc::new(transport),
155            from: config.my_email.clone(),
156            default_to: config.my_email.clone(),
157        })
158    }
159
160    /// Send an email.
161    ///
162    /// In v1, `to` is ignored — always sends to `default_to` (the user's own email).
163    /// If `text` is `None`, a minimal plain-text fallback is generated from the subject.
164    pub async fn send(
165        &self,
166        _to: &str,
167        subject: &str,
168        html: &str,
169        text: Option<&str>,
170    ) -> anyhow::Result<SendReceipt> {
171        let text_body = text
172            .map(|s| s.to_string())
173            .unwrap_or_else(|| subject.to_string());
174
175        let email = Message::builder()
176            .from(self.from.parse()?)
177            .to(self.default_to.parse()?)
178            .subject(subject)
179            .multipart(
180                MultiPart::alternative()
181                    .singlepart(
182                        SinglePart::builder()
183                            .header(ContentType::TEXT_PLAIN)
184                            .body(text_body),
185                    )
186                    .singlepart(
187                        SinglePart::builder()
188                            .header(ContentType::TEXT_HTML)
189                            .body(html.to_string()),
190                    ),
191            )?;
192
193        let _response = self.transport.send(email).await?;
194        let message_id = format!("<{}>", uuid::Uuid::new_v4());
195
196        Ok(SendReceipt {
197            message_id,
198            sent_at: Utc::now(),
199        })
200    }
201
202    /// Test the SMTP connection by sending a simple test email.
203    pub async fn test_connection(&self) -> anyhow::Result<()> {
204        let email = Message::builder()
205            .from(self.from.parse()?)
206            .to(self.default_to.parse()?)
207            .subject("Oxios Email Test")
208            .body("If you see this, Oxios email is working.".to_string())?;
209
210        self.transport.send(email).await?;
211        Ok(())
212    }
213
214    /// The "from" address (user's own email).
215    pub fn from_addr(&self) -> &str {
216        &self.from
217    }
218
219    /// The default "to" address (user's own email, same as from in v1).
220    pub fn default_to(&self) -> &str {
221        &self.default_to
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_provider_defaults() {
231        let (host, port, tls) = SmtpProvider::Gmail.defaults();
232        assert_eq!(host, "smtp.gmail.com");
233        assert_eq!(port, 465);
234        assert_eq!(tls, SmtpTls::Tls);
235
236        let (host, port, tls) = SmtpProvider::Icloud.defaults();
237        assert_eq!(host, "smtp.mail.me.com");
238        assert_eq!(port, 587);
239        assert_eq!(tls, SmtpTls::StartTls);
240
241        let (host, port, tls) = SmtpProvider::Fastmail.defaults();
242        assert_eq!(host, "smtp.fastmail.com");
243        assert_eq!(port, 465);
244        assert_eq!(tls, SmtpTls::Tls);
245
246        let (host, port, tls) = SmtpProvider::Resend.defaults();
247        assert_eq!(host, "smtp.resend.com");
248        assert_eq!(port, 587);
249        assert_eq!(tls, SmtpTls::StartTls);
250
251        let (host, port, _) = SmtpProvider::Custom.defaults();
252        assert!(host.is_empty());
253        assert_eq!(port, 0);
254    }
255}