Skip to main content

tuwunel_service/sendmail/
mod.rs

1//! Outbound email delivery.
2//!
3//! The service builds messages from the configured sender and delivers them through a pooled SMTP
4//! transport. It remains disabled when no SMTP connection URI is configured.
5
6use std::sync::Arc;
7
8use lettre::{
9	Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
10	message::{Mailbox, header::ContentType},
11};
12use tuwunel_core::{Err, Result, err, implement};
13
14/// Delivers outbound email through the configured SMTP transport.
15///
16/// The service retains a pooled connection and sender mailbox when SMTP is configured. Without a
17/// connection URI it remains disabled and rejects delivery attempts.
18pub struct Service {
19	transport: Option<Transport>,
20}
21
22struct Transport {
23	smtp: AsyncSmtpTransport<Tokio1Executor>,
24	sender: Mailbox,
25}
26
27impl crate::Service for Service {
28	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
29		let smtp = &args.server.config.smtp;
30		let transport = smtp
31			.connection_uri
32			.is_some()
33			.then(|| build_transport(smtp))
34			.transpose()?;
35
36		Ok(Arc::new(Self { transport }))
37	}
38
39	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
40}
41
42/// Reports whether outbound email is configured.
43///
44/// An enabled service has both a validated sender mailbox and an SMTP transport ready for use.
45#[implement(Service)]
46#[inline]
47#[must_use]
48pub fn is_enabled(&self) -> bool { self.transport.is_some() }
49
50/// Sends an HTML message to one recipient from the configured sender.
51///
52/// Message construction and SMTP delivery occur in one operation. A disabled transport, invalid
53/// message, or delivery failure returns an error.
54#[implement(Service)]
55#[tracing::instrument(
56	level = "debug",
57	skip(self, subject, body_html),
58	fields(
59		%to,
60	),
61)]
62pub async fn send(&self, to: &Address, subject: &str, body_html: String) -> Result<()> {
63	let Some(transport) = self.transport.as_ref() else {
64		return Err!(Config("smtp", "The email subsystem is not configured"));
65	};
66
67	let message = Message::builder()
68		.from(transport.sender.clone())
69		.to(Mailbox::new(None, to.clone()))
70		.subject(subject)
71		.header(ContentType::TEXT_HTML)
72		.body(body_html)
73		.map_err(|e| err!(Request(Unknown("Failed to build email message: {e}"))))?;
74
75	transport
76		.smtp
77		.send(message)
78		.await
79		.map_err(|e| err!(Request(Unknown("Failed to send email: {e}"))))?;
80
81	Ok(())
82}
83
84/// Parses a recipient address and sends an HTML message.
85///
86/// A malformed address maps to `M_INVALID_PARAM`. Valid addresses are delivered through
87/// [`Self::send`].
88#[implement(Service)]
89pub async fn send_to(&self, to: &str, subject: &str, body_html: String) -> Result<()> {
90	let to: Address = to
91		.parse()
92		.map_err(|_| err!(Request(InvalidParam("Email address is malformed"))))?;
93
94	self.send(&to, subject, body_html).await
95}
96
97/// Validates that a string parses as an email address.
98///
99/// The address is not contacted or retained. A malformed value maps to `M_INVALID_PARAM`.
100#[implement(Service)]
101pub fn check_address(&self, to: &str) -> Result<()> {
102	to.parse::<Address>()
103		.map(|_| ())
104		.map_err(|_| err!(Request(InvalidParam("Email address is malformed"))))
105}
106
107fn build_transport(config: &tuwunel_core::config::SmtpConfig) -> Result<Transport> {
108	let uri = config.connection_uri.as_deref().ok_or_else(|| {
109		err!(Config(
110			"smtp.connection_uri",
111			"An SMTP connection_uri is required to send email"
112		))
113	})?;
114
115	let sender = config
116		.sender
117		.as_deref()
118		.ok_or_else(|| err!(Config("smtp.sender", "An SMTP sender mailbox is required")))?
119		.parse()
120		.map_err(|e| err!(Config("smtp.sender", "Invalid sender mailbox: {e}")))?;
121
122	let smtp = AsyncSmtpTransport::<Tokio1Executor>::from_url(uri)
123		.map_err(|e| err!(Config("smtp.connection_uri", "Invalid SMTP connection_uri: {e}")))?
124		.build();
125
126	Ok(Transport { smtp, sender })
127}