1use async_trait::async_trait;
2
3#[derive(Debug, Clone)]
4pub struct Message {
6 pub to: String,
8 pub subject: String,
10 pub body_html: Option<String>,
12 pub body_text: Option<String>,
14 pub from: Option<String>,
16}
17
18impl Default for Message {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl Message {
25 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 pub fn to(mut self, to: impl Into<String>) -> Self {
38 self.to = to.into();
39 self
40 }
41
42 pub fn subject(mut self, subject: impl Into<String>) -> Self {
44 self.subject = subject.into();
45 self
46 }
47
48 pub fn html(mut self, html: impl Into<String>) -> Self {
50 self.body_html = Some(html.into());
51 self
52 }
53
54 pub fn text(mut self, text: impl Into<String>) -> Self {
56 self.body_text = Some(text.into());
57 self
58 }
59
60 pub fn from(mut self, from: impl Into<String>) -> Self {
62 self.from = Some(from.into());
63 self
64 }
65}
66
67#[derive(Debug)]
68pub enum MailError {
70 ConfigError(String),
72 SendError(String),
74 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]
91pub trait MailDriver: Send + Sync {
93 async fn send(&self, message: &Message) -> Result<(), MailError>;
95}
96
97pub 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 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#[cfg(feature = "mail-smtp")]
148pub struct SmtpDriver {
149 pub host: String,
151 pub port: u16,
153 pub username: Option<String>,
155 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#[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
236pub struct ResendDriver {
238 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
279pub struct SendGridDriver {
281 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
338pub struct Mail;
340
341impl Mail {
342 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 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 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}