701 lines
22 KiB
Rust
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");
|
|
}
|
|
}
|