1use crate::address::Address;
8use crate::config::{MailConfig, TransportKind};
9use crate::fake::Fake;
10use crate::mailable::Mailable;
11use crate::message::Message;
12use crate::smtp::{SmtpClient, SmtpConfig};
13use rustlavel_core::events::Event;
14use rustlavel_core::{Config, Error, Result};
15use rustlavel_view::Engine;
16use std::future::Future;
17use std::path::{Path, PathBuf};
18use std::pin::Pin;
19use std::sync::Arc;
20use std::time::Instant;
21
22pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
27
28pub trait Transport: Send + Sync + 'static {
30 fn name(&self) -> &'static str;
32
33 fn send<'a>(&'a self, message: &'a Message) -> BoxFuture<'a, Result<()>>;
34}
35
36pub struct SmtpTransport {
42 settings: SmtpConfig,
43}
44
45impl SmtpTransport {
46 pub fn new(settings: SmtpConfig) -> SmtpTransport {
47 SmtpTransport { settings }
48 }
49}
50
51impl Transport for SmtpTransport {
52 fn name(&self) -> &'static str {
53 "smtp"
54 }
55
56 fn send<'a>(&'a self, message: &'a Message) -> BoxFuture<'a, Result<()>> {
57 Box::pin(async move {
58 let from = message
59 .sender()
60 .ok_or_else(|| Error::msg("this message has no From address"))?
61 .email()
62 .to_string();
63 let recipients = message.envelope_recipients();
64 let data = message.to_smtp_data()?;
65
66 let mut client = SmtpClient::connect(&self.settings).await?;
67 let result = client.send_message(&from, &recipients, &data).await;
68 client.quit().await;
69 result
70 })
71 }
72}
73
74pub struct LogTransport {
79 write: bool,
81}
82
83impl Default for LogTransport {
84 fn default() -> Self {
85 LogTransport::new()
86 }
87}
88
89impl LogTransport {
90 pub fn new() -> LogTransport {
91 LogTransport { write: true }
92 }
93
94 pub fn silent() -> LogTransport {
96 LogTransport { write: false }
97 }
98}
99
100impl Transport for LogTransport {
101 fn name(&self) -> &'static str {
102 "log"
103 }
104
105 fn send<'a>(&'a self, message: &'a Message) -> BoxFuture<'a, Result<()>> {
106 Box::pin(async move {
107 let _ = message.to_mime()?;
111
112 if self.write {
113 let body = message
121 .text_body()
122 .or_else(|| message.html_body())
123 .unwrap_or("(no body)");
124
125 rustlavel_core::info!(
126 "mail to {}\nSubject: {}\n\n{body}",
127 message.envelope_recipients().join(", "),
128 message.subject_text()
129 );
130 }
131 Ok(())
132 })
133 }
134}
135
136pub struct FileTransport {
142 directory: PathBuf,
143}
144
145impl FileTransport {
146 pub fn new(directory: impl Into<PathBuf>) -> FileTransport {
147 FileTransport { directory: directory.into() }
148 }
149
150 pub fn directory(&self) -> &Path {
151 &self.directory
152 }
153}
154
155impl Transport for FileTransport {
156 fn name(&self) -> &'static str {
157 "file"
158 }
159
160 fn send<'a>(&'a self, message: &'a Message) -> BoxFuture<'a, Result<()>> {
161 Box::pin(async move {
162 let mime = message.to_mime()?;
163
164 std::fs::create_dir_all(&self.directory).map_err(|error| {
165 Error::msg(format!(
166 "cannot write mail to `{}`: {error}. Check mail.path.",
167 self.directory.display()
168 ))
169 })?;
170
171 let path = self.directory.join(filename(message));
172 std::fs::write(&path, mime).map_err(|error| {
173 Error::msg(format!("cannot write `{}`: {error}", path.display()))
174 })?;
175
176 rustlavel_core::debug!("mail written to {}", path.display());
177 Ok(())
178 })
179 }
180}
181
182fn filename(message: &Message) -> String {
184 let slug: String = message
185 .subject_text()
186 .chars()
187 .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
188 .collect::<String>()
189 .trim_matches('-')
190 .split('-')
191 .filter(|part| !part.is_empty())
192 .collect::<Vec<_>>()
193 .join("-");
194
195 let slug = if slug.is_empty() { "message".to_string() } else { slug.chars().take(60).collect() };
196 format!("{}-{slug}.eml", crate::encode::unique_token())
197}
198
199#[derive(Clone)]
204pub struct Mailer {
205 transport: Arc<dyn Transport>,
206 from: Option<Address>,
207 fake: Option<Arc<Fake>>,
208}
209
210impl Mailer {
211 pub fn new(transport: impl Transport) -> Mailer {
212 Mailer { transport: Arc::new(transport), from: None, fake: None }
213 }
214
215 pub fn from_config(config: &Config) -> Result<Mailer> {
220 Mailer::build(&MailConfig::from_app_config(config)?)
221 }
222
223 pub fn build(settings: &MailConfig) -> Result<Mailer> {
224 let transport: Arc<dyn Transport> = match settings.transport {
225 TransportKind::Smtp => Arc::new(SmtpTransport::new(settings.smtp())),
226 TransportKind::Log => Arc::new(LogTransport::new()),
227 TransportKind::File => Arc::new(FileTransport::new(settings.path.clone())),
228 };
229
230 Ok(Mailer { transport, from: settings.from()?, fake: None })
231 }
232
233 pub fn from(mut self, address: Address) -> Mailer {
235 self.from = Some(address);
236 self
237 }
238
239 pub fn faking(mut self, fake: Fake) -> Mailer {
241 let fake = Arc::new(fake);
242 self.fake = Some(Arc::clone(&fake));
243 self.transport = fake;
244 self
245 }
246
247 pub fn fake(&self) -> Option<&Arc<Fake>> {
249 self.fake.as_ref()
250 }
251
252 pub fn transport_name(&self) -> &'static str {
253 self.transport.name()
254 }
255
256 pub async fn send(&self, message: Message) -> Result<()> {
258 let message = match &self.from {
259 Some(address) => message.default_from(address.clone()),
260 None => message,
261 };
262 message.validate()?;
263
264 let started = Instant::now();
265 let result = self.transport.send(&message).await;
266 let elapsed = started.elapsed();
267
268 result.map_err(|error| {
269 Error::msg(format!(
270 "could not send `{}` to {}: {error}",
271 message.subject_text(),
272 message.envelope_recipients().join(", ")
273 ))
274 })?;
275
276 Event::new("mail.sent")
279 .with("to", message.envelope_recipients().join(", "))
280 .with("subject", message.subject_text())
281 .with("transport", self.transport.name())
282 .took(elapsed)
283 .dispatch();
284
285 Ok(())
286 }
287
288 pub async fn send_mailable<M: Mailable + ?Sized>(
290 &self,
291 engine: &Engine,
292 to: impl crate::address::IntoAddress,
293 mailable: &M,
294 ) -> Result<()> {
295 let message = mailable.build(engine)?.to(to);
296 self.send(message).await
297 }
298
299 #[track_caller]
301 pub fn assert_sent_to(&self, address: &str) {
302 self.recorder().assert_sent_to(address);
303 }
304
305 #[track_caller]
306 pub fn assert_sent_times(&self, expected: usize) {
307 self.recorder().assert_sent_times(expected);
308 }
309
310 #[track_caller]
311 pub fn assert_nothing_sent(&self) {
312 self.recorder().assert_nothing_sent();
313 }
314
315 #[track_caller]
316 fn recorder(&self) -> &Fake {
317 self.fake
318 .as_deref()
319 .expect("this mailer is not faking — build it with `Mail::fake()` before asserting")
320 }
321}
322
323#[cfg(test)]
324mod tests {
325
326 #[tokio::test]
331 async fn a_long_link_survives_being_logged() {
332 let token = "436c02b608c95a2f59e2476756b44c7a9da324a72fbeb0785ee7bca3d8429b26";
333 let url = format!("http://localhost:9001/reset-password/{token}");
334 let message = Message::new()
335 .from("a@example.com")
336 .to("b@example.com")
337 .subject("Reset your password")
338 .text(format!("Use this link:\n\n{url}\n"));
339
340 let mime = message.to_mime().unwrap();
342 assert!(!mime.contains(&url), "the encoder no longer wraps; this test is moot");
343
344 let body = message.text_body().unwrap();
346 assert!(body.contains(&url), "the logged body must carry the link unbroken");
347 assert!(!body.contains("=\r\n"), "a soft break reached the readable body: {body}");
348 }
349 use super::*;
350 use rustlavel_core::events;
351
352 fn message() -> Message {
353 Message::new()
354 .from(("Rustlavel", "no-reply@example.com"))
355 .to("ada@example.com")
356 .subject("Your receipt")
357 .text("Thank you.")
358 }
359
360 fn fixture(test: &str) -> PathBuf {
372 let directory = std::env::temp_dir()
373 .join(format!("rustlavel-mail-{test}-{}", std::process::id()));
374 let _ = std::fs::remove_dir_all(&directory);
375 directory
376 }
377
378 #[tokio::test]
379 async fn the_log_transport_renders_the_message_and_reports_success() {
380 let mailer = Mailer::new(LogTransport::new());
381
382 assert_eq!(mailer.transport_name(), "log");
383 mailer.send(message()).await.unwrap();
384 }
385
386 #[tokio::test]
387 async fn the_file_transport_writes_one_eml_per_message() {
388 let directory = fixture("file-transport");
389 let mailer = Mailer::new(FileTransport::new(&directory));
390
391 mailer.send(message()).await.unwrap();
392 mailer.send(message().subject("Second")).await.unwrap();
393
394 let mut written: Vec<PathBuf> =
395 std::fs::read_dir(&directory).unwrap().map(|e| e.unwrap().path()).collect();
396 written.sort();
397
398 assert_eq!(written.len(), 2);
399 assert!(written.iter().all(|path| path.extension().is_some_and(|e| e == "eml")));
400 assert!(
401 written.iter().any(|path| path.to_string_lossy().contains("your-receipt")),
402 "the subject should be in the filename: {written:?}"
403 );
404
405 let body = std::fs::read_to_string(&written[0]).unwrap();
406 assert!(body.contains("From: Rustlavel <no-reply@example.com>\r\n"), "{body}");
407 assert!(body.contains("Thank you."));
408
409 let _ = std::fs::remove_dir_all(&directory);
410 }
411
412 #[tokio::test]
413 async fn the_file_transport_explains_a_directory_it_cannot_create() {
414 let blocker = fixture("file-blocked");
416 std::fs::create_dir_all(&blocker).unwrap();
417 let file = blocker.join("not-a-directory");
418 std::fs::write(&file, b"x").unwrap();
419
420 let mailer = Mailer::new(FileTransport::new(file.join("mail")));
421 let error = mailer.send(message()).await.unwrap_err().to_string();
422
423 assert!(error.contains("mail.path"), "{error}");
424 let _ = std::fs::remove_dir_all(&blocker);
425 }
426
427 #[tokio::test]
428 async fn the_configured_sender_fills_in_a_message_that_has_none() {
429 let config = Config::new();
430 config.set("mail.transport", "log");
431 config.set("mail.from.address", "no-reply@example.com");
432 config.set("mail.from.name", "Rustlavel");
433
434 let mailer = Mailer::from_config(&config).unwrap();
435 let sent = Message::new().to("ada@example.com").subject("Hi").text("hi");
436
437 mailer.send(sent).await.unwrap();
438 }
439
440 #[tokio::test]
441 async fn a_message_with_no_sender_at_all_is_refused_before_any_transport_runs() {
442 let mailer = Mailer::new(LogTransport::new());
443
444 let error = mailer
445 .send(Message::new().to("ada@example.com").subject("Hi"))
446 .await
447 .unwrap_err()
448 .to_string();
449
450 assert!(error.contains("mail.from.address"), "{error}");
451 }
452
453 #[tokio::test]
454 async fn a_sent_message_dispatches_an_event_without_the_body() {
455 const SUBJECT: &str = "event-probe-2c1f";
459
460 events::clear_subscribers();
461 let seen = Arc::new(std::sync::Mutex::new(Vec::<Event>::new()));
462 let sink = Arc::clone(&seen);
463 events::subscribe(move |event: &Event| {
464 let mine = event.field("subject").and_then(rustlavel_core::Json::as_str)
465 == Some(SUBJECT);
466 if event.kind == "mail.sent" && mine {
467 sink.lock().unwrap().push(event.clone());
468 }
469 });
470
471 Mailer::new(LogTransport::silent())
472 .send(message().subject(SUBJECT).text("a secret body nobody should record"))
473 .await
474 .unwrap();
475
476 let recorded = seen.lock().unwrap();
477 let event = recorded.first().expect("a mail.sent event");
478
479 assert_eq!(
480 event.field("to").and_then(rustlavel_core::Json::as_str),
481 Some("ada@example.com")
482 );
483 assert_eq!(
484 event.field("transport").and_then(rustlavel_core::Json::as_str),
485 Some("log")
486 );
487 assert!(event.duration.is_some());
488
489 let rendered = format!("{:?}", event.fields);
490 assert!(!rendered.contains("secret body"), "the body reached the event: {rendered}");
491
492 drop(recorded);
493 events::clear_subscribers();
494 }
495
496 #[tokio::test]
497 async fn a_transport_failure_names_the_subject_and_the_recipients() {
498 struct Broken;
499 impl Transport for Broken {
500 fn name(&self) -> &'static str {
501 "broken"
502 }
503 fn send<'a>(&'a self, _message: &'a Message) -> BoxFuture<'a, Result<()>> {
504 Box::pin(async { Err(Error::msg("the server hung up")) })
505 }
506 }
507
508 let error = Mailer::new(Broken).send(message()).await.unwrap_err().to_string();
509
510 assert!(error.contains("Your receipt"), "{error}");
511 assert!(error.contains("ada@example.com"), "{error}");
512 assert!(error.contains("the server hung up"), "{error}");
513 }
514
515 #[tokio::test]
516 async fn the_smtp_transport_reports_a_server_it_cannot_reach() {
517 let settings = SmtpConfig::new("127.0.0.1", 1);
521 let mailer = Mailer::new(SmtpTransport::new(settings));
522
523 assert_eq!(mailer.transport_name(), "smtp");
524
525 let error = mailer.send(message()).await.unwrap_err().to_string();
526 assert!(error.contains("127.0.0.1:1"), "{error}");
527 assert!(error.contains("Your receipt"), "{error}");
528 }
529
530 #[tokio::test]
531 async fn a_mailable_can_be_rendered_and_sent_in_one_call() {
532 use rustlavel_view::EXTENSION;
533
534 let root = fixture("send-mailable");
535 let path = root.join(format!("mail/hello.{EXTENSION}"));
536 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
537 std::fs::write(&path, "<p>Hello {{ name }}</p>").unwrap();
538
539 struct Hello;
540 impl Mailable for Hello {
541 fn subject(&self) -> String {
542 "Hello".into()
543 }
544 fn view(&self) -> &str {
545 "mail.hello"
546 }
547 fn context(&self) -> rustlavel_view::Context {
548 rustlavel_view::Context::new().with("name", "Ada")
549 }
550 }
551
552 let mailer = crate::Mail::fake().from(Address::new("no-reply@example.com").unwrap());
553 mailer.send_mailable(&Engine::new(&root), "ada@example.com", &Hello).await.unwrap();
554
555 mailer.assert_sent_to("ada@example.com");
556 let sent = &mailer.fake().unwrap().sent()[0];
557 assert_eq!(sent.subject_text(), "Hello");
558 assert_eq!(sent.html_body(), Some("<p>Hello Ada</p>"));
559 assert_eq!(sent.text_body(), Some("Hello Ada"));
560
561 let _ = std::fs::remove_dir_all(&root);
562 }
563
564 #[test]
565 fn a_filename_survives_a_subject_that_is_not_a_filename() {
566 let awkward = Message::new().subject("Re: your / order #7!!");
567 let name = filename(&awkward);
568
569 assert!(name.ends_with("-re-your-order-7.eml"), "{name}");
570 assert!(!filename(&Message::new()).is_empty());
571 assert!(filename(&Message::new()).contains("message"));
572 }
573}