Skip to main content

rullst/
mail.rs

1use async_trait::async_trait;
2
3#[derive(Debug, Clone)]
4/// An email message structure to be sent via a mail driver.
5pub struct Message {
6    /// The recipient email address.
7    pub to: String,
8    /// The subject line of the email.
9    pub subject: String,
10    /// Optional HTML body content.
11    pub body_html: Option<String>,
12    /// Optional plain-text body content.
13    pub body_text: Option<String>,
14    /// Optional sender email address.
15    pub from: Option<String>,
16}
17
18impl Default for Message {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl Message {
25    /// Creates a new, empty `Message`.
26    pub fn new() -> Self {
27        Message {
28            to: String::new(),
29            subject: String::new(),
30            body_html: None,
31            body_text: None,
32            from: None,
33        }
34    }
35
36    /// Sets the recipient email address.
37    pub fn to(mut self, to: impl Into<String>) -> Self {
38        self.to = to.into();
39        self
40    }
41
42    /// Sets the email subject.
43    pub fn subject(mut self, subject: impl Into<String>) -> Self {
44        self.subject = subject.into();
45        self
46    }
47
48    /// Sets the HTML body content.
49    pub fn html(mut self, html: impl Into<String>) -> Self {
50        self.body_html = Some(html.into());
51        self
52    }
53
54    /// Sets the plain-text body content.
55    pub fn text(mut self, text: impl Into<String>) -> Self {
56        self.body_text = Some(text.into());
57        self
58    }
59
60    /// Sets the sender email address.
61    pub fn from(mut self, from: impl Into<String>) -> Self {
62        self.from = Some(from.into());
63        self
64    }
65}
66
67#[derive(Debug)]
68/// Errors that can occur during mail operations.
69pub enum MailError {
70    /// Configuration errors (e.g. missing API keys).
71    ConfigError(String),
72    /// Errors occurred while sending the message.
73    SendError(String),
74    /// Errors related to the driver backend itself.
75    DriverError(String),
76}
77
78impl std::fmt::Display for MailError {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            MailError::ConfigError(err) => write!(f, "Configuration error: {}", err),
82            MailError::SendError(err) => write!(f, "Send error: {}", err),
83            MailError::DriverError(err) => write!(f, "Driver error: {}", err),
84        }
85    }
86}
87
88impl std::error::Error for MailError {}
89
90#[async_trait]
91/// Interface for different email dispatching backends.
92pub trait MailDriver: Send + Sync {
93    /// Dispatches the given email message.
94    async fn send(&self, message: &Message) -> Result<(), MailError>;
95}
96
97/// A driver that outputs emails to the terminal and logs to storage/logs/mail.log
98pub struct LogDriver;
99
100#[async_trait]
101impl MailDriver for LogDriver {
102    async fn send(&self, message: &Message) -> Result<(), MailError> {
103        let log_dir = std::path::Path::new("storage/logs");
104        tokio::fs::create_dir_all(log_dir).await.map_err(|e| {
105            MailError::DriverError(format!("Failed to create log directory: {}", e))
106        })?;
107
108        let log_path = log_dir.join("mail.log");
109        let formatted = format!(
110            "========================================\n[MAIL SENT] {}\nTo: {}\nFrom: {}\nSubject: {}\n----------------------------------------\n[TEXT BODY]\n{}\n----------------------------------------\n[HTML BODY]\n{}\n========================================\n\n",
111            chrono::Local::now().to_rfc3339(),
112            message.to,
113            message.from.as_deref().unwrap_or("noreply@rullst.dev"),
114            message.subject,
115            message.body_text.as_deref().unwrap_or(""),
116            message.body_html.as_deref().unwrap_or("")
117        );
118        println!("{}", formatted);
119
120        // Use spawn_blocking with std::fs to guarantee bytes are flushed to disk
121        // before this function returns. tokio::fs::File's async write_all may
122        // buffer internally, causing read_to_string in tests to see empty content.
123        let log_path_owned = log_path.clone();
124        let formatted_clone = formatted.clone();
125        tokio::task::spawn_blocking(move || {
126            use std::io::Write;
127            let mut file = std::fs::OpenOptions::new()
128                .create(true)
129                .append(true)
130                .open(&log_path_owned)
131                .map_err(|e| MailError::DriverError(format!("Failed to open log file: {}", e)))?;
132            file.write_all(formatted_clone.as_bytes()).map_err(|e| {
133                MailError::DriverError(format!("Failed to write to log file: {}", e))
134            })?;
135            file.flush()
136                .map_err(|e| MailError::DriverError(format!("Failed to flush log file: {}", e)))?;
137            Ok::<(), MailError>(())
138        })
139        .await
140        .map_err(|e| MailError::DriverError(format!("spawn_blocking error: {}", e)))??;
141
142        Ok(())
143    }
144}
145
146/// An SMTP mail driver
147#[cfg(feature = "mail-smtp")]
148pub struct SmtpDriver {
149    /// SMTP server hostname or IP.
150    pub host: String,
151    /// SMTP port (e.g. 587, 465, 25).
152    pub port: u16,
153    /// Optional username for authentication.
154    pub username: Option<String>,
155    /// Optional password for authentication.
156    pub password: Option<String>,
157}
158
159#[cfg(feature = "mail-smtp")]
160#[async_trait]
161impl MailDriver for SmtpDriver {
162    async fn send(&self, message: &Message) -> Result<(), MailError> {
163        use lettre::{
164            AsyncSmtpTransport, AsyncTransport, Message as LettreMessage, Tokio1Executor,
165            transport::smtp::authentication::Credentials,
166        };
167
168        let from_addr = message.from.as_deref().unwrap_or("noreply@rullst.dev");
169        let email_builder = LettreMessage::builder()
170            .from(
171                from_addr
172                    .parse()
173                    .map_err(|e| MailError::SendError(format!("{}", e)))?,
174            )
175            .to(message
176                .to
177                .parse()
178                .map_err(|e| MailError::SendError(format!("{}", e)))?)
179            .subject(&message.subject);
180
181        let email = if let Some(ref html) = message.body_html {
182            if let Some(ref text) = message.body_text {
183                email_builder
184                    .multipart(
185                        lettre::message::MultiPart::alternative()
186                            .singlepart(lettre::message::SinglePart::plain(text.clone()))
187                            .singlepart(lettre::message::SinglePart::html(html.clone())),
188                    )
189                    .map_err(|e| MailError::SendError(format!("{}", e)))?
190            } else {
191                email_builder
192                    .header(lettre::message::header::ContentType::TEXT_HTML)
193                    .body(html.clone())
194                    .map_err(|e| MailError::SendError(format!("{}", e)))?
195            }
196        } else if let Some(ref text) = message.body_text {
197            email_builder
198                .header(lettre::message::header::ContentType::TEXT_PLAIN)
199                .body(text.clone())
200                .map_err(|e| MailError::SendError(format!("{}", e)))?
201        } else {
202            return Err(MailError::SendError("No email body provided".to_string()));
203        };
204
205        let mut builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host)
206            .map_err(|e| MailError::SendError(e.to_string()))?
207            .port(self.port);
208
209        if let (Some(user), Some(pass)) = (&self.username, &self.password) {
210            builder = builder.credentials(Credentials::new(user.clone(), pass.clone()));
211        }
212
213        let transport = builder.build();
214        transport
215            .send(email)
216            .await
217            .map_err(|e| MailError::SendError(format!("{}", e)))?;
218        Ok(())
219    }
220}
221
222/// Placeholder SMTP driver if Cargo feature is not enabled
223#[cfg(not(feature = "mail-smtp"))]
224pub struct SmtpDriver;
225
226#[cfg(not(feature = "mail-smtp"))]
227#[async_trait]
228impl MailDriver for SmtpDriver {
229    async fn send(&self, _message: &Message) -> Result<(), MailError> {
230        Err(MailError::DriverError(
231            "SMTP mailer driver requires the 'mail-smtp' Cargo feature to be enabled".to_string(),
232        ))
233    }
234}
235
236/// A Resend HTTP REST API driver
237pub struct ResendDriver {
238    /// Resend API token.
239    pub api_key: String,
240}
241
242#[async_trait]
243impl MailDriver for ResendDriver {
244    async fn send(&self, message: &Message) -> Result<(), MailError> {
245        static HTTP_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
246        let client = HTTP_CLIENT.get_or_init(reqwest::Client::new);
247
248        let from_addr = message.from.as_deref().unwrap_or("noreply@rullst.dev");
249        let mut body = serde_json::json!({
250            "to": message.to,
251            "from": from_addr,
252            "subject": message.subject,
253        });
254
255        if let Some(ref html) = message.body_html {
256            body["html"] = serde_json::json!(html);
257        }
258        if let Some(ref text) = message.body_text {
259            body["text"] = serde_json::json!(text);
260        }
261
262        let res = client
263            .post("https://api.resend.com/emails")
264            .bearer_auth(&self.api_key)
265            .json(&body)
266            .send()
267            .await
268            .map_err(|e| MailError::SendError(e.to_string()))?;
269
270        if res.status().is_success() {
271            Ok(())
272        } else {
273            let text = res.text().await.unwrap_or_default();
274            Err(MailError::SendError(format!("Resend API error: {}", text)))
275        }
276    }
277}
278
279/// A SendGrid HTTP REST API driver
280pub struct SendGridDriver {
281    /// SendGrid API token.
282    pub api_key: String,
283}
284
285#[async_trait]
286impl MailDriver for SendGridDriver {
287    async fn send(&self, message: &Message) -> Result<(), MailError> {
288        static HTTP_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
289        let client = HTTP_CLIENT.get_or_init(reqwest::Client::new);
290
291        let from_addr = message.from.as_deref().unwrap_or("noreply@rullst.dev");
292
293        let personalizations = vec![serde_json::json!({
294            "to": [{ "email": message.to }]
295        })];
296
297        let mut content = vec![];
298        if let Some(ref text) = message.body_text {
299            content.push(serde_json::json!({
300                "type": "text/plain",
301                "value": text
302            }));
303        }
304        if let Some(ref html) = message.body_html {
305            content.push(serde_json::json!({
306                "type": "text/html",
307                "value": html
308            }));
309        }
310
311        let body = serde_json::json!({
312            "personalizations": personalizations,
313            "from": { "email": from_addr },
314            "subject": message.subject,
315            "content": content
316        });
317
318        let res = client
319            .post("https://api.sendgrid.com/v3/mail/send")
320            .bearer_auth(&self.api_key)
321            .json(&body)
322            .send()
323            .await
324            .map_err(|e| MailError::SendError(e.to_string()))?;
325
326        if res.status().is_success() {
327            Ok(())
328        } else {
329            let text = res.text().await.unwrap_or_default();
330            Err(MailError::SendError(format!(
331                "SendGrid API error: {}",
332                text
333            )))
334        }
335    }
336}
337
338/// The main Mail facade
339pub struct Mail;
340
341impl Mail {
342    /// Send a message using the default configured mail driver
343    pub async fn send(message: Message) -> Result<(), MailError> {
344        let driver = Self::resolve_driver().await?;
345        driver.send(&message).await
346    }
347
348    async fn resolve_driver() -> Result<Box<dyn MailDriver>, MailError> {
349        // Resolve the driver either from env or Rullst.toml
350        let mut driver_name_opt = std::env::var("MAIL_DRIVER").ok();
351
352        if driver_name_opt.is_none() {
353            if let Ok(toml_content) = tokio::fs::read_to_string("Rullst.toml").await {
354                let mut in_mail = false;
355                for line in toml_content.lines() {
356                    let trimmed = line.trim();
357                    if trimmed.starts_with('[') {
358                        in_mail = trimmed == "[mail]" || trimmed == "[mailer]";
359                        continue;
360                    }
361                    if in_mail && trimmed.starts_with("driver") {
362                        if let Some(val) = trimmed.split('=').nth(1) {
363                            let clean_val = val.split('#').next().unwrap_or(val).trim();
364                            driver_name_opt =
365                                Some(clean_val.trim_matches('"').trim_matches('\'').to_string());
366                        }
367                    }
368                }
369            }
370        }
371
372        let driver_name = driver_name_opt.unwrap_or_else(|| "log".to_string());
373
374        match driver_name.as_str() {
375            "log" => Ok(Box::new(LogDriver)),
376            "smtp" => {
377                #[cfg(feature = "mail-smtp")]
378                {
379                    let host =
380                        std::env::var("MAIL_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
381                    let port = std::env::var("MAIL_PORT")
382                        .ok()
383                        .and_then(|p| p.parse().ok())
384                        .unwrap_or(25);
385                    let username = std::env::var("MAIL_USERNAME").ok();
386                    let password = std::env::var("MAIL_PASSWORD").ok();
387
388                    Ok(Box::new(SmtpDriver {
389                        host,
390                        port,
391                        username,
392                        password,
393                    }))
394                }
395                #[cfg(not(feature = "mail-smtp"))]
396                {
397                    Ok(Box::new(SmtpDriver))
398                }
399            }
400            "resend" => {
401                let api_key = std::env::var("RESEND_API_KEY").map_err(|_| {
402                    MailError::ConfigError(
403                        "RESEND_API_KEY environment variable is not set".to_string(),
404                    )
405                })?;
406                Ok(Box::new(ResendDriver { api_key }))
407            }
408            "sendgrid" => {
409                let api_key = std::env::var("SENDGRID_API_KEY").map_err(|_| {
410                    MailError::ConfigError(
411                        "SENDGRID_API_KEY environment variable is not set".to_string(),
412                    )
413                })?;
414                Ok(Box::new(SendGridDriver { api_key }))
415            }
416            other => Err(MailError::ConfigError(format!(
417                "Unknown mail driver: {}",
418                other
419            ))),
420        }
421    }
422}
423
424#[cfg(test)]
425#[allow(clippy::unwrap_used, clippy::expect_used)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_message_subject() {
431        let msg = Message::new().subject("Test Subject");
432        assert_eq!(msg.subject, "Test Subject");
433
434        let msg2 = Message::new().subject(String::from("Another Subject"));
435        assert_eq!(msg2.subject, "Another Subject");
436    }
437
438    #[tokio::test]
439    async fn test_log_driver() {
440        let cwd = std::env::current_dir().unwrap_or_default();
441        // Prepare storage/logs directory
442        let log_path = "storage/logs/mail.log";
443        let _ = std::fs::remove_file(log_path);
444
445        let msg = Message::new()
446            .to("test@rullst.dev")
447            .subject("Hello Test")
448            .text("Testing 1 2 3")
449            .html("<h1>Testing 1 2 3</h1>");
450
451        let driver = LogDriver;
452        if let Err(e) = driver.send(&msg).await {
453            panic!(
454                "driver.send failed! Error: {:?}. CWD: {}. Log Path exists? {}",
455                e,
456                cwd.display(),
457                std::path::Path::new(log_path).exists()
458            );
459        }
460
461        let path = std::path::Path::new(log_path);
462        if !path.exists() {
463            panic!(
464                "Log file does not exist after send! CWD: {}. Expected Path: {}",
465                cwd.display(),
466                path.display()
467            );
468        }
469        let content = std::fs::read_to_string(path).expect("Failed to read log file");
470        if !content.contains("To: test@rullst.dev")
471            || !content.contains("Subject: Hello Test")
472            || !content.contains("Testing 1 2 3")
473        {
474            panic!(
475                "Log file content mismatch! Content was: {:?}. CWD: {}",
476                content,
477                cwd.display()
478            );
479        }
480    }
481
482    #[test]
483    fn test_message_to() {
484        let msg = Message::new().to("user@example.com");
485        assert_eq!(msg.to, "user@example.com");
486    }
487}
488
489#[cfg(test)]
490#[allow(clippy::unwrap_used, clippy::expect_used)]
491mod tests_additional {
492    use super::*;
493    #[tokio::test]
494    async fn test_mail_custom() {
495        let msg = Message::new()
496            .to("a")
497            .from("b")
498            .subject("c")
499            .text("d")
500            .html("e");
501        assert_eq!(msg.to, "a");
502        assert_eq!(msg.from.unwrap(), "b");
503    }
504    #[tokio::test]
505    async fn test_mail_html() {
506        let msg = Message::new().html("h");
507        assert_eq!(msg.body_html.unwrap(), "h");
508    }
509    #[tokio::test]
510    async fn test_mail_subject() {
511        let msg = Message::new().subject("sub");
512        assert_eq!(msg.subject, "sub");
513    }
514    #[tokio::test]
515    async fn test_mail_to() {
516        let msg = Message::new().to("to");
517        assert_eq!(msg.to, "to");
518    }
519    #[tokio::test]
520    async fn test_mail_send() {
521        let msg = Message::new().to("to");
522        assert_eq!(msg.to, "to");
523    }
524    #[tokio::test]
525    async fn test_mail_from() {
526        let msg = Message::new().from("from");
527        assert_eq!(msg.from.unwrap(), "from");
528    }
529    #[tokio::test]
530    async fn test_mail_text() {
531        let msg = Message::new().text("txt");
532        assert_eq!(msg.body_text.unwrap(), "txt");
533    }
534}