Skip to main content

sofie_mail/
mail.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use sofie_core::{Error, Result};
5use sofie_values::Email;
6
7const MAXIMUM_SUBJECT: usize = 255;
8const MAXIMUM_NAME: usize = 255;
9const MAXIMUM_LIST: usize = 64;
10/// Each would end the address early in an SMTP command or split it in a header.
11const REFUSED_IN_ADDRESS: &[char] = &['<', '>', '(', ')', ',', ';', ':', '"', '[', ']', '\\'];
12
13/// Written into headers and SMTP commands as it is, so it holds nothing that could end either.
14#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(try_from = "AddressParts")]
16pub struct Address {
17    name: Option<String>,
18    email: Email,
19}
20
21#[derive(Deserialize)]
22struct AddressParts {
23    name: Option<String>,
24    email: Email,
25}
26
27impl TryFrom<AddressParts> for Address {
28    type Error = Error;
29
30    fn try_from(parts: AddressParts) -> Result<Self> {
31        let address = Self::new(&parts.email)?;
32        match parts.name {
33            Some(name) => address.named(&name),
34            None => Ok(address),
35        }
36    }
37}
38
39impl Address {
40    /// One beyond ASCII is sent only through a server offering SMTPUTF8.
41    pub fn new(email: &Email) -> Result<Self> {
42        if email.as_str().contains(REFUSED_IN_ADDRESS) {
43            return Err(Error::invalid(
44                "email",
45                "must not contain <>(),;:\"[]\\ to receive mail",
46            ));
47        }
48        Ok(Self {
49            name: None,
50            email: email.clone(),
51        })
52    }
53
54    pub fn named(self, name: &str) -> Result<Self> {
55        if name.chars().count() > MAXIMUM_NAME || name.chars().any(sofie_core::misleads_display) {
56            return Err(Error::invalid(
57                "name",
58                "must be at most 255 characters without control or invisible formatting characters",
59            ));
60        }
61        Ok(Self {
62            name: Some(name.to_owned()),
63            ..self
64        })
65    }
66
67    pub(crate) fn parse(text: &str) -> Result<Self> {
68        let text = text.trim();
69        let Some((name, rest)) = text.split_once('<') else {
70            return Self::new(&Email::parse(text)?);
71        };
72        let email = rest
73            .strip_suffix('>')
74            .ok_or_else(|| Error::invalid("email", "must end with `>` after `<`"))?;
75        let address = Self::new(&Email::parse(email)?)?;
76        let name = unquoted(name.trim());
77        if name.is_empty() {
78            return Ok(address);
79        }
80        address.named(&name)
81    }
82
83    #[must_use]
84    pub fn name(&self) -> Option<&str> {
85        self.name.as_deref()
86    }
87
88    #[must_use]
89    pub fn email(&self) -> &Email {
90        &self.email
91    }
92
93    pub(crate) fn domain(&self) -> &str {
94        self.email
95            .as_str()
96            .rsplit_once('@')
97            .map_or("localhost", |(_, domain)| domain)
98    }
99}
100
101impl fmt::Debug for Address {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(formatter, "{:?}", self.email)
104    }
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Body {
109    html: String,
110    text: String,
111}
112
113impl Body {
114    pub(crate) fn rendered(html: String) -> Self {
115        let text = crate::text::from_html(&html);
116        Self { html, text }
117    }
118
119    #[must_use]
120    pub fn html(&self) -> &str {
121        &self.html
122    }
123
124    #[must_use]
125    pub fn text(&self) -> &str {
126        &self.text
127    }
128}
129
130#[derive(Clone, Serialize, Deserialize)]
131#[serde(try_from = "MailParts")]
132pub struct Mail {
133    from: Option<Address>,
134    to: Vec<Address>,
135    reply_to: Option<Address>,
136    subject: String,
137    body: Body,
138    list: Option<String>,
139    /// Set when it is sent, as each recipient's link is its own.
140    #[serde(skip)]
141    unsubscribe: Option<String>,
142}
143
144#[derive(Deserialize)]
145struct MailParts {
146    from: Option<Address>,
147    to: Vec<Address>,
148    reply_to: Option<Address>,
149    subject: String,
150    body: Body,
151    #[serde(default)]
152    list: Option<String>,
153}
154
155impl TryFrom<MailParts> for Mail {
156    type Error = Error;
157
158    fn try_from(parts: MailParts) -> Result<Self> {
159        let [first, others @ ..] = parts.to.as_slice() else {
160            return Err(Error::invalid("to", "names no recipient"));
161        };
162        let mail = Self::new(first.clone(), &parts.subject, parts.body)?;
163        let mail = others.iter().cloned().fold(mail, Self::also_to);
164        let mail = match parts.list {
165            Some(list) => mail.bulk(&list)?,
166            None => mail,
167        };
168        Self {
169            from: parts.from,
170            reply_to: parts.reply_to,
171            ..mail
172        }
173        .checked()
174    }
175}
176
177/// One outer pair of quotes removed and `\x` read as `x`, as RFC 5322 quotes a name.
178fn unquoted(name: &str) -> String {
179    let Some(inner) = name
180        .strip_prefix('"')
181        .and_then(|rest| rest.strip_suffix('"'))
182    else {
183        return name.to_owned();
184    };
185    let mut unquoted = String::with_capacity(inner.len());
186    let mut characters = inner.chars();
187    while let Some(character) = characters.next() {
188        match character {
189            '\\' => unquoted.extend(characters.next()),
190            _ => unquoted.push(character),
191        }
192    }
193    unquoted
194}
195
196impl Mail {
197    /// The subject is one header line: no control or invisible formatting characters, at most 255 characters.
198    pub fn new(to: Address, subject: &str, body: Body) -> Result<Self> {
199        if subject.trim().is_empty()
200            || subject.chars().count() > MAXIMUM_SUBJECT
201            || subject.chars().any(sofie_core::misleads_display)
202        {
203            return Err(Error::invalid(
204                "subject",
205                "must be 1 to 255 characters without control or invisible formatting characters",
206            ));
207        }
208        Ok(Self {
209            from: None,
210            to: vec![to],
211            reply_to: None,
212            subject: subject.to_owned(),
213            body,
214            list: None,
215            unsubscribe: None,
216        })
217    }
218
219    /// Sent with a one-click unsubscribe link (RFC 8058) to its one recipient, and not at all
220    /// to an address that unsubscribed from `list` or complained of bulk mail.
221    pub fn bulk(self, list: &str) -> Result<Self> {
222        let named = !list.is_empty()
223            && list.len() <= MAXIMUM_LIST
224            && list
225                .bytes()
226                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
227        if !named {
228            return Err(Error::invalid(
229                "list",
230                "is 1 to 64 lowercase letters, digits and `-`",
231            ));
232        }
233        Ok(Self {
234            list: Some(list.to_owned()),
235            ..self
236        })
237    }
238
239    pub(crate) fn checked(self) -> Result<Self> {
240        if self.list.is_some() && self.to.len() > 1 {
241            return Err(Error::invalid(
242                "to",
243                "names one recipient in a bulk mail, as its unsubscribe link is that recipient's own",
244            ));
245        }
246        Ok(self)
247    }
248
249    #[must_use]
250    pub fn list(&self) -> Option<&str> {
251        self.list.as_deref()
252    }
253
254    #[must_use]
255    pub fn unsubscribe(&self) -> Option<&str> {
256        self.unsubscribe.as_deref()
257    }
258
259    pub(crate) fn sent_to(self, to: Vec<Address>, unsubscribe: Option<String>) -> Self {
260        Self {
261            to,
262            unsubscribe,
263            ..self
264        }
265    }
266
267    #[must_use]
268    pub fn also_to(mut self, address: Address) -> Self {
269        self.to.push(address);
270        self
271    }
272
273    #[must_use]
274    pub fn replying_to(self, address: Address) -> Self {
275        Self {
276            reply_to: Some(address),
277            ..self
278        }
279    }
280
281    #[must_use]
282    pub fn sent_from(self, address: Address) -> Self {
283        Self {
284            from: Some(address),
285            ..self
286        }
287    }
288
289    #[must_use]
290    pub fn from(&self) -> Option<&Address> {
291        self.from.as_ref()
292    }
293
294    #[must_use]
295    pub fn to(&self) -> &[Address] {
296        &self.to
297    }
298
299    #[must_use]
300    pub fn reply_to(&self) -> Option<&Address> {
301        self.reply_to.as_ref()
302    }
303
304    #[must_use]
305    pub fn subject(&self) -> &str {
306        &self.subject
307    }
308
309    #[must_use]
310    pub fn body(&self) -> &Body {
311        &self.body
312    }
313
314    pub(crate) fn with_default_sender(self, default: &Address) -> Self {
315        Self {
316            from: Some(self.from.unwrap_or_else(|| default.clone())),
317            ..self
318        }
319    }
320}
321
322/// Addresses masked, subject and body left out: either may carry personal data.
323impl fmt::Debug for Mail {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        formatter
326            .debug_struct("Mail")
327            .field("to", &self.to)
328            .finish_non_exhaustive()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn email(text: &str) -> Email {
337        Email::parse(text).unwrap()
338    }
339
340    fn body() -> Body {
341        Body::rendered("<p>Hello</p>".to_owned())
342    }
343
344    #[test]
345    fn an_address_that_could_end_a_command_or_header_is_refused() {
346        for refused in ["a>b@example.com", "a,b@example.com"] {
347            let address = Email::parse(refused).and_then(|email| Address::new(&email));
348            assert!(address.is_err(), "{refused}");
349        }
350        assert!(Address::new(&email("çağrı@example.com")).is_ok());
351        let named = Address::new(&email("ada@example.com")).unwrap();
352        assert!(named.clone().named("Ada\r\nBcc: x").is_err());
353        assert_eq!(
354            named.named("Ada Lovelace").unwrap().name(),
355            Some("Ada Lovelace")
356        );
357    }
358
359    #[test]
360    fn a_quoted_from_name_is_unescaped_once() {
361        let named = Address::parse(r#""Shop \"Main\"" <shop@example.com>"#).unwrap();
362
363        assert_eq!(named.name(), Some("Shop \"Main\""));
364    }
365
366    #[test]
367    fn a_from_setting_is_read_with_or_without_a_name() {
368        let bare = Address::parse("shop@example.com").unwrap();
369        let named = Address::parse("\"Shop\" <Shop@Example.com>").unwrap();
370
371        assert_eq!(
372            (bare.name(), bare.email().as_str()),
373            (None, "shop@example.com")
374        );
375        assert_eq!(
376            (named.name(), named.email().as_str()),
377            (Some("Shop"), "shop@example.com")
378        );
379        assert!(Address::parse("Shop <shop@example.com").is_err());
380    }
381
382    #[test]
383    fn a_subject_is_one_short_line() {
384        let to = Address::new(&email("ada@example.com")).unwrap();
385
386        assert!(Mail::new(to.clone(), "Your receipt", body()).is_ok());
387        for refused in ["", "  ", "Line\r\nBcc: x", &"x".repeat(256)] {
388            assert!(
389                Mail::new(to.clone(), refused, body()).is_err(),
390                "{refused:?}"
391            );
392        }
393    }
394
395    #[test]
396    fn a_stored_mail_is_checked_again_when_read() {
397        let to = Address::new(&email("ada@example.com")).unwrap();
398        let mail = Mail::new(to, "Your receipt", body()).unwrap();
399        let mut stored = serde_json::to_value(&mail).unwrap();
400
401        let read: Mail = serde_json::from_value(stored.clone()).unwrap();
402        assert_eq!(read.subject(), "Your receipt");
403        stored["subject"] = "Forged\r\nBcc: x".into();
404        assert!(serde_json::from_value::<Mail>(stored.clone()).is_err());
405        stored["subject"] = "Fine".into();
406        stored["to"] = serde_json::json!([]);
407        assert!(serde_json::from_value::<Mail>(stored).is_err());
408    }
409
410    #[test]
411    fn a_stored_bulk_mail_to_two_recipients_is_refused() {
412        let to = Address::new(&email("ada@example.com")).unwrap();
413        let mail = Mail::new(to.clone(), "News", body()).unwrap();
414        let mut stored = serde_json::to_value(mail.bulk("news").unwrap()).unwrap();
415
416        stored["to"] = serde_json::to_value([&to, &to]).unwrap();
417
418        assert!(serde_json::from_value::<Mail>(stored).is_err());
419    }
420
421    #[test]
422    fn debug_shows_no_address_subject_or_body() {
423        let to = Address::new(&email("ada@example.com")).unwrap();
424        let mail = Mail::new(to, "Secret plans", body()).unwrap();
425
426        let shown = format!("{mail:?}");
427        assert_eq!(shown, "Mail { to: [a***@example.com], .. }");
428    }
429}