Skip to main content

cloudillo_email/
sender.rs

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