Skip to main content

rustlavel_mail/
address.rs

1//! Email addresses, and the display names attached to them.
2
3use crate::encode::{encode_word, sanitize_header_text};
4use rustlavel_core::{Error, Result};
5use std::fmt;
6
7/// The characters RFC 5322 calls "specials". A display name containing one of
8/// them has to be quoted, or the parser downstream reads it as syntax.
9const SPECIALS: &[char] = &['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '[', ']', '.'];
10
11/// One mailbox: an address, and optionally the name a human should see.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Address {
14    name: Option<String>,
15    email: String,
16}
17
18impl Address {
19    /// A bare address, validated.
20    pub fn new(email: impl Into<String>) -> Result<Address> {
21        let email = email.into().trim().to_string();
22        validate(&email)?;
23        Ok(Address { name: None, email })
24    }
25
26    /// An address with the name a recipient's client will show.
27    pub fn named(name: impl Into<String>, email: impl Into<String>) -> Result<Address> {
28        let mut address = Address::new(email)?;
29        let name = sanitize_header_text(&name.into());
30        address.name = (!name.is_empty()).then_some(name);
31        Ok(address)
32    }
33
34    /// Parse `Ada Lovelace <ada@example.com>`, `<ada@example.com>`, or a bare
35    /// `ada@example.com` — the three shapes a configuration file or a header
36    /// actually contains.
37    pub fn parse(input: &str) -> Result<Address> {
38        let input = input.trim();
39
40        let Some(open) = input.rfind('<') else {
41            return Address::new(input);
42        };
43        let close = input[open..].find('>').map(|at| open + at).ok_or_else(|| {
44            Error::msg(format!("`{input}` is missing the closing `>` around the address"))
45        })?;
46
47        let email = &input[open + 1..close];
48        let name = input[..open].trim().trim_matches('"');
49
50        if name.is_empty() { Address::new(email) } else { Address::named(name, email) }
51    }
52
53    pub fn email(&self) -> &str {
54        &self.email
55    }
56
57    pub fn name(&self) -> Option<&str> {
58        self.name.as_deref()
59    }
60
61    /// The domain part, which is what a `Message-ID` is built from.
62    pub fn domain(&self) -> &str {
63        self.email.split_once('@').map(|(_, domain)| domain).unwrap_or("localhost")
64    }
65
66    /// How this address is written in a header: the name encoded or quoted as
67    /// it needs to be, the address inside angle brackets.
68    pub fn to_header(&self) -> String {
69        match &self.name {
70            None => self.email.clone(),
71            Some(name) => format!("{} <{}>", encode_display_name(name), self.email),
72        }
73    }
74}
75
76impl fmt::Display for Address {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(&self.to_header())
79    }
80}
81
82/// Anything a caller may hand to `.to(...)`.
83///
84/// Conversion is fallible, but the builder keeps the error rather than
85/// returning it, so a chain of `.to(...).cc(...)` stays a chain and the failure
86/// surfaces once, at send time, naming the address that was wrong.
87pub trait IntoAddress {
88    fn into_address(self) -> Result<Address>;
89}
90
91impl IntoAddress for Address {
92    fn into_address(self) -> Result<Address> {
93        Ok(self)
94    }
95}
96
97impl IntoAddress for &Address {
98    fn into_address(self) -> Result<Address> {
99        Ok(self.clone())
100    }
101}
102
103impl IntoAddress for &str {
104    fn into_address(self) -> Result<Address> {
105        Address::parse(self)
106    }
107}
108
109impl IntoAddress for String {
110    fn into_address(self) -> Result<Address> {
111        Address::parse(&self)
112    }
113}
114
115/// `("Ada Lovelace", "ada@example.com")`.
116impl IntoAddress for (&str, &str) {
117    fn into_address(self) -> Result<Address> {
118        Address::named(self.0, self.1)
119    }
120}
121
122/// Quote or encode a display name so it survives as one token.
123fn encode_display_name(name: &str) -> String {
124    if !name.is_ascii() {
125        // An encoded-word is never quoted: quotes around it would be shown
126        // literally by every client that decodes it.
127        return encode_word(name);
128    }
129    if name.contains(SPECIALS) {
130        let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
131        return format!("\"{escaped}\"");
132    }
133    name.to_string()
134}
135
136/// Reject anything that is not an address a server will accept.
137///
138/// Strict on purpose. This value ends up in a `RCPT TO:<...>` command, so a
139/// space or a newline inside it is not a typo, it is a way to inject SMTP.
140fn validate(email: &str) -> Result<()> {
141    if email.is_empty() {
142        return Err(Error::msg("an email address cannot be empty"));
143    }
144    if email.len() > 320 {
145        return Err(Error::msg(format!(
146            "`{email}` is longer than the 320 characters an address may have"
147        )));
148    }
149    if email.chars().any(|c| c.is_whitespace() || c.is_control()) {
150        return Err(Error::msg(format!(
151            "`{email}` contains whitespace or a control character — an address may contain neither"
152        )));
153    }
154
155    let Some((local, domain)) = email.split_once('@') else {
156        return Err(Error::msg(format!("`{email}` is not an email address — it has no `@`")));
157    };
158    if local.is_empty() {
159        return Err(Error::msg(format!("`{email}` has nothing before the `@`")));
160    }
161    if domain.is_empty() {
162        return Err(Error::msg(format!("`{email}` has no domain after the `@`")));
163    }
164    if domain.contains('@') {
165        return Err(Error::msg(format!("`{email}` has more than one `@`")));
166    }
167    if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
168        return Err(Error::msg(format!("`{email}` has a malformed domain")));
169    }
170
171    Ok(())
172}
173
174/// Render a list of addresses as one header value.
175pub fn address_list(addresses: &[Address]) -> String {
176    addresses.iter().map(Address::to_header).collect::<Vec<_>>().join(", ")
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn a_bare_address_needs_no_name() {
185        let address = Address::new("ada@example.com").unwrap();
186
187        assert_eq!(address.to_header(), "ada@example.com");
188        assert_eq!(address.domain(), "example.com");
189        assert_eq!(address.name(), None);
190    }
191
192    #[test]
193    fn a_display_name_is_written_before_the_angle_brackets() {
194        let address = Address::named("Ada Lovelace", "ada@example.com").unwrap();
195        assert_eq!(address.to_header(), "Ada Lovelace <ada@example.com>");
196    }
197
198    #[test]
199    fn a_name_containing_specials_is_quoted() {
200        let address = Address::named("Lovelace, Ada", "ada@example.com").unwrap();
201        assert_eq!(address.to_header(), "\"Lovelace, Ada\" <ada@example.com>");
202
203        let awkward = Address::named("Ada \"The Countess\"", "ada@example.com").unwrap();
204        assert_eq!(
205            awkward.to_header(),
206            "\"Ada \\\"The Countess\\\"\" <ada@example.com>"
207        );
208    }
209
210    #[test]
211    fn a_non_ascii_name_becomes_an_encoded_word_and_is_not_quoted() {
212        let address = Address::named("Bj\u{f6}rn Str\u{f6}m", "bjorn@example.com").unwrap();
213
214        assert_eq!(address.to_header(), "=?UTF-8?B?QmrDtnJuIFN0csO2bQ==?= <bjorn@example.com>");
215        assert!(!address.to_header().contains('"'));
216    }
217
218    #[test]
219    fn addresses_parse_out_of_the_three_shapes_that_occur_in_practice() {
220        assert_eq!(Address::parse("ada@example.com").unwrap().email(), "ada@example.com");
221        assert_eq!(Address::parse("<ada@example.com>").unwrap().email(), "ada@example.com");
222
223        let named = Address::parse("Ada Lovelace <ada@example.com>").unwrap();
224        assert_eq!((named.name(), named.email()), (Some("Ada Lovelace"), "ada@example.com"));
225
226        let quoted = Address::parse("\"Lovelace, Ada\" <ada@example.com>").unwrap();
227        assert_eq!(quoted.name(), Some("Lovelace, Ada"));
228    }
229
230    #[test]
231    fn a_malformed_address_is_refused_with_a_reason() {
232        for bad in ["", "no-at-sign", "@example.com", "ada@", "ada@@example.com", "a b@x.com"] {
233            let error = Address::new(bad).unwrap_err().to_string();
234            assert!(!error.is_empty(), "`{bad}` should have been refused");
235        }
236
237        let error = Address::new("no-at-sign").unwrap_err().to_string();
238        assert!(error.contains("no `@`"), "{error}");
239    }
240
241    #[test]
242    fn an_address_cannot_carry_a_newline_into_a_smtp_command() {
243        // `RCPT TO:<...>` is built from this value; a CRLF in it would be a new
244        // command line, which is the SMTP version of header injection.
245        let error = Address::new("ada@example.com\r\nRCPT TO:<evil@example.com>").unwrap_err();
246        assert!(error.to_string().contains("whitespace"), "{error}");
247    }
248
249    #[test]
250    fn a_newline_in_a_display_name_is_flattened_rather_than_folded() {
251        let address = Address::named("Ada\r\nBcc: evil@example.com", "ada@example.com").unwrap();
252
253        assert!(!address.to_header().contains('\n'));
254        assert!(address.to_header().starts_with("\"Ada Bcc"));
255    }
256
257    #[test]
258    fn a_list_of_addresses_is_comma_separated() {
259        let list = address_list(&[
260            Address::named("Ada", "ada@example.com").unwrap(),
261            Address::new("grace@example.com").unwrap(),
262        ]);
263
264        assert_eq!(list, "Ada <ada@example.com>, grace@example.com");
265    }
266
267    #[test]
268    fn the_conversion_trait_accepts_the_shapes_a_caller_reaches_for() {
269        assert_eq!("ada@example.com".into_address().unwrap().email(), "ada@example.com");
270        assert_eq!(
271            ("Ada", "ada@example.com").into_address().unwrap().to_header(),
272            "Ada <ada@example.com>"
273        );
274        assert_eq!(
275            String::from("Ada <ada@example.com>").into_address().unwrap().email(),
276            "ada@example.com"
277        );
278    }
279}