steid

@jamesgill /

1//! Sending mail, and a fake that records it.
2//!
3//! One adapter over lettre, plus [`RecordingMailer`] — the in-memory implementation the
4//! use case tests run against, in the same spirit as
5//! [`StubHasher`](super::password::StubHasher).
6
7use std::sync::{Arc, Mutex};
8
9use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::Mailbox};
10
11use crate::application::{
12 AppConfig,
13 port::{Mail, MailError, Mailer},
14};
15
16/// Submits mail to a configured SMTP relay.
17///
18/// Both halves are optional, and both absent is the designed "this instance cannot send
19/// email" state rather than a fault — see [`AppConfig::smtp_url`]. Held as an `Option`
20/// pair rather than as an `Option<LettreMailer>` so that a page can ask
21/// [`configured`](Mailer::configured) through the port instead of reading configuration
22/// it has no business reading.
23#[derive(Clone)]
24pub struct LettreMailer {
25 from: Option<Mailbox>,
26 transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
27}
28
29impl LettreMailer {
30 /// Builds the adapter, refusing configuration it cannot understand.
31 ///
32 /// An `Err` here means the operator wrote something wrong, not that mail is
33 /// unconfigured — `main` calls this once at boot and fails startup, which is the same
34 /// treatment `STEID_SETUP_TOKEN` gets and for the same reason: the one moment anyone
35 /// is reading the output is the moment to say so. Per-request construction then
36 /// cannot surprise anyone.
37 pub fn from_config(config: &AppConfig) -> Result<Self, MailError> {
38 let from = match config.mail_from.as_deref() {
39 Some(value) => Some(value.parse::<Mailbox>().map_err(|error| {
40 MailError::new(format!("STEID_MAIL_FROM is not an address: {error}"))
41 })?),
42 None => None,
43 };
44
45 let transport = match config.smtp_url.as_ref() {
46 Some(url) => {
47 let builder = AsyncSmtpTransport::<Tokio1Executor>::from_url(url.expose())
48 // The error is lettre's and never quotes the URL back, which matters:
49 // the URL carries the submission password.
50 .map_err(|error| {
51 MailError::new(format!("STEID_SMTP_URL is not usable: {error}"))
52 })?;
53
54 Some(builder.build())
55 }
56 None => None,
57 };
58
59 Ok(Self { from, transport })
60 }
61
62 /// A mailer that cannot send anything.
63 ///
64 /// For the one caller that has to produce a mailer where a failure is unreachable —
65 /// see `web::context::mailer`. Distinct from an unconfigured instance only in how it
66 /// got here; both say `configured()` is false and both refuse to send.
67 pub fn unusable() -> Self {
68 Self {
69 from: None,
70 transport: None,
71 }
72 }
73}
74
75/// Says whether mail works and nothing else.
76///
77/// Written by hand rather than derived: the transport was built from a URL carrying the
78/// submission password, and a derived `Debug` is one `dbg!` away from putting it on
79/// standard error — the same reasoning as [`Secret`](crate::application::Secret).
80impl std::fmt::Debug for LettreMailer {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.debug_struct("LettreMailer")
83 .field("configured", &self.configured())
84 .finish()
85 }
86}
87
88impl Mailer for LettreMailer {
89 fn configured(&self) -> bool {
90 self.from.is_some() && self.transport.is_some()
91 }
92
93 async fn send(&self, mail: &Mail) -> Result<(), MailError> {
94 let (Some(from), Some(transport)) = (self.from.as_ref(), self.transport.as_ref()) else {
95 return Err(MailError::new(
96 "this instance has no STEID_SMTP_URL and STEID_MAIL_FROM",
97 ));
98 };
99
100 let to = mail
101 .to
102 .as_str()
103 .parse::<Mailbox>()
104 .map_err(|error| MailError::new(format!("recipient is not an address: {error}")))?;
105
106 let message = Message::builder()
107 .from(from.clone())
108 .to(to)
109 .subject(&mail.subject)
110 .body(mail.body.clone())
111 .map_err(|error| MailError::new(error.to_string()))?;
112
113 // The response is discarded on purpose: it carries the relay's greeting and
114 // queue id, and nothing here logs either. What matters is whether it was
115 // accepted.
116 transport
117 .send(message)
118 .await
119 .map(|_| ())
120 .map_err(|error| MailError::new(error.to_string()))
121 }
122}
123
124/// A mailer that records what it was asked to send.
125///
126/// What the use case tests assert against: that a reset mail went to the right address
127/// exactly once, and — for the miss path — that nothing went at all.
128#[derive(Debug, Clone)]
129pub struct RecordingMailer {
130 configured: bool,
131 sent: Arc<Mutex<Vec<Mail>>>,
132}
133
134impl RecordingMailer {
135 /// A mailer that works.
136 pub fn new() -> Self {
137 Self {
138 configured: true,
139 sent: Arc::new(Mutex::new(Vec::new())),
140 }
141 }
142
143 /// A mailer standing in for an instance with no SMTP configured.
144 pub fn unconfigured() -> Self {
145 Self {
146 configured: false,
147 ..Self::new()
148 }
149 }
150
151 /// Everything sent so far, oldest first.
152 pub fn sent(&self) -> Vec<Mail> {
153 self.sent.lock().expect("lock poisoned").clone()
154 }
155}
156
157impl Default for RecordingMailer {
158 fn default() -> Self {
159 Self::new()
160 }
161}
162
163impl Mailer for RecordingMailer {
164 fn configured(&self) -> bool {
165 self.configured
166 }
167
168 async fn send(&self, mail: &Mail) -> Result<(), MailError> {
169 if !self.configured {
170 return Err(MailError::new("no mail is configured"));
171 }
172
173 self.sent.lock().expect("lock poisoned").push(mail.clone());
174
175 Ok(())
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::domain::Email;
183
184 fn config(smtp: Option<&str>, from: Option<&str>) -> AppConfig {
185 let mut pairs: Vec<(String, String)> = Vec::new();
186 if let Some(smtp) = smtp {
187 pairs.push(("STEID_SMTP_URL".to_owned(), smtp.to_owned()));
188 }
189 if let Some(from) = from {
190 pairs.push(("STEID_MAIL_FROM".to_owned(), from.to_owned()));
191 }
192
193 envy::prefixed("STEID_")
194 .from_iter(pairs)
195 .expect("config should parse")
196 }
197
198 fn mail() -> Mail {
199 Mail {
200 to: Email::new("ada@example.com").expect("valid email"),
201 subject: "Reset your password".to_owned(),
202 body: "https://steid.example/auth/reset/abc\n".to_owned(),
203 }
204 }
205
206 #[test]
207 fn an_instance_with_no_mail_configured_says_so_rather_than_failing_to_build() {
208 let mailer = LettreMailer::from_config(&config(None, None)).expect("should build");
209
210 assert!(!mailer.configured());
211 }
212
213 #[test]
214 fn half_a_configuration_is_not_a_configuration() {
215 // A relay with nobody to send as, and an address with nowhere to submit it.
216 assert!(
217 !LettreMailer::from_config(&config(Some("smtps://smtp.example.com"), None))
218 .expect("should build")
219 .configured()
220 );
221 assert!(
222 !LettreMailer::from_config(&config(None, Some("steid@example.com")))
223 .expect("should build")
224 .configured()
225 );
226 }
227
228 #[test]
229 fn both_halves_together_can_send() {
230 let mailer = LettreMailer::from_config(&config(
231 Some("smtps://steid:hunter2@smtp.example.com:465"),
232 Some("Steid <steid@example.com>"),
233 ))
234 .expect("should build");
235
236 assert!(mailer.configured());
237 }
238
239 #[test]
240 fn a_plain_local_relay_is_accepted_for_development() {
241 // What the manual check runs against: a sink on a high port, no TLS.
242 let mailer = LettreMailer::from_config(&config(
243 Some("smtp://127.0.0.1:1025"),
244 Some("steid@example.com"),
245 ))
246 .expect("should build");
247
248 assert!(mailer.configured());
249 }
250
251 #[test]
252 fn configuration_that_cannot_be_understood_fails_loudly() {
253 // Both of these reach `main` at boot, which refuses to start.
254 assert!(LettreMailer::from_config(&config(Some("ftp://relay"), None)).is_err());
255 assert!(LettreMailer::from_config(&config(None, Some("not an address"))).is_err());
256 }
257
258 #[test]
259 fn an_unusable_smtp_url_is_reported_without_quoting_the_password_back() {
260 let error =
261 LettreMailer::from_config(&config(Some("ftp://steid:hunter2@relay.example.com"), None))
262 .expect_err("should refuse");
263
264 assert!(!error.to_string().contains("hunter2"), "{error}");
265 }
266
267 #[tokio::test]
268 async fn sending_through_an_unconfigured_mailer_is_an_error_not_a_silent_success() {
269 let mailer = LettreMailer::from_config(&config(None, None)).expect("should build");
270
271 assert!(mailer.send(&mail()).await.is_err());
272 }
273
274 #[tokio::test]
275 async fn the_fake_records_what_it_was_asked_to_send() {
276 let mailer = RecordingMailer::new();
277
278 mailer.send(&mail()).await.expect("send");
279
280 assert_eq!(mailer.sent(), vec![mail()]);
281 }
282
283 #[tokio::test]
284 async fn the_fake_can_stand_in_for_an_instance_that_cannot_mail() {
285 let mailer = RecordingMailer::unconfigured();
286
287 assert!(!mailer.configured());
288 assert!(mailer.send(&mail()).await.is_err());
289 assert!(mailer.sent().is_empty());
290 }
291}