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;
10const REFUSED_IN_ADDRESS: &[char] = &['<', '>', '(', ')', ',', ';', ':', '"', '[', ']', '\\'];
12
13#[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 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 #[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
177fn 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 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 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
322impl 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}