Skip to main content

allowthem_core/
email_smtp.rs

1//! SMTP email delivery via `lettre`.
2//!
3//! Composes branded HTML + plain-text with a multipart/alternative body.
4//! [`SmtpTls`] distinguishes STARTTLS (port 587) from implicit TLS (port 465);
5//! see plan §2.2 for the deviation from bd's `tls: bool`.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use allowthem_core::{SmtpEmailSender, SmtpConfig, SmtpTls, EmailBranding};
11//! let sender = SmtpEmailSender::new(
12//!     SmtpConfig {
13//!         host: "smtp.example.com".to_owned(),
14//!         port: 587,
15//!         username: Some("user".to_owned()),
16//!         password: Some("pass".to_owned()),
17//!         from_address: "noreply@example.com".to_owned(),
18//!         from_name: Some("Example".to_owned()),
19//!         tls: SmtpTls::StartTls,
20//!     },
21//!     EmailBranding::default(),
22//! ).unwrap();
23//! ```
24
25use std::future::Future;
26use std::pin::Pin;
27use std::sync::Arc;
28
29use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
30use lettre::transport::smtp::authentication::Credentials;
31use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
32
33use crate::email::{EmailMessage, EmailSender};
34use crate::email_render::{EmailBranding, render};
35use crate::error::AuthError;
36
37/// Controls TLS mode for SMTP connections.
38///
39/// Use [`StartTls`](SmtpTls::StartTls) for port 587 (opportunistic STARTTLS
40/// upgrade) and [`ImplicitTls`](SmtpTls::ImplicitTls) for port 465 (TLS
41/// wrapper). [`None`](SmtpTls::None) is permitted only for localhost and logs
42/// a warning at construction time.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SmtpTls {
45    /// No TLS. Only allowed for `localhost` / `127.0.0.1`. Logs a warning.
46    None,
47    /// STARTTLS upgrade (RFC 3207). Default for port 587.
48    StartTls,
49    /// Implicit TLS wrapper. Default for port 465.
50    ImplicitTls,
51}
52
53/// Configuration for [`SmtpEmailSender`].
54#[derive(Debug, Clone)]
55pub struct SmtpConfig {
56    pub host: String,
57    pub port: u16,
58    /// SMTP username. `None` means no authentication.
59    pub username: Option<String>,
60    /// SMTP password. Ignored when `username` is `None`.
61    pub password: Option<String>,
62    /// `From` envelope address (e.g. `"noreply@example.com"`).
63    pub from_address: String,
64    /// Optional display name for the `From` header.
65    pub from_name: Option<String>,
66    pub tls: SmtpTls,
67}
68
69/// SMTP email sender backed by `lettre`.
70///
71/// Generic over the transport so tests can inject
72/// `lettre::transport::stub::AsyncStubTransport`.
73pub struct SmtpEmailSender<T = AsyncSmtpTransport<Tokio1Executor>> {
74    transport: T,
75    from: Mailbox,
76    branding: Arc<EmailBranding>,
77}
78
79impl SmtpEmailSender {
80    /// Build a production sender from `config`.
81    pub fn new(config: SmtpConfig, branding: EmailBranding) -> Result<Self, AuthError> {
82        let is_localhost = config.host == "localhost" || config.host == "127.0.0.1";
83        if config.tls == SmtpTls::None && !is_localhost {
84            return Err(AuthError::Email(
85                "SmtpTls::None is only allowed for localhost hosts".to_owned(),
86            ));
87        }
88        if config.tls == SmtpTls::None {
89            tracing::warn!(
90                host = %config.host,
91                "SmtpEmailSender: using unencrypted SMTP (dev only)"
92            );
93        }
94
95        let mut builder = match config.tls {
96            SmtpTls::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
97                .map_err(|e| AuthError::Email(e.to_string()))?,
98            SmtpTls::ImplicitTls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
99                .map_err(|e| AuthError::Email(e.to_string()))?,
100            SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host),
101        };
102
103        builder = builder.port(config.port);
104
105        if let (Some(user), Some(pass)) = (config.username, config.password) {
106            builder = builder.credentials(Credentials::new(user, pass));
107        }
108
109        let transport = builder.build();
110        let from = build_mailbox(config.from_name, &config.from_address)?;
111
112        Ok(Self {
113            transport,
114            from,
115            branding: Arc::new(branding),
116        })
117    }
118}
119
120impl<T> SmtpEmailSender<T> {
121    /// Test constructor: inject any transport.
122    #[cfg(test)]
123    pub fn new_with_transport(transport: T, branding: EmailBranding) -> Self {
124        Self {
125            transport,
126            from: "Test Sender <test@example.com>"
127                .parse()
128                .expect("hardcoded mailbox is valid"),
129            branding: Arc::new(branding),
130        }
131    }
132}
133
134impl<T> EmailSender for SmtpEmailSender<T>
135where
136    T: AsyncTransport + Send + Sync,
137    T::Error: std::fmt::Display,
138{
139    fn send<'a>(
140        &'a self,
141        message: &'a EmailMessage,
142    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
143        Box::pin(async move {
144            let rendered = render(&message.template, &self.branding);
145
146            let to: Mailbox = message
147                .to
148                .parse()
149                .map_err(|e: lettre::address::AddressError| AuthError::Email(e.to_string()))?;
150
151            let email = Message::builder()
152                .from(self.from.clone())
153                .to(to)
154                .subject(&message.subject)
155                .multipart(
156                    MultiPart::alternative()
157                        .singlepart(
158                            SinglePart::builder()
159                                .header(ContentType::TEXT_PLAIN)
160                                .body(rendered.text),
161                        )
162                        .singlepart(
163                            SinglePart::builder()
164                                .header(ContentType::TEXT_HTML)
165                                .body(rendered.html),
166                        ),
167                )
168                .map_err(|e| AuthError::Email(e.to_string()))?;
169
170            self.transport
171                .send(email)
172                .await
173                .map(|_| ())
174                .map_err(|e| AuthError::Email(e.to_string()))
175        })
176    }
177}
178
179fn build_mailbox(name: Option<String>, address: &str) -> Result<Mailbox, AuthError> {
180    let addr: lettre::Address = address
181        .parse()
182        .map_err(|e: lettre::address::AddressError| AuthError::Email(e.to_string()))?;
183    Ok(Mailbox::new(name, addr))
184}
185
186#[cfg(test)]
187mod tests {
188    use lettre::transport::stub::AsyncStubTransport;
189
190    use crate::email::EmailTemplate;
191
192    use super::*;
193
194    fn make_sender() -> SmtpEmailSender<AsyncStubTransport> {
195        let stub = AsyncStubTransport::new_ok();
196        SmtpEmailSender::new_with_transport(stub, EmailBranding::default())
197    }
198
199    fn make_sender_with_branding(branding: EmailBranding) -> SmtpEmailSender<AsyncStubTransport> {
200        let stub = AsyncStubTransport::new_ok();
201        SmtpEmailSender::new_with_transport(stub, branding)
202    }
203
204    fn reset_message() -> EmailMessage {
205        EmailMessage {
206            to: "alice@example.com".to_owned(),
207            subject: "Reset your password".to_owned(),
208            template: EmailTemplate::PasswordReset {
209                url: "https://example.com/reset?t=abc".to_owned(),
210                username: "alice".to_owned(),
211            },
212        }
213    }
214
215    #[tokio::test]
216    async fn send_captures_message_with_correct_headers() {
217        let sender = make_sender();
218        sender.send(&reset_message()).await.unwrap();
219
220        let msgs = sender.transport.messages().await;
221        assert_eq!(msgs.len(), 1);
222        let (envelope, raw) = &msgs[0];
223        // Recipient in envelope
224        let to_addrs: Vec<_> = envelope.to().iter().map(|a| a.as_ref()).collect();
225        assert!(to_addrs.contains(&"alice@example.com"));
226        // Both text and HTML bodies present
227        assert!(raw.contains("Reset your password"));
228        assert!(raw.contains("text/plain"));
229        assert!(raw.contains("text/html"));
230    }
231
232    #[tokio::test]
233    async fn send_includes_subject_and_rendered_url_in_body() {
234        // Tightens the existing header-presence test: confirms the rendered
235        // template body (URL + username) actually reaches the transport,
236        // not just that *something* multipart was sent.
237        let sender = make_sender();
238        sender.send(&reset_message()).await.unwrap();
239
240        let msgs = sender.transport.messages().await;
241        let raw = &msgs[0].1;
242        assert!(raw.contains("Subject: Reset your password"));
243        // PasswordReset template renders the URL into both html and text.
244        // The raw multipart message body has the URL with `=` sequences
245        // possibly quoted-printable-encoded; assert on the host + path
246        // segment which survives encoding.
247        assert!(
248            raw.contains("example.com/reset"),
249            "rendered URL must reach the SMTP transport"
250        );
251        assert!(
252            raw.contains("alice"),
253            "rendered username must reach the SMTP transport"
254        );
255    }
256
257    #[tokio::test]
258    async fn branding_app_name_propagates_to_smtp_body() {
259        // Sender-level branding (§2.4) flows through email_render into the
260        // multipart body lettre hands to the transport. Operators rely on
261        // this so the receiving inbox shows the configured app name.
262        let branding = EmailBranding {
263            app_name: "Acme Inc".to_owned(),
264            logo_url: None,
265            footer_line: None,
266        };
267        let sender = make_sender_with_branding(branding);
268        sender.send(&reset_message()).await.unwrap();
269
270        let msgs = sender.transport.messages().await;
271        let raw = &msgs[0].1;
272        assert!(
273            raw.contains("Acme Inc"),
274            "branding.app_name must appear in the rendered body sent over SMTP"
275        );
276    }
277
278    #[tokio::test]
279    async fn address_parse_failure_returns_email_error() {
280        let sender = make_sender();
281        let msg = EmailMessage {
282            to: "not-an-email".to_owned(),
283            subject: "Subject".to_owned(),
284            template: EmailTemplate::PasswordReset {
285                url: "https://example.com".to_owned(),
286                username: "x".to_owned(),
287            },
288        };
289        let err = sender.send(&msg).await.unwrap_err();
290        assert!(matches!(err, AuthError::Email(_)));
291    }
292
293    #[test]
294    fn tls_none_refused_for_non_localhost() {
295        let cfg = SmtpConfig {
296            host: "smtp.example.com".to_owned(),
297            port: 25,
298            username: None,
299            password: None,
300            from_address: "x@example.com".to_owned(),
301            from_name: None,
302            tls: SmtpTls::None,
303        };
304        let result = SmtpEmailSender::new(cfg, EmailBranding::default());
305        assert!(matches!(result, Err(AuthError::Email(_))));
306    }
307
308    #[test]
309    fn tls_none_allowed_for_localhost() {
310        let cfg = SmtpConfig {
311            host: "localhost".to_owned(),
312            port: 1025,
313            username: None,
314            password: None,
315            from_address: "x@example.com".to_owned(),
316            from_name: None,
317            tls: SmtpTls::None,
318        };
319        // Should succeed (no actual connection attempt at construction time).
320        assert!(SmtpEmailSender::new(cfg, EmailBranding::default()).is_ok());
321    }
322
323    #[test]
324    fn no_auth_config_succeeds() {
325        // username = None, password = None — no Credentials set.
326        let cfg = SmtpConfig {
327            host: "localhost".to_owned(),
328            port: 1025,
329            username: None,
330            password: None,
331            from_address: "x@example.com".to_owned(),
332            from_name: None,
333            tls: SmtpTls::None,
334        };
335        assert!(SmtpEmailSender::new(cfg, EmailBranding::default()).is_ok());
336    }
337}