1use 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 pub html: Option<String>,
18}
19
20#[derive(Debug, thiserror::Error)]
21pub enum MailError {
22 #[error("{0}")]
24 Config(String),
25 #[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
34pub const SMTP_TIMEOUT: Duration = Duration::from_secs(20);
37
38pub struct SmtpMailer {
40 transport: AsyncSmtpTransport<Tokio1Executor>,
41 from: Mailbox,
42 timeout: Duration,
43}
44
45#[derive(Clone, Default)]
46pub enum Mailer {
47 #[default]
49 Log,
50 Capture(Arc<Mutex<Vec<Email>>>),
52 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 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 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 tracing::info!("email not sent: no SMTP is configured; printed to stdout instead");
98 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 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 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}