forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.7 KiB
1//! The SMTP transport (lettre, rustls). It reads the server, sender and password from the
2//! database on every send, so a change on `/admin/mail` applies to the next message.
3
4use std::time::Duration;
5
6use forge_core::Store;
7use forge_core::mail::{MailError, MailSettings, OutgoingMail, Security};
8use lettre::message::Mailbox;
9use lettre::message::header::{ContentType, InReplyTo, MessageId, References};
10use lettre::transport::smtp::authentication::Credentials;
11use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
12
13use super::{Mailer, SendFuture};
14
15/// Longest a connection, handshake or reply may take. A silent server is cut off here.
16const TIMEOUT: Duration = Duration::from_secs(30);
17
18/// Sends through the SMTP server stored in `mail_settings`.
19pub struct SmtpMailer {
20 store: Store,
21}
22
23impl SmtpMailer {
24 pub fn new(store: Store) -> SmtpMailer {
25 SmtpMailer { store }
26 }
27
28 async fn deliver(&self, mail: &OutgoingMail) -> Result<(), MailError> {
29 let settings = self
30 .store
31 .mail_settings()
32 .await
33 .map_err(|e| MailError::Transient(format!("cannot read the mail settings: {e}")))?;
34 let password = self.store.mail_password().await.map_err(|e| {
35 MailError::Permanent(format!("cannot open the stored SMTP password: {e}"))
36 })?;
37 let message = build_message(&settings, mail)?;
38 let transport = build_transport(&settings, password.as_ref().map(|p| p.expose()))?;
39 transport.send(message).await.map(|_| ()).map_err(|e| {
40 if e.is_permanent() {
41 MailError::Permanent(e.to_string())
42 } else {
43 MailError::Transient(e.to_string())
44 }
45 })
46 }
47}
48
49impl Mailer for SmtpMailer {
50 fn send<'a>(&'a self, mail: &'a OutgoingMail) -> SendFuture<'a> {
51 Box::pin(self.deliver(mail))
52 }
53}
54
55/// Builds a plain-text message from the stored sender. A bad address is permanent.
56fn build_message(settings: &MailSettings, mail: &OutgoingMail) -> Result<Message, MailError> {
57 let bad = |what: &str, e: &dyn std::fmt::Display| {
58 MailError::Permanent(format!("the {what} is not a valid address: {e}"))
59 };
60 let address: Address = settings.from_addr.parse().map_err(|e| bad("sender", &e))?;
61 let from = Mailbox::new(
62 (!settings.from_name.is_empty()).then(|| settings.from_name.clone()),
63 address,
64 );
65 let to: Mailbox = mail.to.parse().map_err(|e| bad("recipient", &e))?;
66 let mut builder = Message::builder()
67 .from(from)
68 .to(to)
69 .subject(mail.subject.clone());
70 if let Some(id) = &mail.message_id {
71 builder = builder.header(MessageId::from(id.clone()));
72 }
73 if let Some(parent) = &mail.in_reply_to {
74 builder = builder
75 .header(InReplyTo::from(parent.clone()))
76 .header(References::from(parent.clone()));
77 }
78 builder
79 .header(ContentType::TEXT_PLAIN)
80 .body(mail.body.clone())
81 .map_err(|e| MailError::Permanent(format!("cannot build the message: {e}")))
82}
83
84/// Builds a connection for one send. The transport is cheap, so there is no cache to invalidate.
85fn build_transport(
86 settings: &MailSettings,
87 password: Option<&str>,
88) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> {
89 let builder = match settings.security {
90 Security::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&settings.host),
91 Security::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&settings.host),
92 Security::Plain => Ok(AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(
93 &settings.host,
94 )),
95 }
96 .map_err(|e| MailError::Permanent(format!("cannot set up the SMTP connection: {e}")))?;
97 let mut builder = builder.port(settings.port).timeout(Some(TIMEOUT));
98 if !settings.username.is_empty() {
99 builder = builder.credentials(Credentials::new(
100 settings.username.clone(),
101 password.unwrap_or_default().to_owned(),
102 ));
103 }
104 Ok(builder.build())
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 fn settings() -> MailSettings {
112 MailSettings {
113 host: "smtp.test.example".into(),
114 port: 587,
115 security: Security::StartTls,
116 username: String::new(),
117 from_addr: "forge@test.example".into(),
118 from_name: "forge".into(),
119 enabled: true,
120 has_password: false,
121 updated_at: None,
122 }
123 }
124
125 fn rendered(mail: &OutgoingMail) -> String {
126 let message = build_message(&settings(), mail).expect("message");
127 String::from_utf8(message.formatted()).expect("utf-8 headers")
128 }
129
130 #[test]
131 fn threading_headers_are_written_when_set() {
132 let mail = OutgoingMail {
133 to: "bob@test.example".into(),
134 subject: "[bob/widgets] T (#1)".into(),
135 body: "hello".into(),
136 message_id: Some("<e1.u2@forge.test>".into()),
137 in_reply_to: Some("<issue-i3@forge.test>".into()),
138 };
139 let text = rendered(&mail);
140 assert!(text.contains("Message-ID: <e1.u2@forge.test>"), "{text}");
141 assert!(
142 text.contains("In-Reply-To: <issue-i3@forge.test>"),
143 "{text}"
144 );
145 assert!(text.contains("References: <issue-i3@forge.test>"), "{text}");
146 }
147
148 #[test]
149 fn no_reply_headers_without_a_thread() {
150 let mail = OutgoingMail {
151 to: "bob@test.example".into(),
152 subject: "s".into(),
153 body: "b".into(),
154 message_id: None,
155 in_reply_to: None,
156 };
157 let text = rendered(&mail);
158 assert!(!text.contains("In-Reply-To"), "{text}");
159 assert!(!text.contains("References"), "{text}");
160 }
161}