1use std::fmt::Write as _;
2use std::path::PathBuf;
3
4use sofie_core::{Error, Result, Timestamp};
5use sofie_db::BoxFuture;
6
7use crate::{Address, Mail};
8
9pub(crate) trait Transport: Send + Sync {
10 fn name(&self) -> &'static str;
11
12 fn server(&self) -> Option<(&str, u16)> {
13 None
14 }
15
16 fn send<'a>(&'a self, envelope: &'a Envelope<'a>) -> BoxFuture<'a, Result<Vec<Refused>>>;
18}
19
20#[derive(Clone, Debug)]
22pub struct Refused {
23 recipient: Address,
24 code: u16,
25 reply: String,
27}
28
29impl Refused {
30 pub(crate) fn new(recipient: Address, code: u16, reply: String) -> Self {
31 Self {
32 recipient,
33 code,
34 reply,
35 }
36 }
37
38 #[must_use]
39 pub fn is_deferred(&self) -> bool {
40 self.code < 500
41 }
42
43 #[must_use]
44 pub fn recipient(&self) -> &Address {
45 &self.recipient
46 }
47
48 #[must_use]
49 pub fn code(&self) -> u16 {
50 self.code
51 }
52
53 #[must_use]
54 pub fn reply(&self) -> &str {
55 &self.reply
56 }
57
58 pub(crate) fn names_a_dead_address(&self) -> bool {
62 let names_the_mailbox = self
63 .reply
64 .split_whitespace()
65 .skip(1)
66 .map(|word| word.trim_matches(|character: char| !character.is_ascii_digit()))
67 .find(|word| is_enhanced_status(word))
68 .is_none_or(|status| DEAD_MAILBOX.contains(&status));
69 matches!(self.code(), 550 | 551 | 553) && names_the_mailbox
70 }
71}
72
73const DEAD_MAILBOX: &[&str] = &["5.1.1", "5.1.2", "5.1.3", "5.1.6", "5.1.10"];
74
75fn is_enhanced_status(word: &str) -> bool {
76 let mut parts = word.split('.');
77 parts.next() == Some("5")
78 && parts.clone().count() == 2
79 && parts.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
80}
81
82pub(crate) struct Envelope<'a> {
83 pub(crate) mail: &'a Mail,
84 pub(crate) from: &'a Address,
85 pub(crate) key: &'a str,
87 pub(crate) date: Timestamp,
88}
89
90impl Envelope<'_> {
91 pub(crate) fn message_id(&self) -> String {
92 format!("{}@{}", self.key, self.from.domain())
93 }
94}
95
96pub(crate) struct Logged {
98 pub(crate) preview: Option<PathBuf>,
99}
100
101impl Transport for Logged {
102 fn name(&self) -> &'static str {
103 "log"
104 }
105
106 fn send<'a>(&'a self, envelope: &'a Envelope<'a>) -> BoxFuture<'a, Result<Vec<Refused>>> {
107 let mail = envelope.mail;
108 tracing::info!(
109 mail.from = ?envelope.from,
110 mail.to = ?mail.to(),
111 mail.subject = mail.subject(),
112 mail.text = mail.body().text(),
113 "mail written to the log, not sent"
114 );
115 let Some(folder) = self.preview.clone() else {
116 return Box::pin(std::future::ready(Ok(Vec::new())));
117 };
118 let file = folder.join(format!(
119 "{}-{}.html",
120 envelope.date.as_second(),
121 envelope.key
122 ));
123 let page = preview(envelope);
124 Box::pin(async move {
125 let written = file.clone();
126 tokio::task::spawn_blocking(move || {
127 std::fs::create_dir_all(&folder)?;
128 std::fs::write(&written, page)
129 })
130 .await
131 .map_err(|error| Error::internal("writing a mail preview", error))?
132 .map_err(|error| Error::internal(format!("writing {}", file.display()), error))?;
133 tracing::info!(mail.preview = %file.display(), "mail preview written");
134 Ok(Vec::new())
135 })
136 }
137}
138
139fn preview(envelope: &Envelope<'_>) -> String {
140 let mail = envelope.mail;
141 let recipients: Vec<&str> = mail.to().iter().map(|to| to.email().as_str()).collect();
142 let mut rows = vec![
143 ("From", envelope.from.email().as_str().to_owned()),
144 ("To", recipients.join(", ")),
145 ("Subject", mail.subject().to_owned()),
146 ];
147 if let Some(link) = mail.unsubscribe() {
148 rows.push(("List-Unsubscribe", link.to_owned()));
149 }
150 let mut page = String::from("<!doctype html><meta charset=\"utf-8\"><table>");
151 for (name, value) in rows {
152 let _ = write!(page, "<tr><th>{name}</th><td>");
153 sofie_engine::push_escaped(&mut page, &value);
154 page.push_str("</td></tr>");
155 }
156 page.push_str("</table><hr>");
157 page.push_str(mail.body().html());
158 page
159}
160
161#[cfg(test)]
162mod tests {
163 use sofie_values::Email;
164
165 use super::*;
166
167 fn refused(code: u16, reply: &str) -> Refused {
168 let ada = Address::new(&Email::parse("ada@example.com").unwrap()).unwrap();
169 Refused::new(ada, code, reply.to_owned())
170 }
171
172 #[test]
173 fn only_a_missing_mailbox_names_a_dead_address() {
174 assert!(refused(550, "550 5.1.1 [address] unknown").names_a_dead_address());
175 assert!(refused(553, "553 mailbox name not allowed").names_a_dead_address());
176 assert!(!refused(550, "550 5.7.1 relaying denied").names_a_dead_address());
177 assert!(
178 !refused(553, "553 5.6.7 non-ASCII addresses not permitted").names_a_dead_address()
179 );
180 assert!(!refused(552, "552 5.2.2 mailbox full").names_a_dead_address());
181 assert!(
182 !refused(
183 553,
184 "553 sorry, that domain isn't in my list of allowed rcpthosts (#5.7.1)"
185 )
186 .names_a_dead_address()
187 );
188 assert!(
189 !refused(550, "550 5.1.8 [address]: Sender address rejected").names_a_dead_address()
190 );
191 assert!(refused(550, "550 5.1.10 null MX").names_a_dead_address());
192 }
193}