Skip to main content

sofie_mail/
transport.rs

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    /// The recipients refused, for good or for now; the mail went to the others, if any.
17    fn send<'a>(&'a self, envelope: &'a Envelope<'a>) -> BoxFuture<'a, Result<Vec<Refused>>>;
18}
19
20/// A recipient the mail server refused: for good with a 5xx reply, for now with a 4xx.
21#[derive(Clone, Debug)]
22pub struct Refused {
23    recipient: Address,
24    code: u16,
25    /// The code and the server's words, any address in them left out.
26    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    /// 550, 551 and 553 say the mailbox is not there, unless an enhanced status, wherever the
59    /// reply puts it, names something else: a policy (5.7.x, as when the server relays no mail
60    /// for us), an address beyond ASCII (5.6.7, RFC 6531) or our own sender (5.1.7, 5.1.8).
61    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    /// The job's idempotency key, so every try of one job sends the same `Message-ID`.
86    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
96/// For development: the mail, links included, is logged instead of sent.
97pub(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}