Skip to main content

tuwunel_service/sendmail/
mod.rs

1use std::sync::Arc;
2
3use lettre::{
4	Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
5	message::{Mailbox, header::ContentType},
6};
7use tuwunel_core::{Err, Result, err, implement};
8
9/// Outbound email transport. Holds a pooled SMTP connection and the configured
10/// sender mailbox when `[global.smtp]` is present; disabled otherwise.
11pub struct Service {
12	transport: Option<Transport>,
13}
14
15struct Transport {
16	smtp: AsyncSmtpTransport<Tokio1Executor>,
17	sender: Mailbox,
18}
19
20impl crate::Service for Service {
21	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
22		let smtp = &args.server.config.smtp;
23		let transport = smtp
24			.connection_uri
25			.is_some()
26			.then(|| build_transport(smtp))
27			.transpose()?;
28
29		Ok(Arc::new(Self { transport }))
30	}
31
32	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
33}
34
35/// Whether the email subsystem is configured and able to send.
36#[implement(Service)]
37#[inline]
38#[must_use]
39pub fn is_enabled(&self) -> bool { self.transport.is_some() }
40
41/// Send a single HTML message to one recipient from the configured sender.
42/// Returns an error when the subsystem is disabled or delivery fails.
43#[implement(Service)]
44#[tracing::instrument(
45	level = "debug",
46	skip(self, subject, body_html),
47	fields(
48		%to,
49	),
50)]
51pub async fn send(&self, to: &Address, subject: &str, body_html: String) -> Result<()> {
52	let Some(transport) = self.transport.as_ref() else {
53		return Err!(Config("smtp", "The email subsystem is not configured"));
54	};
55
56	let message = Message::builder()
57		.from(transport.sender.clone())
58		.to(Mailbox::new(None, to.clone()))
59		.subject(subject)
60		.header(ContentType::TEXT_HTML)
61		.body(body_html)
62		.map_err(|e| err!(Request(Unknown("Failed to build email message: {e}"))))?;
63
64	transport
65		.smtp
66		.send(message)
67		.await
68		.map_err(|e| err!(Request(Unknown("Failed to send email: {e}"))))?;
69
70	Ok(())
71}
72
73/// A malformed address maps to `M_INVALID_PARAM`.
74#[implement(Service)]
75pub async fn send_to(&self, to: &str, subject: &str, body_html: String) -> Result<()> {
76	let to: Address = to
77		.parse()
78		.map_err(|_| err!(Request(InvalidParam("Email address is malformed"))))?;
79
80	self.send(&to, subject, body_html).await
81}
82
83/// Confirms a string address parses as a deliverable mailbox. A malformed
84/// address maps to `M_INVALID_PARAM`.
85#[implement(Service)]
86pub fn check_address(&self, to: &str) -> Result<()> {
87	to.parse::<Address>()
88		.map(|_| ())
89		.map_err(|_| err!(Request(InvalidParam("Email address is malformed"))))
90}
91
92fn build_transport(config: &tuwunel_core::config::SmtpConfig) -> Result<Transport> {
93	let uri = config.connection_uri.as_deref().ok_or_else(|| {
94		err!(Config(
95			"smtp.connection_uri",
96			"An SMTP connection_uri is required to send email"
97		))
98	})?;
99
100	let sender = config
101		.sender
102		.as_deref()
103		.ok_or_else(|| err!(Config("smtp.sender", "An SMTP sender mailbox is required")))?
104		.parse()
105		.map_err(|e| err!(Config("smtp.sender", "Invalid sender mailbox: {e}")))?;
106
107	let smtp = AsyncSmtpTransport::<Tokio1Executor>::from_url(uri)
108		.map_err(|e| err!(Config("smtp.connection_uri", "Invalid SMTP connection_uri: {e}")))?
109		.build();
110
111	Ok(Transport { smtp, sender })
112}