Skip to main content

rustlavel_mail/
transport.rs

1//! Where a message actually goes: a mail server, the log, or a directory.
2//!
3//! The transport is a boot-time decision read from configuration, so nothing
4//! in an application changes between a developer's laptop and production —
5//! only `mail.transport` does.
6
7use 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
22/// A boxed future borrowed from the transport and the message it is sending.
23///
24/// The trait has to be dyn-compatible — the factory decides at boot which
25/// transport an application has — and `async fn` in a trait is not.
26pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
27
28/// Something that can deliver a message.
29pub trait Transport: Send + Sync + 'static {
30    /// The transport's name, as it appears in the `mail.sent` event.
31    fn name(&self) -> &'static str;
32
33    fn send<'a>(&'a self, message: &'a Message) -> BoxFuture<'a, Result<()>>;
34}
35
36/// Talks to a real SMTP server, one connection per message.
37///
38/// Connection reuse is not worth the complexity here: mail is not a hot path,
39/// and a pooled SMTP connection that has gone stale fails in ways that are far
40/// harder to explain than a reconnect.
41pub 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
74/// Writes the rendered message to the log.
75///
76/// The local development default: mail you can read without a mail server, an
77/// inbox, or a network — and without the risk of a test mailing a customer.
78pub struct LogTransport {
79    /// Off for the fake, which records rather than reports.
80    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    /// A transport that does nothing at all, for [`crate::Mail::fake`].
95    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            // Built even though the body is logged separately: it is the only
108            // thing that proves the message would actually encode, and finding
109            // that out in development is the point of this transport.
110            let _ = message.to_mime()?;
111
112            if self.write {
113                // The body as written, not as encoded. `to_mime` produces
114                // quoted-printable, which breaks every line over 76 columns
115                // with a soft `=` — correct on the wire, and ruinous on a
116                // terminal, because the break lands inside a long link and the
117                // URL somebody copies is not the URL that was sent. A sign-in
118                // or reset link is the main thing this transport exists to
119                // deliver, so it has to survive being read.
120                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
136/// Writes one `.eml` per message under a directory.
137///
138/// `.eml` is what every mail client opens, so a designer can double-click the
139/// file and see the message exactly as a recipient would — including the
140/// attachments, which a log line cannot show.
141pub 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
182/// A name a human can scan in a directory listing: when, and what about.
183fn 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/// The application's handle on mail.
200///
201/// Cloning is cheap and shares one transport, so a handler can hold a `Mailer`
202/// in context the same way it holds a database pool.
203#[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    /// Build the transport named in `mail.transport`.
216    ///
217    /// Deliberately does not connect: an application must boot even while the
218    /// mail server is restarting.
219    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    /// The sender used by any message that does not set its own.
234    pub fn from(mut self, address: Address) -> Mailer {
235        self.from = Some(address);
236        self
237    }
238
239    /// Record instead of sending. This is `Mail::fake()`.
240    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    /// The recorder, when this mailer is faking.
248    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    /// Send one message.
257    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        // Recipients and subject, never the body and never a credential: this
277        // event is written to a Telescope database an operator can read.
278        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    /// Render a [`Mailable`] and send it to one recipient.
289    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    /// Assert a message went to this address. Only meaningful while faking.
300    #[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    /// The bug this exists to stop: a reset link logged as quoted-printable
327    /// carries a soft `=` line break inside the token, so the URL somebody
328    /// copies out of the terminal is not the URL that was issued — and the
329    /// application, seeing a token that matches nothing, reports it as expired.
330    #[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        // What the wire carries: correct, and broken across lines.
341        let mime = message.to_mime().unwrap();
342        assert!(!mime.contains(&url), "the encoder no longer wraps; this test is moot");
343
344        // What a person reads: the link, whole.
345        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    /// Each test writes its own directory: tests run concurrently, and a shared
361    /// one would be read while another test was writing into it.
362    /// A directory this test owns, in this process.
363    ///
364    /// **The process id is what makes it owned.** The name used to be
365    /// `rustlavel-mail-{test}`, which is unique per test and shared by every
366    /// process that runs the suite — two `cargo test` invocations at once, two
367    /// terminals, or a CI matrix on one machine. One deletes the directory
368    /// while the other is counting what is in it, and the count is wrong. That
369    /// showed up once in a full workspace run and passed on every rerun, which
370    /// is what a shared fixture looks like from the outside.
371    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        // A path under a regular file cannot become a directory.
415        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        // The event bus is process-wide and other tests in this crate send mail
456        // at the same time, so the subscriber keeps only the message this test
457        // sent, identified by a subject nothing else uses.
458        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        // Nothing listens on port 1. The protocol itself is covered against a
518        // scripted duplex server in `smtp::client`; what matters here is that
519        // the transport's failure says where it was trying to go.
520        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}