Skip to main content

reinhardt_mail/
backends.rs

1use crate::headers::{
2	ListUnsubscribe, ListUnsubscribePost, Precedence, XEntityRefId, XMailer, XPriority,
3};
4use crate::message::EmailMessage;
5use crate::{EmailError, EmailResult};
6use lettre::message::header::{HeaderName, HeaderValue};
7use lettre::message::{Mailbox, MultiPart, SinglePart, header};
8use lettre::transport::smtp::authentication::{Credentials, Mechanism};
9use lettre::transport::smtp::client::{Tls, TlsParameters};
10use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
11use std::time::Duration;
12use zeroize::Zeroize;
13
14/// Trait for email backends
15#[async_trait::async_trait]
16pub trait EmailBackend: Send + Sync {
17	async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize>;
18}
19
20/// Creates an email backend from settings configuration.
21///
22/// # Arguments
23/// * `settings` - Email configuration settings
24///
25/// # Returns
26/// A boxed EmailBackend trait object based on settings.backend field
27///
28/// # Errors
29/// Returns EmailError if:
30/// - Unknown backend type
31/// - Missing required fields (e.g., file_path for FileBackend)
32pub fn backend_from_settings(
33	settings: &reinhardt_conf::settings::EmailSettings,
34) -> crate::EmailResult<Box<dyn EmailBackend>> {
35	// Validate from_email if configured
36	if !settings.from_email.is_empty() {
37		crate::validation::validate_email(&settings.from_email)?;
38	}
39
40	match settings.backend.to_lowercase().as_str() {
41		"smtp" => {
42			let security = match (settings.use_tls, settings.use_ssl) {
43				(true, _) => SmtpSecurity::StartTls,
44				(_, true) => SmtpSecurity::Tls,
45				_ => SmtpSecurity::None,
46			};
47
48			let timeout = settings
49				.timeout
50				.map(std::time::Duration::from_secs)
51				.unwrap_or(std::time::Duration::from_secs(60));
52
53			let mut config = SmtpConfig::new(&settings.host, settings.port)
54				.with_security(security)
55				.with_timeout(timeout);
56
57			if let (Some(username), Some(password)) = (&settings.username, &settings.password) {
58				config = config.with_credentials(username.clone(), password.clone());
59			}
60
61			let backend = SmtpBackend::new(config)?;
62			Ok(Box::new(backend))
63		}
64		"console" => Ok(Box::new(ConsoleBackend)),
65		"file" => {
66			let directory = settings
67				.file_path
68				.clone()
69				.ok_or_else(|| crate::EmailError::MissingField("file_path".to_string()))?;
70			Ok(Box::new(FileBackend::new(directory)))
71		}
72		"memory" => Ok(Box::new(MemoryBackend::new())),
73		unknown => Err(crate::EmailError::BackendError(format!(
74			"Unknown email backend type: '{}'. Valid options: smtp, console, file, memory",
75			unknown
76		))),
77	}
78}
79
80/// Console backend for development
81///
82/// Prints email messages to the console instead of sending them.
83pub struct ConsoleBackend;
84
85#[async_trait::async_trait]
86impl EmailBackend for ConsoleBackend {
87	async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
88		for (i, msg) in messages.iter().enumerate() {
89			println!("========== Email {} ==========", i + 1);
90			println!("From: {}", msg.from_email());
91			println!("To: {}", msg.to().join(", "));
92			if !msg.cc().is_empty() {
93				println!("Cc: {}", msg.cc().join(", "));
94			}
95			if !msg.bcc().is_empty() {
96				println!("Bcc: {}", msg.bcc().join(", "));
97			}
98			println!("Subject: {}", msg.subject());
99			for (name, value) in msg.headers() {
100				println!("{}: {}", name, value);
101			}
102			println!("\n{}", msg.body());
103			if let Some(html) = msg.html_body() {
104				println!("\n--- HTML ---\n{}", html);
105			}
106			for attachment in msg.attachments() {
107				println!(
108					"\n--- Attachment: {} (Content-Type: {}, {} bytes) ---",
109					attachment.filename(),
110					attachment.mime_type(),
111					attachment.content().len()
112				);
113			}
114			println!("==============================\n");
115		}
116		Ok(messages.len())
117	}
118}
119
120/// File backend for saving emails to files
121pub struct FileBackend {
122	directory: std::path::PathBuf,
123}
124
125impl FileBackend {
126	pub fn new(directory: impl Into<std::path::PathBuf>) -> Self {
127		Self {
128			directory: directory.into(),
129		}
130	}
131}
132
133#[async_trait::async_trait]
134impl EmailBackend for FileBackend {
135	async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
136		std::fs::create_dir_all(&self.directory)?;
137
138		for msg in messages.iter() {
139			let filename = format!(
140				"email_{}.eml",
141				chrono::Utc::now().format("%Y%m%d_%H%M%S_%f")
142			);
143			let path = self.directory.join(filename);
144
145			let mut content = format!(
146				"From: {}\nTo: {}\nSubject: {}",
147				msg.from_email(),
148				msg.to().join(", "),
149				msg.subject()
150			);
151
152			// Include custom headers
153			for (name, value) in msg.headers() {
154				content.push_str(&format!("\n{}: {}", name, value));
155			}
156
157			content.push_str(&format!("\n\n{}", msg.body()));
158
159			// Include HTML body if present
160			if let Some(html) = msg.html_body() {
161				content.push_str("\n\n--- HTML Body ---\n");
162				content.push_str(html);
163			}
164
165			// Include attachment metadata
166			for attachment in msg.attachments() {
167				content.push_str(&format!(
168					"\n\n--- Attachment: {} ---\nContent-Type: {}\nSize: {} bytes\n",
169					attachment.filename(),
170					attachment.mime_type(),
171					attachment.content().len()
172				));
173			}
174
175			tokio::fs::write(path, content).await?;
176		}
177
178		Ok(messages.len())
179	}
180}
181
182/// Memory backend for testing
183pub struct MemoryBackend {
184	messages: std::sync::Arc<tokio::sync::Mutex<Vec<EmailMessage>>>,
185}
186
187impl MemoryBackend {
188	pub fn new() -> Self {
189		Self {
190			messages: std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())),
191		}
192	}
193
194	pub async fn count(&self) -> usize {
195		self.messages.lock().await.len()
196	}
197
198	pub async fn get_messages(&self) -> Vec<EmailMessage> {
199		self.messages.lock().await.clone()
200	}
201
202	pub async fn clear(&self) {
203		self.messages.lock().await.clear();
204	}
205}
206
207impl Default for MemoryBackend {
208	fn default() -> Self {
209		Self::new()
210	}
211}
212
213#[async_trait::async_trait]
214impl EmailBackend for MemoryBackend {
215	async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
216		let mut stored = self.messages.lock().await;
217		stored.extend_from_slice(messages);
218		Ok(messages.len())
219	}
220}
221
222/// SMTP connection security mode
223#[derive(Debug, Clone)]
224pub enum SmtpSecurity {
225	/// No encryption
226	None,
227	/// STARTTLS (upgrade to TLS)
228	StartTls,
229	/// Direct TLS/SSL connection
230	Tls,
231}
232
233/// SMTP authentication mechanism
234#[derive(Debug, Clone)]
235pub enum SmtpAuthMechanism {
236	/// PLAIN authentication
237	Plain,
238	/// LOGIN authentication
239	Login,
240	/// Any supported mechanism
241	Auto,
242}
243
244/// Configuration for SMTP backend
245#[derive(Debug, Clone)]
246pub struct SmtpConfig {
247	pub host: String,
248	pub port: u16,
249	pub username: Option<String>,
250	pub password: Option<String>,
251	pub security: SmtpSecurity,
252	pub auth_mechanism: SmtpAuthMechanism,
253	pub timeout: Duration,
254}
255
256impl Default for SmtpConfig {
257	fn default() -> Self {
258		Self {
259			host: "localhost".to_string(),
260			port: 25,
261			username: None,
262			password: None,
263			security: SmtpSecurity::None,
264			auth_mechanism: SmtpAuthMechanism::Auto,
265			timeout: Duration::from_secs(30),
266		}
267	}
268}
269
270impl SmtpConfig {
271	pub fn new(host: impl Into<String>, port: u16) -> Self {
272		Self {
273			host: host.into(),
274			port,
275			username: None,
276			password: None,
277			security: SmtpSecurity::None,
278			auth_mechanism: SmtpAuthMechanism::Auto,
279			timeout: Duration::from_secs(30),
280		}
281	}
282
283	pub fn with_credentials(mut self, username: String, password: String) -> Self {
284		self.username = Some(username);
285		self.password = Some(password);
286		self
287	}
288
289	pub fn with_security(mut self, security: SmtpSecurity) -> Self {
290		self.security = security;
291		self
292	}
293
294	pub fn with_auth_mechanism(mut self, mechanism: SmtpAuthMechanism) -> Self {
295		self.auth_mechanism = mechanism;
296		self
297	}
298
299	pub fn with_timeout(mut self, timeout: Duration) -> Self {
300		self.timeout = timeout;
301		self
302	}
303
304	/// Validate the SMTP configuration
305	///
306	/// Checks that email-formatted usernames (containing `@`) are valid email addresses.
307	pub fn validate(&self) -> EmailResult<()> {
308		// Validate username if it looks like an email address
309		if let Some(username) = &self.username
310			&& username.contains('@')
311		{
312			crate::validation::validate_email(username)?;
313		}
314		Ok(())
315	}
316}
317
318/// Zeroize SMTP credentials on drop to prevent sensitive data from lingering in memory.
319///
320/// This ensures that username and password fields are securely erased when
321/// the `SmtpConfig` is no longer needed, reducing the risk of credential
322/// exposure through memory inspection or core dumps.
323impl Drop for SmtpConfig {
324	fn drop(&mut self) {
325		if let Some(ref mut username) = self.username {
326			username.zeroize();
327		}
328		if let Some(ref mut password) = self.password {
329			password.zeroize();
330		}
331	}
332}
333
334/// SMTP backend for sending emails
335///
336/// # Examples
337///
338/// ```rust,no_run
339/// # use reinhardt_mail::{SmtpBackend, SmtpConfig, SmtpSecurity};
340/// # use std::time::Duration;
341/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
342/// let config = SmtpConfig::new("smtp.gmail.com", 587)
343///     .with_credentials("user@gmail.com".to_string(), "password".to_string())
344///     .with_security(SmtpSecurity::StartTls)
345///     .with_timeout(Duration::from_secs(30));
346///
347/// let backend = SmtpBackend::new(config)?;
348/// # Ok(())
349/// # }
350/// ```
351pub struct SmtpBackend {
352	config: SmtpConfig,
353}
354
355impl SmtpBackend {
356	pub fn new(config: SmtpConfig) -> EmailResult<Self> {
357		config.validate()?;
358		Ok(Self { config })
359	}
360
361	fn build_transport(&self) -> EmailResult<AsyncSmtpTransport<Tokio1Executor>> {
362		// Use lettre's recommended secure APIs for standard ports
363		// This ensures proper TLS hostname verification by default
364		match (&self.config.security, self.config.port) {
365			// Port 465 with TLS: use relay() for secure SMTPS
366			(SmtpSecurity::Tls, 465) => {
367				let builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&self.config.host)
368					.map_err(|e| EmailError::SmtpError(format!("TLS relay error: {}", e)))?
369					.timeout(Some(self.config.timeout));
370				let builder = self.configure_auth(builder);
371				Ok(builder.build())
372			}
373			// Port 587 with STARTTLS: use starttls_relay() for secure STARTTLS
374			(SmtpSecurity::StartTls, 587) => {
375				let builder =
376					AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.config.host)
377						.map_err(|e| EmailError::SmtpError(format!("STARTTLS relay error: {}", e)))?
378						.timeout(Some(self.config.timeout));
379				let builder = self.configure_auth(builder);
380				Ok(builder.build())
381			}
382			// Custom port or no TLS: use builder_dangerous with manual TLS configuration
383			// This is needed for test environments and non-standard SMTP configurations
384			_ => self.build_transport_with_custom_port(),
385		}
386	}
387
388	/// Configure authentication on the transport builder
389	fn configure_auth(
390		&self,
391		mut builder: lettre::transport::smtp::AsyncSmtpTransportBuilder,
392	) -> lettre::transport::smtp::AsyncSmtpTransportBuilder {
393		if let (Some(username), Some(password)) = (&self.config.username, &self.config.password) {
394			let credentials = Credentials::new(username.clone(), password.clone());
395
396			builder = match &self.config.auth_mechanism {
397				SmtpAuthMechanism::Plain => builder
398					.credentials(credentials)
399					.authentication(vec![Mechanism::Plain]),
400				SmtpAuthMechanism::Login => builder
401					.credentials(credentials)
402					.authentication(vec![Mechanism::Login]),
403				SmtpAuthMechanism::Auto => builder.credentials(credentials),
404			};
405		}
406		builder
407	}
408
409	/// Build transport with custom port using builder_dangerous
410	///
411	/// This method is used for non-standard ports or when TLS is disabled.
412	/// For standard ports (465/587), prefer `relay()` or `starttls_relay()` instead.
413	fn build_transport_with_custom_port(&self) -> EmailResult<AsyncSmtpTransport<Tokio1Executor>> {
414		let mut builder =
415			AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.config.host)
416				.port(self.config.port)
417				.timeout(Some(self.config.timeout));
418
419		// Configure TLS/SSL
420		match &self.config.security {
421			SmtpSecurity::None => {
422				// No encryption - intended for test environments only
423			}
424			SmtpSecurity::StartTls => {
425				let tls_params = TlsParameters::builder(self.config.host.clone())
426					.build()
427					.map_err(|e| EmailError::SmtpError(format!("TLS error: {}", e)))?;
428				builder = builder.tls(Tls::Required(tls_params));
429			}
430			SmtpSecurity::Tls => {
431				let tls_params = TlsParameters::builder(self.config.host.clone())
432					.build()
433					.map_err(|e| EmailError::SmtpError(format!("TLS error: {}", e)))?;
434				builder = builder.tls(Tls::Wrapper(tls_params));
435			}
436		}
437
438		builder = self.configure_auth(builder);
439
440		Ok(builder.build())
441	}
442
443	fn build_message(&self, email: &EmailMessage) -> EmailResult<Message> {
444		// Parse from address
445		let from = email
446			.from_email()
447			.parse::<Mailbox>()
448			.map_err(|e| EmailError::InvalidAddress(format!("Invalid from address: {}", e)))?;
449
450		// Start building the message
451		let mut builder = Message::builder().from(from).subject(email.subject());
452
453		// Add recipients
454		for to in email.to() {
455			let mailbox = to
456				.parse::<Mailbox>()
457				.map_err(|e| EmailError::InvalidAddress(format!("Invalid to address: {}", e)))?;
458			builder = builder.to(mailbox);
459		}
460
461		// Add CC recipients
462		for cc in email.cc() {
463			let mailbox = cc
464				.parse::<Mailbox>()
465				.map_err(|e| EmailError::InvalidAddress(format!("Invalid cc address: {}", e)))?;
466			builder = builder.cc(mailbox);
467		}
468
469		// Add BCC recipients
470		for bcc in email.bcc() {
471			let mailbox = bcc
472				.parse::<Mailbox>()
473				.map_err(|e| EmailError::InvalidAddress(format!("Invalid bcc address: {}", e)))?;
474			builder = builder.bcc(mailbox);
475		}
476
477		// Add Reply-To
478		for reply_to in email.reply_to() {
479			let mailbox = reply_to.parse::<Mailbox>().map_err(|e| {
480				EmailError::InvalidAddress(format!("Invalid reply-to address: {}", e))
481			})?;
482			builder = builder.reply_to(mailbox);
483		}
484
485		// Add custom headers
486		// Known headers are added via typed lettre Header implementations.
487		// Unknown/arbitrary headers are injected via raw header insertion after message build.
488		let mut deferred_headers: Vec<(String, String)> = Vec::new();
489		for (name, value) in email.headers() {
490			let name_lower = name.to_lowercase();
491			match name_lower.as_str() {
492				"x-mailer" => {
493					builder = builder.header(XMailer::new(value));
494				}
495				"x-priority" => {
496					builder = builder.header(XPriority::new(value));
497				}
498				"list-unsubscribe" => {
499					builder = builder.header(ListUnsubscribe::new(value));
500				}
501				"list-unsubscribe-post" => {
502					builder = builder.header(ListUnsubscribePost::new(value));
503				}
504				"x-entity-ref-id" => {
505					builder = builder.header(XEntityRefId::new(value));
506				}
507				"precedence" => {
508					builder = builder.header(Precedence::new(value));
509				}
510				_ => {
511					// Defer arbitrary headers for raw insertion after build
512					deferred_headers.push((name.clone(), value.clone()));
513				}
514			}
515		}
516
517		// Build the body - convert body to String once to avoid repeated allocation
518		let has_html = email.html_body().is_some();
519		let has_attachments = !email.attachments().is_empty();
520		let body = email.body().to_string();
521
522		let message = if has_html && has_attachments {
523			// HTML with plain text alternative AND attachments
524			// Structure: mixed( alternative(text, html), attachment1, attachment2, ... )
525			let alternative = MultiPart::alternative()
526				.singlepart(SinglePart::plain(body))
527				.singlepart(SinglePart::html(email.html_body().unwrap().to_string()));
528
529			let mut mixed = MultiPart::mixed().multipart(alternative);
530
531			for attachment in email.attachments() {
532				let content_type = header::ContentType::parse(attachment.mime_type())
533					.unwrap_or(header::ContentType::parse("application/octet-stream").unwrap());
534
535				let part = if let Some(cid) = attachment.content_id() {
536					SinglePart::builder()
537						.header(content_type)
538						.header(header::ContentDisposition::inline())
539						.header(header::ContentId::from(cid.to_string()))
540						.body(attachment.content().to_vec())
541				} else {
542					SinglePart::builder()
543						.header(content_type)
544						.header(header::ContentDisposition::attachment(
545							attachment.filename(),
546						))
547						.body(attachment.content().to_vec())
548				};
549
550				mixed = mixed.singlepart(part);
551			}
552
553			builder
554				.multipart(mixed)
555				.map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
556		} else if has_html {
557			// HTML with plain text alternative (no attachments)
558			let multipart = MultiPart::alternative()
559				.singlepart(SinglePart::plain(body))
560				.singlepart(SinglePart::html(email.html_body().unwrap().to_string()));
561
562			builder
563				.multipart(multipart)
564				.map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
565		} else if has_attachments {
566			// Plain text with attachments
567			let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(body));
568
569			for attachment in email.attachments() {
570				let content_type = header::ContentType::parse(attachment.mime_type())
571					.unwrap_or(header::ContentType::parse("application/octet-stream").unwrap());
572
573				let part = if let Some(cid) = attachment.content_id() {
574					// Inline attachment with content ID and Content-Type
575					SinglePart::builder()
576						.header(content_type)
577						.header(header::ContentDisposition::inline())
578						.header(header::ContentId::from(cid.to_string()))
579						.body(attachment.content().to_vec())
580				} else {
581					// Regular attachment with Content-Type
582					SinglePart::builder()
583						.header(content_type)
584						.header(header::ContentDisposition::attachment(
585							attachment.filename(),
586						))
587						.body(attachment.content().to_vec())
588				};
589
590				multipart = multipart.singlepart(part);
591			}
592
593			builder
594				.multipart(multipart)
595				.map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
596		} else {
597			// Plain text only
598			builder
599				.body(body)
600				.map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
601		};
602
603		// Inject deferred arbitrary headers via raw insertion
604		let mut message = message;
605		for (name, value) in deferred_headers {
606			match HeaderName::new_from_ascii(name.clone()) {
607				Ok(header_name) => {
608					let header_value = HeaderValue::new(header_name, value);
609					message.headers_mut().insert_raw(header_value);
610				}
611				Err(_) => {
612					return Err(EmailError::InvalidHeader(format!(
613						"Invalid header name: '{}'",
614						name
615					)));
616				}
617			}
618		}
619
620		Ok(message)
621	}
622}
623
624#[async_trait::async_trait]
625impl EmailBackend for SmtpBackend {
626	async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
627		let transport = self.build_transport()?;
628
629		let mut sent_count = 0;
630		for email in messages {
631			let message = self.build_message(email)?;
632
633			transport
634				.send(message)
635				.await
636				.map_err(|e| EmailError::SmtpError(format!("Failed to send email: {}", e)))?;
637
638			sent_count += 1;
639		}
640
641		Ok(sent_count)
642	}
643}