Rustelo/server/src/email/service.rs
2025-07-07 23:05:19 +01:00

701 lines
22 KiB
Rust

//! Email service implementation
//!
//! This module provides the main EmailService struct that coordinates email sending
//! across different providers and handles template rendering, configuration, and error handling.
use crate::email::providers::EmailProvider as EmailProviderTrait;
use crate::email::{
ConsoleProvider, EmailConfig, EmailError, EmailMessage, EmailProvider, EmailResult,
EmailTemplateEngine, FormSubmission, SendGridProvider, SmtpProvider,
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, error, info};
/// Main email service that coordinates email sending
pub struct EmailService {
provider: Arc<dyn EmailProviderTrait>,
template_engine: Arc<RwLock<EmailTemplateEngine>>,
config: EmailConfig,
}
impl EmailService {
/// Create a new email service with the given configuration
pub async fn new(config: EmailConfig) -> EmailResult<Self> {
// Create the appropriate provider based on configuration
let provider: Arc<dyn EmailProviderTrait> = match config.provider {
EmailProvider::Smtp => {
let smtp_config = config.smtp.as_ref().ok_or_else(|| {
EmailError::config("SMTP configuration required when using SMTP provider")
})?;
Arc::new(SmtpProvider::new(smtp_config.clone())?)
}
EmailProvider::SendGrid => {
let sendgrid_config = config.sendgrid.as_ref().ok_or_else(|| {
EmailError::config(
"SendGrid configuration required when using SendGrid provider",
)
})?;
Arc::new(SendGridProvider::new(sendgrid_config.clone())?)
}
EmailProvider::Console => Arc::new(ConsoleProvider::new()),
};
// Create template engine with default language
let template_engine = EmailTemplateEngine::new_with_language(&config.template_dir, "en")?;
let template_engine = Arc::new(RwLock::new(template_engine));
info!(
"Email service initialized with provider: {}",
provider.provider_name()
);
Ok(Self {
provider,
template_engine,
config,
})
}
/// Send an email message
pub async fn send_email(&self, message: &EmailMessage) -> EmailResult<String> {
self.send_email_with_language(message, "en").await
}
/// Send an email message with specific language
pub async fn send_email_with_language(
&self,
message: &EmailMessage,
language: &str,
) -> EmailResult<String> {
if !self.config.enabled {
debug!(
"Email sending is disabled, skipping message to: {}",
message.to
);
return Ok("disabled".to_string());
}
// Validate the message
self.validate_message(message)?;
// Process the message (apply templates, set defaults, etc.)
let processed_message = self
.process_message_with_language(message, language)
.await?;
// Send the email
debug!("Sending email to: {}", processed_message.to);
let result = self.provider.send_email(&processed_message).await;
match &result {
Ok(message_id) => {
info!(
"Email sent successfully to: {}, Message ID: {}",
processed_message.to, message_id
);
}
Err(e) => {
error!(
"Failed to send email to: {}, Error: {}",
processed_message.to, e
);
}
}
result
}
/// Send a form submission email
pub async fn send_form_submission(
&self,
submission: &FormSubmission,
recipient: &str,
) -> EmailResult<String> {
self.send_form_submission_with_language(submission, recipient, "en")
.await
}
/// Send a form submission email with specific language
pub async fn send_form_submission_with_language(
&self,
submission: &FormSubmission,
recipient: &str,
language: &str,
) -> EmailResult<String> {
let message = submission.to_email_message(recipient);
self.send_email_with_language(&message, language).await
}
/// Send a simple text email
pub async fn send_simple_email(
&self,
to: &str,
subject: &str,
body: &str,
) -> EmailResult<String> {
self.send_simple_email_with_language(to, subject, body, "en")
.await
}
/// Send a simple text email with specific language
pub async fn send_simple_email_with_language(
&self,
to: &str,
subject: &str,
body: &str,
language: &str,
) -> EmailResult<String> {
let message = EmailMessage::new(to, subject)
.text_body(body)
.from(&self.config.default_from)
.from_name(&self.config.default_from_name);
self.send_email_with_language(&message, language).await
}
/// Send an email using a template
pub async fn send_templated_email(
&self,
to: &str,
subject: &str,
template_name: &str,
template_data: HashMap<String, serde_json::Value>,
) -> EmailResult<String> {
self.send_templated_email_with_language(to, subject, template_name, template_data, "en")
.await
}
/// Send an email using a template with specific language
pub async fn send_templated_email_with_language(
&self,
to: &str,
subject: &str,
template_name: &str,
template_data: HashMap<String, serde_json::Value>,
language: &str,
) -> EmailResult<String> {
let message = EmailMessage::new(to, subject)
.template(template_name)
.template_data_map(template_data)
.from(&self.config.default_from)
.from_name(&self.config.default_from_name);
self.send_email_with_language(&message, language).await
}
/// Send a notification email
pub async fn send_notification(
&self,
to: &str,
title: &str,
message: &str,
content: Option<&str>,
) -> EmailResult<String> {
self.send_notification_with_language(to, title, message, content, "en")
.await
}
/// Send a notification email with specific language
pub async fn send_notification_with_language(
&self,
to: &str,
title: &str,
message: &str,
content: Option<&str>,
language: &str,
) -> EmailResult<String> {
let mut template_data = HashMap::new();
template_data.insert(
"title".to_string(),
serde_json::Value::String(title.to_string()),
);
template_data.insert(
"message".to_string(),
serde_json::Value::String(message.to_string()),
);
if let Some(content) = content {
template_data.insert(
"content".to_string(),
serde_json::Value::String(content.to_string()),
);
}
self.send_templated_email_with_language(to, title, "notification", template_data, language)
.await
}
/// Send a contact form submission
pub async fn send_contact_form(
&self,
name: &str,
email: &str,
subject: &str,
message: &str,
recipient: &str,
) -> EmailResult<String> {
self.send_contact_form_with_language(name, email, subject, message, recipient, "en")
.await
}
/// Send a contact form submission with specific language
pub async fn send_contact_form_with_language(
&self,
name: &str,
email: &str,
subject: &str,
message: &str,
recipient: &str,
language: &str,
) -> EmailResult<String> {
let submission = FormSubmission::new("contact", name, email, subject, message);
self.send_form_submission_with_language(&submission, recipient, language)
.await
}
/// Send a support form submission
pub async fn send_support_form(
&self,
name: &str,
email: &str,
subject: &str,
message: &str,
priority: Option<&str>,
category: Option<&str>,
recipient: &str,
) -> EmailResult<String> {
self.send_support_form_with_language(
name, email, subject, message, priority, category, recipient, "en",
)
.await
}
/// Send a support form submission with specific language
pub async fn send_support_form_with_language(
&self,
name: &str,
email: &str,
subject: &str,
message: &str,
priority: Option<&str>,
category: Option<&str>,
recipient: &str,
language: &str,
) -> EmailResult<String> {
let mut submission = FormSubmission::new("support", name, email, subject, message);
if let Some(priority) = priority {
submission = submission.field("priority", priority);
}
if let Some(category) = category {
submission = submission.field("category", category);
}
self.send_form_submission_with_language(&submission, recipient, language)
.await
}
/// Get email configuration
pub fn get_config(&self) -> &EmailConfig {
&self.config
}
/// Check if email service is enabled
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
/// Get provider name
pub fn provider_name(&self) -> &'static str {
self.provider.provider_name()
}
/// Check if provider is configured
pub fn is_configured(&self) -> bool {
self.provider.is_configured()
}
/// Reload email templates
pub async fn reload_templates(&self) -> EmailResult<()> {
let mut template_engine = self.template_engine.write().await;
template_engine.reload_templates()?;
info!("Email templates reloaded successfully");
Ok(())
}
/// Get available template names
pub async fn get_template_names(&self) -> Vec<String> {
let template_engine = self.template_engine.read().await;
template_engine.get_template_names()
}
/// Get available template languages
pub async fn get_available_languages(&self) -> Vec<String> {
let template_engine = self.template_engine.read().await;
template_engine.get_available_languages()
}
/// Check if a template exists for a specific language
pub async fn has_template_for_language(&self, template_name: &str, language: &str) -> bool {
let template_engine = self.template_engine.read().await;
template_engine.has_template_for_language(template_name, language)
}
/// Validate an email message
fn validate_message(&self, message: &EmailMessage) -> EmailResult<()> {
// Check recipient
if message.to.is_empty() {
return Err(EmailError::validation("Recipient email is required"));
}
// Basic email validation
if !message.to.contains('@') {
return Err(EmailError::validation("Invalid recipient email format"));
}
// Check subject
if message.subject.is_empty() {
return Err(EmailError::validation("Email subject is required"));
}
// Check that we have either a body or a template
if message.text_body.is_none() && message.html_body.is_none() && message.template.is_none()
{
return Err(EmailError::validation(
"Email must have either text body, HTML body, or template",
));
}
// Validate sender if provided
if let Some(from) = &message.from {
if !from.contains('@') {
return Err(EmailError::validation("Invalid sender email format"));
}
}
// Validate CC emails
if let Some(cc_list) = &message.cc {
for cc in cc_list {
if !cc.contains('@') {
return Err(EmailError::validation(&format!(
"Invalid CC email format: {}",
cc
)));
}
}
}
// Validate BCC emails
if let Some(bcc_list) = &message.bcc {
for bcc in bcc_list {
if !bcc.contains('@') {
return Err(EmailError::validation(&format!(
"Invalid BCC email format: {}",
bcc
)));
}
}
}
// Validate reply-to
if let Some(reply_to) = &message.reply_to {
if !reply_to.contains('@') {
return Err(EmailError::validation("Invalid reply-to email format"));
}
}
Ok(())
}
/// Process an email message (apply templates, set defaults, etc.)
#[allow(dead_code)]
async fn process_message(&self, message: &EmailMessage) -> EmailResult<EmailMessage> {
self.process_message_with_language(message, "en").await
}
/// Process an email message with specific language (apply templates, set defaults, etc.)
async fn process_message_with_language(
&self,
message: &EmailMessage,
language: &str,
) -> EmailResult<EmailMessage> {
let mut processed = message.clone();
// Set default sender if not provided
if processed.from.is_none() {
processed.from = Some(self.config.default_from.clone());
}
if processed.from_name.is_none() {
processed.from_name = Some(self.config.default_from_name.clone());
}
// Process template if specified
if let Some(template_name) = &message.template {
let empty_hashmap = HashMap::new();
let template_data = message.template_data.as_ref().unwrap_or(&empty_hashmap);
let template_engine_guard = self.template_engine.read().await;
let template_engine = &*template_engine_guard;
// Try to render HTML template
let html_template_name = format!("{}_html", template_name);
if template_engine.has_template_for_language(&html_template_name, language) {
let html_body = template_engine.render_with_language(
&html_template_name,
template_data,
language,
)?;
processed.html_body = Some(html_body);
}
// Try to render text template
let text_template_name = format!("{}_text", template_name);
if template_engine.has_template_for_language(&text_template_name, language) {
let text_body = template_engine.render_with_language(
&text_template_name,
template_data,
language,
)?;
processed.text_body = Some(text_body);
}
// If no templates found, return an error
if processed.html_body.is_none() && processed.text_body.is_none() {
return Err(EmailError::template(&format!(
"Template not found: {} for language {} (looked for {}_html and {}_text)",
template_name, language, template_name, template_name
)));
}
}
Ok(processed)
}
}
/// Email service builder for easier configuration
pub struct EmailServiceBuilder {
config: EmailConfig,
}
impl EmailServiceBuilder {
/// Create a new email service builder
pub fn new() -> Self {
Self {
config: EmailConfig {
default_from: "noreply@example.com".to_string(),
default_from_name: "No Reply".to_string(),
provider: crate::email::EmailProvider::Console,
smtp: None,
sendgrid: None,
template_dir: "./templates/email".to_string(),
enabled: true,
},
}
}
/// Set default sender email
pub fn default_from(mut self, email: &str) -> Self {
self.config.default_from = email.to_string();
self
}
/// Set default sender name
pub fn default_from_name(mut self, name: &str) -> Self {
self.config.default_from_name = name.to_string();
self
}
/// Set provider to SMTP
pub fn smtp_provider(mut self, smtp_config: crate::email::SmtpConfig) -> Self {
self.config.provider = crate::email::EmailProvider::Smtp;
self.config.smtp = Some(smtp_config);
self
}
/// Set provider to SendGrid
pub fn sendgrid_provider(mut self, sendgrid_config: crate::email::SendGridConfig) -> Self {
self.config.provider = crate::email::EmailProvider::SendGrid;
self.config.sendgrid = Some(sendgrid_config);
self
}
/// Set provider to Console (for development)
pub fn console_provider(mut self) -> Self {
self.config.provider = crate::email::EmailProvider::Console;
self
}
/// Set template directory
pub fn template_dir(mut self, dir: &str) -> Self {
self.config.template_dir = dir.to_string();
self
}
/// Enable or disable email sending
pub fn enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
/// Build the email service
pub async fn build(self) -> EmailResult<EmailService> {
EmailService::new(self.config).await
}
}
impl Default for EmailServiceBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_email_service_creation() {
let temp_dir = TempDir::new().unwrap();
let service = EmailServiceBuilder::new()
.console_provider()
.template_dir(temp_dir.path().to_str().unwrap())
.build()
.await;
assert!(service.is_ok());
let service = service.unwrap();
assert_eq!(service.provider_name(), "Console");
assert!(service.is_configured());
assert!(service.is_enabled());
}
#[tokio::test]
async fn test_simple_email_sending() {
let temp_dir = TempDir::new().unwrap();
let service = EmailServiceBuilder::new()
.console_provider()
.template_dir(temp_dir.path().to_str().unwrap())
.default_from("test@example.com")
.default_from_name("Test Sender")
.build()
.await
.unwrap();
let result = service
.send_simple_email("recipient@example.com", "Test Subject", "Test message body")
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_contact_form_sending() {
let temp_dir = TempDir::new().unwrap();
let service = EmailServiceBuilder::new()
.console_provider()
.template_dir(temp_dir.path().to_str().unwrap())
.enabled(false) // Disable email service for testing
.build()
.await
.unwrap();
let result = service
.send_contact_form(
"John Doe",
"john@example.com",
"Test Contact",
"This is a test contact form message",
"admin@example.com",
)
.await;
// Should succeed because email service is disabled
assert!(result.is_ok());
}
#[tokio::test]
async fn test_notification_sending() {
let temp_dir = TempDir::new().unwrap();
let service = EmailServiceBuilder::new()
.console_provider()
.template_dir(temp_dir.path().to_str().unwrap())
.enabled(false) // Disable email service for testing
.build()
.await
.unwrap();
let result = service
.send_notification(
"recipient@example.com",
"Test Notification",
"This is a test notification message",
Some("Additional content here"),
)
.await;
// Should succeed because email service is disabled
assert!(result.is_ok());
}
#[tokio::test]
async fn test_email_validation() {
let temp_dir = TempDir::new().unwrap();
let service = EmailServiceBuilder::new()
.console_provider()
.template_dir(temp_dir.path().to_str().unwrap())
.build()
.await
.unwrap();
// Test invalid email
let invalid_message =
EmailMessage::new("invalid-email", "Test Subject").text_body("Test body");
let result = service.send_email(&invalid_message).await;
assert!(result.is_err());
// Test empty subject
let empty_subject = EmailMessage::new("test@example.com", "").text_body("Test body");
let result = service.send_email(&empty_subject).await;
assert!(result.is_err());
// Test no body or template
let no_body = EmailMessage::new("test@example.com", "Test Subject");
let result = service.send_email(&no_body).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_disabled_email_service() {
let temp_dir = TempDir::new().unwrap();
let service = EmailServiceBuilder::new()
.console_provider()
.template_dir(temp_dir.path().to_str().unwrap())
.enabled(false)
.build()
.await
.unwrap();
let result = service
.send_simple_email("test@example.com", "Test Subject", "Test body")
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "disabled");
}
}