acme_proxy/notify/
email.rs1use 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#[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 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 let message = builder
122 .body(body)
123 .map_err(|error| NotifyError::permanent(format!("failed to build message: {error}")))?;
124
125 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 #[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 #[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 let subject = render(¬ifier.env, "email/certificate_issued.subject.j2", &event).unwrap();
257 let body = render(¬ifier.env, "email/certificate_issued.body.j2", &event).unwrap();
258 assert!(subject.contains("le"));
259 assert!(body.contains("example.com"));
260 }
261}