forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.3 KiB
1//! Outgoing mail: the SMTP settings, the outbox states, the retry schedule and the send error.
2//! The store functions live in store/mail.rs; the transports live in forge-web (mail/).
3
4use std::fmt;
5use std::time::Duration;
6
7/// Tries per message: one first try and five retries. Once the last try fails, the message gives up.
8pub const MAX_ATTEMPTS: u32 = 6;
9
10/// The wait after the first, second, ... failed try. Five waits serve five retries.
11const BACKOFF: [Duration; 5] = [
12 Duration::from_secs(60),
13 Duration::from_secs(5 * 60),
14 Duration::from_secs(30 * 60),
15 Duration::from_secs(2 * 60 * 60),
16 Duration::from_secs(12 * 60 * 60),
17];
18
19/// The wait before the next try, given that `attempts` tries have failed. `None` means give up.
20pub fn retry_delay(attempts: u32) -> Option<Duration> {
21 if attempts == 0 || attempts >= MAX_ATTEMPTS {
22 return None;
23 }
24 BACKOFF.get((attempts - 1) as usize).copied()
25}
26
27/// How the SMTP connection is secured.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Security {
30 /// Plain connection upgraded with STARTTLS (port 587 usually).
31 StartTls,
32 /// TLS from the first byte (port 465 usually).
33 Tls,
34 /// No encryption. Only for a relay on a trusted network.
35 Plain,
36}
37
38impl Security {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Security::StartTls => "starttls",
42 Security::Tls => "tls",
43 Security::Plain => "none",
44 }
45 }
46
47 pub fn parse(s: &str) -> Option<Security> {
48 match s {
49 "starttls" => Some(Security::StartTls),
50 "tls" => Some(Security::Tls),
51 "none" => Some(Security::Plain),
52 _ => None,
53 }
54 }
55}
56
57/// The stored SMTP settings. The password is never part of this value; `has_password` says whether
58/// one is stored.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct MailSettings {
61 pub host: String,
62 pub port: u16,
63 pub security: Security,
64 pub username: String,
65 pub from_addr: String,
66 pub from_name: String,
67 pub enabled: bool,
68 pub has_password: bool,
69 pub updated_at: Option<String>,
70}
71
72/// A change to the SMTP settings. `password: None` keeps the stored password; `Some` replaces it.
73pub struct MailSettingsUpdate<'a> {
74 pub host: &'a str,
75 pub port: u16,
76 pub security: Security,
77 pub username: &'a str,
78 pub from_addr: &'a str,
79 pub from_name: &'a str,
80 pub enabled: bool,
81 pub password: Option<&'a str>,
82}
83
84impl fmt::Debug for MailSettingsUpdate<'_> {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.debug_struct("MailSettingsUpdate")
87 .field("host", &self.host)
88 .field("port", &self.port)
89 .field("security", &self.security)
90 .field("username", &self.username)
91 .field("from_addr", &self.from_addr)
92 .field("from_name", &self.from_name)
93 .field("enabled", &self.enabled)
94 .field("password", &self.password.map(|_| "<redacted>"))
95 .finish()
96 }
97}
98
99/// The decrypted SMTP password. Its Debug output is redacted.
100#[derive(Clone, PartialEq, Eq)]
101pub struct MailPassword(String);
102
103impl MailPassword {
104 pub fn new(value: String) -> MailPassword {
105 MailPassword(value)
106 }
107
108 pub fn expose(&self) -> &str {
109 &self.0
110 }
111}
112
113impl fmt::Debug for MailPassword {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.write_str("MailPassword(<redacted>)")
116 }
117}
118
119/// One message to send. Its Debug output omits the body, which can carry one-time links.
120#[derive(Clone, PartialEq, Eq)]
121pub struct OutgoingMail {
122 pub to: String,
123 pub subject: String,
124 pub body: String,
125 /// `Message-ID` header, in `<...>` form. `None` for mail that is not part of a thread.
126 pub message_id: Option<String>,
127 /// `In-Reply-To` and `References` headers, in `<...>` form.
128 pub in_reply_to: Option<String>,
129}
130
131impl fmt::Debug for OutgoingMail {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.debug_struct("OutgoingMail")
134 .field("to", &self.to)
135 .field("subject", &self.subject)
136 .field("body", &"<redacted>")
137 .finish()
138 }
139}
140
141/// One outbox row. The body is kept for the worker; the admin list never shows it.
142#[derive(Clone, PartialEq, Eq)]
143pub struct OutboxEntry {
144 pub id: String,
145 pub idem_key: String,
146 pub to_addr: String,
147 pub subject: String,
148 pub body: String,
149 pub message_id: Option<String>,
150 pub in_reply_to: Option<String>,
151 pub created_at: String,
152 /// Tries made so far.
153 pub attempts: u32,
154 pub next_attempt_at: Option<String>,
155 pub sent_at: Option<String>,
156 pub last_error: Option<String>,
157}
158
159impl fmt::Debug for OutboxEntry {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 f.debug_struct("OutboxEntry")
162 .field("id", &self.id)
163 .field("to_addr", &self.to_addr)
164 .field("subject", &self.subject)
165 .field("body", &"<redacted>")
166 .field("attempts", &self.attempts)
167 .field("next_attempt_at", &self.next_attempt_at)
168 .field("sent_at", &self.sent_at)
169 .field("last_error", &self.last_error)
170 .finish()
171 }
172}
173
174/// Where a message stands.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum OutboxStatus {
177 Sent,
178 Queued,
179 GaveUp,
180}
181
182impl OutboxStatus {
183 pub fn label(self) -> &'static str {
184 match self {
185 OutboxStatus::Sent => "sent",
186 OutboxStatus::Queued => "queued",
187 OutboxStatus::GaveUp => "gave up",
188 }
189 }
190}
191
192impl OutboxEntry {
193 pub fn status(&self) -> OutboxStatus {
194 if self.sent_at.is_some() {
195 OutboxStatus::Sent
196 } else if self.next_attempt_at.is_some() {
197 OutboxStatus::Queued
198 } else {
199 OutboxStatus::GaveUp
200 }
201 }
202}
203
204/// Why a send failed. A transient failure is retried on the schedule; a permanent one gives up now.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum MailError {
207 Transient(String),
208 Permanent(String),
209}
210
211impl fmt::Display for MailError {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 match self {
214 MailError::Transient(m) | MailError::Permanent(m) => f.write_str(m),
215 }
216 }
217}
218
219/// Checks the SMTP settings before they are stored. `enabled` needs a host and a sender.
220pub fn validate_settings(update: &MailSettingsUpdate<'_>) -> crate::Result<()> {
221 use crate::Error;
222 let bad = |m: &str| Err(Error::Invalid(m.to_owned()));
223 if update.port == 0 {
224 return bad("the SMTP port must be between 1 and 65535");
225 }
226 if update.host.chars().count() > 253
227 || update
228 .host
229 .chars()
230 .any(|c| c.is_whitespace() || c.is_control() || c == '/')
231 {
232 return bad("the SMTP host is not a host name");
233 }
234 if update.username.chars().count() > 256 || update.username.chars().any(char::is_control) {
235 return bad("the SMTP username is too long or has control characters");
236 }
237 if update.from_name.chars().count() > 100 || update.from_name.chars().any(char::is_control) {
238 return bad("the sender name must be at most 100 characters with no control characters");
239 }
240 if !update.from_addr.is_empty() {
241 crate::store::validate_email(update.from_addr)?;
242 }
243 if update.enabled {
244 if update.host.is_empty() {
245 return bad("set the SMTP host before turning mail on");
246 }
247 if update.from_addr.is_empty() {
248 return bad("set the sender address before turning mail on");
249 }
250 }
251 Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn retry_schedule_is_five_waits_then_give_up() {
260 let mins = |n: u32| retry_delay(n).map(|d| d.as_secs() / 60);
261 assert_eq!(retry_delay(0), None, "no try has failed yet");
262 assert_eq!(mins(1), Some(1));
263 assert_eq!(mins(2), Some(5));
264 assert_eq!(mins(3), Some(30));
265 assert_eq!(mins(4), Some(120));
266 assert_eq!(mins(5), Some(720));
267 assert_eq!(mins(6), None, "the sixth failed try gives up");
268 assert_eq!(MAX_ATTEMPTS, 6);
269 }
270
271 #[test]
272 fn security_round_trips_and_rejects_unknown_names() {
273 for s in [Security::StartTls, Security::Tls, Security::Plain] {
274 assert_eq!(Security::parse(s.as_str()), Some(s));
275 }
276 assert_eq!(Security::parse("ssl"), None);
277 }
278
279 #[test]
280 fn debug_output_never_shows_the_password_or_the_body() {
281 let pw = MailPassword::new("hunter2-smtp".into());
282 assert!(!format!("{pw:?}").contains("hunter2"));
283 let update = MailSettingsUpdate {
284 host: "smtp.example",
285 port: 587,
286 security: Security::StartTls,
287 username: "u",
288 from_addr: "forge@example.com",
289 from_name: "",
290 enabled: true,
291 password: Some("hunter2-smtp"),
292 };
293 let shown = format!("{update:?}");
294 assert!(!shown.contains("hunter2"));
295 assert!(shown.contains("<redacted>"));
296 let mail = OutgoingMail {
297 to: "a@example.com".into(),
298 subject: "Reset".into(),
299 body: "https://forge.test/reset/SECRET-TOKEN".into(),
300 message_id: None,
301 in_reply_to: None,
302 };
303 assert!(!format!("{mail:?}").contains("SECRET-TOKEN"));
304 }
305
306 #[test]
307 fn validation_requires_host_and_sender_before_enabling() {
308 let base = MailSettingsUpdate {
309 host: "smtp.example",
310 port: 587,
311 security: Security::StartTls,
312 username: "",
313 from_addr: "forge@example.com",
314 from_name: "forge",
315 enabled: true,
316 password: None,
317 };
318 assert!(validate_settings(&base).is_ok());
319 let no_host = MailSettingsUpdate { host: "", ..base };
320 assert!(validate_settings(&no_host).is_err());
321 let off = MailSettingsUpdate {
322 host: "",
323 enabled: false,
324 ..base
325 };
326 assert!(
327 validate_settings(&off).is_ok(),
328 "settings may be saved while off"
329 );
330 let no_port = MailSettingsUpdate { port: 0, ..base };
331 assert!(validate_settings(&no_port).is_err());
332 let bad_host = MailSettingsUpdate {
333 host: "smtp.example/evil",
334 ..base
335 };
336 assert!(validate_settings(&bad_host).is_err());
337 let bad_from = MailSettingsUpdate {
338 from_addr: "not-an-address",
339 ..base
340 };
341 assert!(validate_settings(&bad_from).is_err());
342 }
343
344 #[test]
345 fn status_follows_sent_at_and_next_attempt() {
346 let mut entry = OutboxEntry {
347 id: "1".into(),
348 idem_key: "k".into(),
349 to_addr: "a@example.com".into(),
350 subject: "s".into(),
351 body: "b".into(),
352 message_id: None,
353 in_reply_to: None,
354 created_at: "t".into(),
355 attempts: 0,
356 next_attempt_at: Some("t".into()),
357 sent_at: None,
358 last_error: None,
359 };
360 assert_eq!(entry.status(), OutboxStatus::Queued);
361 entry.next_attempt_at = None;
362 assert_eq!(entry.status(), OutboxStatus::GaveUp);
363 entry.sent_at = Some("t".into());
364 assert_eq!(entry.status(), OutboxStatus::Sent);
365 }
366}