Skip to main content

codoseo_notify/
email.rs

1//! Sending email. The log mailer prints the message, which is what self-hosters without an email
2//! server use (spec section 8: "without an email server, login links are printed to the logs");
3//! the SMTP mailer sends through `SMTP_URL`; tests capture messages.
4
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use lettre::message::{Mailbox, MultiPart};
9use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Email {
13    pub to: String,
14    pub subject: String,
15    pub text: String,
16    /// When set, the message goes out as multipart/alternative with `text` as the plain part.
17    pub html: Option<String>,
18}
19
20#[derive(Debug, thiserror::Error)]
21pub enum MailError {
22    /// `SMTP_URL` or `MAIL_FROM` is unusable; raised at startup.
23    #[error("{0}")]
24    Config(String),
25    /// A recipient address that can't be parsed.
26    #[error("invalid email address {0:?}")]
27    Address(String),
28    #[error("could not build the message: {0}")]
29    Build(String),
30    #[error("could not send the message: {0}")]
31    Send(String),
32}
33
34/// How long one SMTP connect or command may take before the send fails (and the job retries).
35/// lettre's own default is 60 s, long enough to hold the single job runner on a hung server.
36pub const SMTP_TIMEOUT: Duration = Duration::from_secs(20);
37
38/// A configured SMTP connection.
39pub struct SmtpMailer {
40    transport: AsyncSmtpTransport<Tokio1Executor>,
41    from: Mailbox,
42    timeout: Duration,
43}
44
45#[derive(Clone, Default)]
46pub enum Mailer {
47    /// Prints every message to the log.
48    #[default]
49    Log,
50    /// Keeps every message in memory, for tests.
51    Capture(Arc<Mutex<Vec<Email>>>),
52    /// Sends through an SMTP server.
53    Smtp(Arc<SmtpMailer>),
54}
55
56impl Mailer {
57    pub fn capture() -> (Mailer, Arc<Mutex<Vec<Email>>>) {
58        let sent = Arc::new(Mutex::new(Vec::new()));
59        (Mailer::Capture(sent.clone()), sent)
60    }
61
62    /// The mailer for `SMTP_URL` (`smtps://user:pass@host:465`, `smtp://host:587?tls=required`);
63    /// no URL means the log mailer. `from` is the `MAIL_FROM` sender, e.g.
64    /// `CodoSEO <hello@codoseo.com>`.
65    pub fn from_config(smtp_url: Option<&str>, from: &str) -> Result<Mailer, MailError> {
66        Mailer::from_config_with_timeout(smtp_url, from, SMTP_TIMEOUT)
67    }
68
69    /// [`from_config`](Self::from_config) with an explicit connect/command timeout.
70    fn from_config_with_timeout(
71        smtp_url: Option<&str>,
72        from: &str,
73        timeout: Duration,
74    ) -> Result<Mailer, MailError> {
75        let from: Mailbox = from
76            .parse()
77            .map_err(|e| MailError::Config(format!("MAIL_FROM is invalid: {e}")))?;
78        let Some(url) = smtp_url else {
79            return Ok(Mailer::Log);
80        };
81        let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
82            .map_err(|e| MailError::Config(format!("SMTP_URL is invalid: {e}")))?
83            .timeout(Some(timeout))
84            .build();
85        Ok(Mailer::Smtp(Arc::new(SmtpMailer {
86            transport,
87            from,
88            timeout,
89        })))
90    }
91
92    pub async fn send(&self, email: Email) -> Result<(), MailError> {
93        match self {
94            Mailer::Log => {
95                // Only the fact is logged: the recipient is personal data and the text below already
96                // carries everything a self-hoster needs.
97                tracing::info!("email not sent: no SMTP is configured; printed to stdout instead");
98                // The login link must be findable even when tracing isn't initialised.
99                println!(
100                    "\n── email to {} ──\n{}\n{}\n",
101                    email.to, email.subject, email.text
102                );
103                Ok(())
104            }
105            Mailer::Capture(sent) => {
106                sent.lock().expect("mailer lock").push(email);
107                Ok(())
108            }
109            Mailer::Smtp(smtp) => {
110                let message = build_message(&smtp.from, &email)?;
111                // lettre's timeout bounds only the TCP connect; a server that accepts and then
112                // goes quiet would hold the job runner, so the whole send gets the same limit.
113                match tokio::time::timeout(smtp.timeout, smtp.transport.send(message)).await {
114                    Ok(sent) => sent.map(|_| ()).map_err(|e| MailError::Send(e.to_string())),
115                    Err(_) => Err(MailError::Send(format!(
116                        "the SMTP server did not answer within {} seconds",
117                        smtp.timeout.as_secs()
118                    ))),
119                }
120            }
121        }
122    }
123}
124
125fn build_message(from: &Mailbox, email: &Email) -> Result<Message, MailError> {
126    let to: Mailbox = email
127        .to
128        .parse()
129        .map_err(|_| MailError::Address(email.to.clone()))?;
130    let builder = Message::builder()
131        .from(from.clone())
132        .to(to)
133        .subject(email.subject.clone());
134    let built = match &email.html {
135        Some(html) => builder.multipart(MultiPart::alternative_plain_html(
136            email.text.clone(),
137            html.clone(),
138        )),
139        None => builder.body(email.text.clone()),
140    };
141    built.map_err(|e| MailError::Build(e.to_string()))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn email(html: Option<&str>) -> Email {
149        Email {
150            to: "a@example.com".into(),
151            subject: "Hi".into(),
152            text: "plain body".into(),
153            html: html.map(str::to_owned),
154        }
155    }
156
157    #[tokio::test]
158    async fn a_server_that_never_answers_fails_the_send_within_the_timeout() {
159        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
160        let port = listener.local_addr().unwrap().port();
161        // Accepts and then says nothing, like a hung server.
162        let _server = tokio::spawn(async move {
163            let _held = listener.accept().await;
164            std::future::pending::<()>().await;
165        });
166        let mailer = Mailer::from_config_with_timeout(
167            Some(&format!("smtp://127.0.0.1:{port}")),
168            "CodoSEO <hello@codoseo.com>",
169            Duration::from_millis(300),
170        )
171        .unwrap();
172        let outcome = tokio::time::timeout(Duration::from_secs(5), mailer.send(email(None))).await;
173        assert!(
174            matches!(outcome, Ok(Err(MailError::Send(_)))),
175            "{outcome:?}"
176        );
177    }
178
179    #[test]
180    fn the_default_timeout_is_twenty_seconds() {
181        assert_eq!(SMTP_TIMEOUT, Duration::from_secs(20));
182    }
183
184    fn from() -> Mailbox {
185        "CodoSEO <hello@codoseo.com>".parse().unwrap()
186    }
187
188    #[test]
189    fn html_makes_a_multipart_alternative() {
190        let raw = build_message(&from(), &email(Some("<p>html body</p>")))
191            .unwrap()
192            .formatted();
193        let raw = String::from_utf8_lossy(&raw).into_owned();
194        assert!(raw.contains("multipart/alternative"), "{raw}");
195        assert!(
196            raw.contains("text/plain") && raw.contains("text/html"),
197            "{raw}"
198        );
199        assert!(raw.contains("hello@codoseo.com"), "{raw}");
200    }
201
202    #[test]
203    fn plain_text_stays_single_part() {
204        let raw = build_message(&from(), &email(None)).unwrap().formatted();
205        let raw = String::from_utf8_lossy(&raw).into_owned();
206        assert!(!raw.contains("multipart"), "{raw}");
207        assert!(raw.contains("plain body"), "{raw}");
208    }
209
210    #[test]
211    fn a_bad_recipient_is_an_address_error() {
212        let mut e = email(None);
213        e.to = "not an address".into();
214        assert!(matches!(
215            build_message(&from(), &e),
216            Err(MailError::Address(_))
217        ));
218    }
219
220    #[test]
221    fn no_smtp_url_means_the_log_mailer() {
222        let m = Mailer::from_config(None, "CodoSEO <hello@codoseo.com>").unwrap();
223        assert!(matches!(m, Mailer::Log));
224    }
225
226    #[tokio::test]
227    async fn a_good_smtp_url_builds_and_a_bad_one_is_a_config_error() {
228        let ok = Mailer::from_config(
229            Some("smtps://user:pw@mail.example.com:465"),
230            "CodoSEO <hello@codoseo.com>",
231        );
232        assert!(matches!(ok, Ok(Mailer::Smtp(_))));
233        let bad = Mailer::from_config(Some("not a url"), "CodoSEO <hello@codoseo.com>");
234        assert!(matches!(bad, Err(MailError::Config(_))));
235        let bad_from = Mailer::from_config(None, "nonsense");
236        assert!(matches!(bad_from, Err(MailError::Config(_))));
237    }
238
239    #[tokio::test]
240    async fn log_and_capture_send_ok() {
241        assert!(Mailer::Log.send(email(None)).await.is_ok());
242        let (m, sent) = Mailer::capture();
243        m.send(email(Some("<p>x</p>"))).await.unwrap();
244        assert_eq!(sent.lock().unwrap().len(), 1);
245    }
246}