rustlavel_mail/
address.rs1use crate::encode::{encode_word, sanitize_header_text};
4use rustlavel_core::{Error, Result};
5use std::fmt;
6
7const SPECIALS: &[char] = &['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '[', ']', '.'];
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Address {
14 name: Option<String>,
15 email: String,
16}
17
18impl Address {
19 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 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 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 pub fn domain(&self) -> &str {
63 self.email.split_once('@').map(|(_, domain)| domain).unwrap_or("localhost")
64 }
65
66 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
82pub 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
115impl IntoAddress for (&str, &str) {
117 fn into_address(self) -> Result<Address> {
118 Address::named(self.0, self.1)
119 }
120}
121
122fn encode_display_name(name: &str) -> String {
124 if !name.is_ascii() {
125 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
136fn 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
174pub 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 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}