Skip to main content

acme_proxy/notify/
email.rs

1//! The `email` notify backend: SMTP via `lettre`.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use lettre::message::Mailbox;
8use lettre::transport::smtp::authentication::Credentials;
9use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
10use tracing::info;
11
12use super::{NotifyBackend, NotifyError, NotifyEvent, render};
13use crate::config::EmailNotifyConfig;
14
15/// Delivers a subject + body per event over SMTP.
16#[derive(Debug)]
17pub struct EmailNotifier {
18    transport: AsyncSmtpTransport<Tokio1Executor>,
19    from: Mailbox,
20    to: Vec<Mailbox>,
21    env: Arc<minijinja::Environment<'static>>,
22}
23
24impl EmailNotifier {
25    /// Validates the configuration and builds the SMTP transport. Building
26    /// the transport does not itself connect — that only happens on `send`,
27    /// consistent with the rest of this codebase preferring to fail requests
28    /// (or, here, individual deliveries) rather than startup on a transient
29    /// network condition.
30    pub fn from_config(
31        cfg: &EmailNotifyConfig,
32        env: Arc<minijinja::Environment<'static>>,
33    ) -> anyhow::Result<Self> {
34        anyhow::ensure!(
35            !cfg.smtp_host.trim().is_empty(),
36            "notify.email is enabled but notify.email.smtp_host is empty"
37        );
38        anyhow::ensure!(
39            !cfg.from.trim().is_empty(),
40            "notify.email is enabled but notify.email.from is empty"
41        );
42        anyhow::ensure!(
43            !cfg.to.is_empty(),
44            "notify.email is enabled but notify.email.to is empty"
45        );
46
47        let from: Mailbox = cfg.from.parse().map_err(|error| {
48            anyhow::anyhow!(
49                "notify.email.from `{}` is not a valid address: {error}",
50                cfg.from
51            )
52        })?;
53        let to = cfg
54            .to
55            .iter()
56            .map(|addr| {
57                addr.parse::<Mailbox>().map_err(|error| {
58                    anyhow::anyhow!("notify.email.to `{addr}` is not a valid address: {error}")
59                })
60            })
61            .collect::<anyhow::Result<Vec<_>>>()?;
62
63        let mut builder = match cfg.smtp_security.as_str() {
64            "starttls" => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)?,
65            "tls" => AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)?,
66            "none" => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp_host),
67            other => anyhow::bail!(
68                "notify.email.smtp_security: unknown value `{other}` \
69                 (expected \"starttls\", \"tls\" or \"none\")"
70            ),
71        };
72        builder = builder
73            .port(cfg.smtp_port)
74            .timeout(Some(Duration::from_millis(cfg.timeout_ms)));
75        if !cfg.smtp_username.is_empty() {
76            builder = builder.credentials(Credentials::new(
77                cfg.smtp_username.clone(),
78                cfg.smtp_password.clone(),
79            ));
80        }
81        let transport = builder.build();
82
83        info!(
84            event = "notify_email_loaded",
85            outcome = "success",
86            smtp_host = %cfg.smtp_host,
87            smtp_port = cfg.smtp_port,
88            smtp_security = %cfg.smtp_security,
89            to = ?cfg.to,
90        );
91
92        Ok(Self {
93            transport,
94            from,
95            to,
96            env,
97        })
98    }
99}
100
101#[async_trait]
102impl NotifyBackend for EmailNotifier {
103    fn name(&self) -> &'static str {
104        "email"
105    }
106
107    async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError> {
108        let kind = event.kind();
109        let subject = render(&self.env, &format!("email/{kind}.subject.j2"), event)?;
110        let body = render(&self.env, &format!("email/{kind}.body.j2"), event)?;
111
112        let mut builder = Message::builder()
113            .from(self.from.clone())
114            .subject(subject.trim());
115        for to in &self.to {
116            builder = builder.to(to.clone());
117        }
118        // Permanent: an address or a header the builder refuses is a
119        // configuration or a template, and it will be refused identically on
120        // every attempt.
121        let message = builder
122            .body(body)
123            .map_err(|error| NotifyError::permanent(format!("failed to build message: {error}")))?;
124
125        // Retryable: a relay that is down, refusing connections or greylisting
126        // has decided nothing about this message.
127        self.transport
128            .send(message)
129            .await
130            .map_err(|error| NotifyError::new(format!("SMTP delivery failed: {error}")))?;
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::notify::{CertificateIssuedData, build_environment};
139
140    fn cfg() -> EmailNotifyConfig {
141        EmailNotifyConfig {
142            smtp_host: "localhost".to_string(),
143            from: "acme-proxy@example.com".to_string(),
144            to: vec!["ops@example.com".to_string()],
145            ..EmailNotifyConfig::default()
146        }
147    }
148
149    #[test]
150    fn missing_smtp_host_is_a_startup_error() {
151        let cfg = EmailNotifyConfig {
152            smtp_host: String::new(),
153            ..cfg()
154        };
155        let error = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap_err();
156        assert!(error.to_string().contains("smtp_host is empty"));
157    }
158
159    #[test]
160    fn missing_to_is_a_startup_error() {
161        let cfg = EmailNotifyConfig {
162            to: Vec::new(),
163            ..cfg()
164        };
165        let error = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap_err();
166        assert!(error.to_string().contains("to is empty"));
167    }
168
169    #[test]
170    fn unknown_smtp_security_is_a_startup_error() {
171        let cfg = EmailNotifyConfig {
172            smtp_security: "smtps-but-typo".to_string(),
173            ..cfg()
174        };
175        let error = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap_err();
176        assert!(error.to_string().contains("smtp_security"));
177    }
178
179    #[test]
180    fn invalid_from_address_is_a_startup_error() {
181        let cfg = EmailNotifyConfig {
182            from: "not an address".to_string(),
183            ..cfg()
184        };
185        let error = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap_err();
186        assert!(error.to_string().contains("notify.email.from"));
187    }
188
189    /// One bad address among several is caught, not skipped: the recipient
190    /// list is what the operator asked to be reachable at.
191    #[test]
192    fn an_invalid_recipient_is_a_startup_error() {
193        let cfg = EmailNotifyConfig {
194            to: vec!["ops@example.com".to_string(), "not an address".to_string()],
195            ..cfg()
196        };
197        let error = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap_err();
198        assert!(error.to_string().contains("notify.email.to"), "{error}");
199    }
200
201    #[test]
202    fn a_missing_from_is_a_startup_error() {
203        let cfg = EmailNotifyConfig {
204            from: String::new(),
205            ..cfg()
206        };
207        let error = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap_err();
208        assert!(error.to_string().contains("from is empty"), "{error}");
209    }
210
211    /// `send` renders both templates, builds the message and hands it to the
212    /// transport. Nothing listens on port 1, so delivery fails — which is the
213    /// point: everything up to and including the SMTP call has run, and the
214    /// failure is reported rather than swallowed.
215    #[tokio::test]
216    async fn send_reports_a_delivery_failure() {
217        let cfg = EmailNotifyConfig {
218            smtp_host: "127.0.0.1".to_string(),
219            smtp_port: 1,
220            smtp_security: "none".to_string(),
221            timeout_ms: 2000,
222            ..cfg()
223        };
224        let notifier = EmailNotifier::from_config(&cfg, Arc::new(build_environment(""))).unwrap();
225        assert_eq!(notifier.name(), "email");
226
227        let error = notifier
228            .send(&NotifyEvent::ProfileMounted(
229                crate::notify::ProfileMountedData {
230                    profile: "le".to_string(),
231                },
232            ))
233            .await
234            .expect_err("nothing is listening on port 1");
235        assert!(
236            error.to_string().contains("SMTP delivery failed"),
237            "{error}"
238        );
239    }
240
241    #[tokio::test]
242    async fn renders_the_certificate_issued_template() {
243        let notifier = EmailNotifier::from_config(&cfg(), Arc::new(build_environment(""))).unwrap();
244        let event = NotifyEvent::CertificateIssued(CertificateIssuedData {
245            profile: "le".to_string(),
246            order_id: "ord-1".to_string(),
247            account_id: "acc-1".to_string(),
248            cert_serial: "AA:BB".to_string(),
249            identifiers: vec!["example.com".to_string()],
250            client_ip: Some("203.0.113.1".to_string()),
251        });
252        // Delivery itself needs a real SMTP server; a loopback-listener test
253        // would only prove `AsyncSmtpTransport` connects, not this backend's
254        // own logic. What matters here is that both templates render without
255        // error against real event data.
256        let subject = render(&notifier.env, "email/certificate_issued.subject.j2", &event).unwrap();
257        let body = render(&notifier.env, "email/certificate_issued.body.j2", &event).unwrap();
258        assert!(subject.contains("le"));
259        assert!(body.contains("example.com"));
260    }
261}