fabrica

hanna/fabrica

7478 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Outbound mail: SMTP delivery plus the activation/reset templates.
6//!
7//! Three backends (`config::MailBackend`): `smtp` delivers over SMTP with
8//! native-TLS (`starttls`/`tls`/plaintext), `stdout` prints the rendered message
9//! (development), and `none` rejects any flow that needs mail. Sending is
10//! best-effort — the caller always has the activation URL to deliver by hand — so
11//! `send_invite` returns a `Result` the caller logs rather than fails on.
12
13mod templates;
14
15use std::time::Duration;
16
17use config::{Mail, MailBackend, MailEncryption};
18use lettre::message::{Mailbox, MultiPart};
19use lettre::transport::smtp::authentication::Credentials;
20use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
21
22pub use crate::templates::{Purpose, Rendered, render};
23
24/// An error from the mail layer.
25#[derive(Debug, thiserror::Error)]
26pub enum MailError {
27 /// The `none` backend was asked to send.
28 #[error("mail is disabled (mail.backend = \"none\")")]
29 Disabled,
30 /// An address failed to parse.
31 #[error("invalid email address: {0}")]
32 Address(String),
33 /// Building the SMTP transport failed.
34 #[error("smtp transport setup failed: {0}")]
35 Transport(String),
36 /// Building the message failed.
37 #[error("message construction failed: {0}")]
38 Message(String),
39 /// Delivery failed.
40 #[error("mail delivery failed: {0}")]
41 Send(String),
42}
43
44/// A configured mailer. Cheap to build once and reuse.
45pub struct Mailer {
46 from: Mailbox,
47 backend: Delivery,
48}
49
50/// The delivery mechanism behind a [`Mailer`].
51enum Delivery {
52 /// Deliver over SMTP.
53 Smtp(Box<AsyncSmtpTransport<Tokio1Executor>>),
54 /// Print the rendered message to stdout.
55 Stdout,
56 /// Reject sends.
57 None,
58}
59
60impl Mailer {
61 /// Build a mailer from configuration.
62 ///
63 /// # Errors
64 ///
65 /// Returns [`MailError::Address`] if `mail.from` is not a valid address, or
66 /// [`MailError::Transport`] if the SMTP transport cannot be constructed.
67 pub fn build(config: &Mail) -> Result<Self, MailError> {
68 let from: Mailbox = config
69 .from
70 .parse()
71 .map_err(|e| MailError::Address(format!("{}: {e}", config.from)))?;
72
73 let backend = match config.backend {
74 MailBackend::Stdout => Delivery::Stdout,
75 MailBackend::None => Delivery::None,
76 MailBackend::Smtp => Delivery::Smtp(Box::new(build_transport(config)?)),
77 };
78 Ok(Self { from, backend })
79 }
80
81 /// Send an invite email to `to_email` (optionally named `to_name`) with the
82 /// activation `link`.
83 ///
84 /// # Errors
85 ///
86 /// Returns [`MailError`] on address/message/transport failure, or
87 /// [`MailError::Disabled`] under the `none` backend. Callers treat this as
88 /// best-effort and still surface the link out of band.
89 pub async fn send_invite(
90 &self,
91 instance_name: &str,
92 to_email: &str,
93 to_name: Option<&str>,
94 link: &str,
95 purpose: Purpose,
96 ) -> Result<(), MailError> {
97 let rendered = render(instance_name, link, purpose);
98 let to = mailbox(to_email, to_name)?;
99
100 match &self.backend {
101 Delivery::None => Err(MailError::Disabled),
102 Delivery::Stdout => {
103 println!("--- mail ({}) ---", purpose.as_str());
104 println!("From: {}", self.from);
105 println!("To: {to}");
106 println!("Subject: {}", rendered.subject);
107 println!();
108 println!("{}", rendered.plain);
109 println!("--- end mail ---");
110 Ok(())
111 }
112 Delivery::Smtp(transport) => {
113 let email = Message::builder()
114 .from(self.from.clone())
115 .to(to)
116 .subject(rendered.subject)
117 .multipart(MultiPart::alternative_plain_html(
118 rendered.plain,
119 rendered.html,
120 ))
121 .map_err(|e| MailError::Message(e.to_string()))?;
122 transport
123 .send(email)
124 .await
125 .map_err(|e| MailError::Send(e.to_string()))?;
126 Ok(())
127 }
128 }
129 }
130}
131
132/// Build a `to` mailbox from an address and optional display name.
133fn mailbox(email: &str, name: Option<&str>) -> Result<Mailbox, MailError> {
134 let address = email
135 .parse()
136 .map_err(|e| MailError::Address(format!("{email}: {e}")))?;
137 Ok(Mailbox::new(name.map(str::to_string), address))
138}
139
140/// Construct the async SMTP transport for the `smtp` backend.
141fn build_transport(config: &Mail) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> {
142 let builder = match config.encryption {
143 MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
144 .map_err(|e| MailError::Transport(e.to_string()))?,
145 MailEncryption::Starttls => {
146 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
147 .map_err(|e| MailError::Transport(e.to_string()))?
148 }
149 MailEncryption::None => {
150 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
151 }
152 };
153
154 let mut builder = builder
155 .port(config.port)
156 .timeout(Some(Duration::from_secs(config.timeout_secs)));
157 if !config.username.is_empty() {
158 let password = config
159 .password
160 .as_ref()
161 .map(|s| s.expose().to_string())
162 .unwrap_or_default();
163 builder = builder.credentials(Credentials::new(config.username.clone(), password));
164 }
165 Ok(builder.build())
166}
167
168#[cfg(test)]
169mod tests {
170 #![allow(clippy::unwrap_used)]
171
172 use config::Mail;
173
174 use super::*;
175
176 fn stdout_config() -> Mail {
177 Mail {
178 backend: MailBackend::Stdout,
179 from: "Fabrica <fabrica@example.com>".to_string(),
180 ..Mail::default()
181 }
182 }
183
184 #[tokio::test]
185 async fn stdout_backend_sends_without_error() {
186 let mailer = Mailer::build(&stdout_config()).unwrap();
187 mailer
188 .send_invite(
189 "Forge",
190 "ada@example.com",
191 Some("Ada"),
192 "https://git.example.com/invite/tok",
193 Purpose::Activate,
194 )
195 .await
196 .unwrap();
197 }
198
199 #[tokio::test]
200 async fn none_backend_rejects() {
201 let config = Mail {
202 backend: MailBackend::None,
203 ..stdout_config()
204 };
205 let mailer = Mailer::build(&config).unwrap();
206 let err = mailer
207 .send_invite(
208 "Forge",
209 "a@b.com",
210 None,
211 "https://x/invite/t",
212 Purpose::Activate,
213 )
214 .await
215 .unwrap_err();
216 assert!(matches!(err, MailError::Disabled));
217 }
218
219 #[test]
220 fn invalid_from_is_rejected() {
221 let config = Mail {
222 from: "not an address".to_string(),
223 ..stdout_config()
224 };
225 assert!(matches!(Mailer::build(&config), Err(MailError::Address(_))));
226 }
227}