Skip to main content

cloudillo_email/
sender.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! SMTP email sender using lettre
5//!
6//! Handles SMTP connection and email delivery with settings integration.
7
8use crate::prelude::*;
9use crate::EmailMessage;
10use cloudillo_core::settings::service::SettingsService;
11use lettre::transport::smtp::authentication::Credentials;
12use lettre::transport::smtp::SmtpTransport;
13use lettre::{Message, Transport};
14use std::sync::Arc;
15use std::time::Duration;
16
17/// SMTP email sender
18pub struct EmailSender {
19	settings_service: Arc<SettingsService>,
20}
21
22impl EmailSender {
23	/// Create new email sender
24	pub fn new(settings_service: Arc<SettingsService>) -> ClResult<Self> {
25		Ok(Self { settings_service })
26	}
27
28	/// Send email using SMTP settings from database
29	pub async fn send(&self, tn_id: TnId, message: EmailMessage) -> ClResult<()> {
30		// Check if email is enabled
31		if !self.settings_service.get_bool(tn_id, "email.enabled").await? {
32			info!("Email sending disabled, skipping send to {}", message.to);
33			return Ok(());
34		}
35
36		// Check if SMTP host is configured - if not, silently skip
37		let host = match self.settings_service.get_string_opt(tn_id, "email.smtp.host").await? {
38			Some(h) if !h.is_empty() => h,
39			_ => {
40				debug!("SMTP host not configured, silently skipping email to {}", message.to);
41				return Ok(());
42			}
43		};
44
45		// Fetch remaining SMTP settings
46		let port = u16::try_from(self.settings_service.get_int(tn_id, "email.smtp.port").await?)
47			.unwrap_or_default();
48		let username = self
49			.settings_service
50			.get_string_opt(tn_id, "email.smtp.username")
51			.await?
52			.unwrap_or_default();
53		let password = self
54			.settings_service
55			.get_string_opt(tn_id, "email.smtp.password")
56			.await?
57			.unwrap_or_default();
58		let from_address = self.settings_service.get_string(tn_id, "email.from.address").await?;
59		// Use override sender name if provided, otherwise use default from settings
60		let from_name = match &message.from_name_override {
61			Some(name) => name.clone(),
62			None => self.settings_service.get_string(tn_id, "email.from.name").await?,
63		};
64		let tls_mode = self.settings_service.get_string(tn_id, "email.smtp.tls_mode").await?;
65		let timeout_seconds = u64::try_from(
66			self.settings_service.get_int(tn_id, "email.smtp.timeout_seconds").await?,
67		)
68		.unwrap_or_default();
69
70		debug!("Sending email to {} via {}:{} with TLS mode: {}", message.to, host, port, tls_mode);
71
72		// Validate email addresses
73		if !message.to.contains('@') {
74			return Err(Error::ValidationError("Invalid recipient email address".into()));
75		}
76
77		if !from_address.contains('@') {
78			return Err(Error::ValidationError("Invalid from email address".into()));
79		}
80
81		// Build email message with both text and HTML bodies
82		// Quote the display name and escape internal quotes to handle RFC 5322 special characters
83		let escaped_name = from_name.replace('\\', "\\\\").replace('"', "\\\"");
84		let email_builder = Message::builder()
85			.from(
86				format!("\"{}\" <{}>", escaped_name, from_address)
87					.parse()
88					.map_err(|_| Error::ValidationError("Invalid from email format".into()))?,
89			)
90			.to(message
91				.to
92				.parse()
93				.map_err(|_| Error::ValidationError("Invalid recipient email format".into()))?)
94			.subject(&message.subject);
95
96		let email = if let Some(html_body) = message.html_body {
97			// Build multipart message with both text and HTML
98			email_builder
99				.multipart(
100					lettre::message::MultiPart::alternative()
101						.singlepart(lettre::message::SinglePart::plain(message.text_body))
102						.singlepart(lettre::message::SinglePart::html(html_body)),
103				)
104				.map_err(|e| Error::ValidationError(format!("Failed to build email: {}", e)))?
105		} else {
106			// Build text-only message
107			email_builder
108				.singlepart(lettre::message::SinglePart::plain(message.text_body))
109				.map_err(|e| Error::ValidationError(format!("Failed to build email: {}", e)))?
110		};
111
112		// Build SMTP transport with configured settings
113		let tls = match tls_mode.as_str() {
114			"tls" => {
115				debug!("Using TLS mode");
116				lettre::transport::smtp::client::Tls::Wrapper(
117					lettre::transport::smtp::client::TlsParameters::builder(host.clone())
118						.build()
119						.map_err(|e| Error::ConfigError(format!("TLS configuration error: {}", e)))?,
120				)
121			}
122			"starttls" => {
123				debug!("Using STARTTLS mode");
124				lettre::transport::smtp::client::Tls::Opportunistic(
125					lettre::transport::smtp::client::TlsParameters::builder(host.clone())
126						.build()
127						.map_err(|e| Error::ConfigError(format!("TLS configuration error: {}", e)))?,
128				)
129			}
130			"none" => {
131				debug!("No TLS mode");
132				lettre::transport::smtp::client::Tls::None
133			}
134			_ => {
135				return Err(Error::ConfigError(format!(
136					"Invalid TLS mode: {}. Must be 'none', 'starttls', or 'tls'",
137					tls_mode
138				)))
139			}
140		};
141
142		// Set credentials
143		let credentials = Credentials::new(username, password);
144		let mailer = SmtpTransport::builder_dangerous(&host)
145			.port(port)
146			.timeout(Some(Duration::from_secs(timeout_seconds)))
147			.tls(tls)
148			.credentials(credentials)
149			.build();
150
151		// Send email
152		match mailer.send(&email) {
153			Ok(response) => {
154				info!("Email sent successfully to {} (response: {:?})", message.to, response);
155				Ok(())
156			}
157			Err(e) => {
158				warn!("Failed to send email to {}: {}", message.to, e);
159				Err(Error::ServiceUnavailable(format!("SMTP send failed: {}", e)))
160			}
161		}
162	}
163}
164
165#[cfg(test)]
166mod tests {
167	use super::*;
168
169	#[test]
170	fn test_email_message_creation() {
171		let message = EmailMessage {
172			to: "user@example.com".to_string(),
173			subject: "Test Email".to_string(),
174			text_body: "This is a test".to_string(),
175			html_body: Some("<p>This is a test</p>".to_string()),
176			from_name_override: None,
177		};
178
179		assert_eq!(message.to, "user@example.com");
180		assert_eq!(message.subject, "Test Email");
181		assert!(message.html_body.is_some());
182	}
183
184	#[test]
185	fn test_email_address_validation() {
186		// Valid addresses should contain @
187		let valid = "user@example.com";
188		assert!(valid.contains('@'));
189
190		// Invalid addresses
191		let invalid1 = "userexample.com";
192		assert!(!invalid1.contains('@'));
193
194		let invalid2 = "user@";
195		assert!(invalid2.contains('@'));
196	}
197}
198
199// vim: ts=4