use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::Result;
use crate::config::{EmailApiConfig, EmailConfig, SmtpConfig};
use crate::parser::replace_variables;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailMessage {
pub to: String,
pub subject: String,
pub html_body: String,
#[serde(default)]
pub text_body: Option<String>,
}
pub async fn send_email(config: &EmailConfig, message: &EmailMessage) -> Result<()> {
if let Some(ref smtp) = config.smtp {
send_via_smtp(config, smtp, message).await
} else if let Some(ref api) = config.api {
send_via_api(config, api, message).await
} else {
Err(crate::Error::Config(
"Email config has neither [email.smtp] nor [email.api] section".into(),
))
}
}
pub fn render_email_template(
project_root: &Path,
template_dir: &str,
template_name: &str,
context: &HashMap<String, Value>,
) -> Result<String> {
let template_path = project_root
.join(template_dir)
.join(format!("{}.html", template_name));
let template = std::fs::read_to_string(&template_path).map_err(|e| {
crate::Error::Config(format!(
"Failed to read email template {}: {}",
template_path.display(),
e
))
})?;
Ok(replace_variables(&template, context))
}
async fn send_via_smtp(
config: &EmailConfig,
smtp: &SmtpConfig,
message: &EmailMessage,
) -> Result<()> {
use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from: Mailbox = if let Some(ref name) = config.from_name {
format!("{} <{}>", name, config.from)
.parse()
.map_err(|e| crate::Error::Config(format!("Invalid from address: {}", e)))?
} else {
config
.from
.parse()
.map_err(|e| crate::Error::Config(format!("Invalid from address: {}", e)))?
};
let to: Mailbox = message
.to
.parse()
.map_err(|e| crate::Error::Config(format!("Invalid to address: {}", e)))?;
let builder = Message::builder()
.from(from)
.to(to)
.subject(&message.subject);
let email = if let Some(ref text) = message.text_body {
builder
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text.clone()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(message.html_body.clone()),
),
)
.map_err(|e| crate::Error::Config(format!("Failed to build email: {}", e)))?
} else {
builder
.header(ContentType::TEXT_HTML)
.body(message.html_body.clone())
.map_err(|e| crate::Error::Config(format!("Failed to build email: {}", e)))?
};
let mut transport_builder = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
.map_err(|e| crate::Error::Config(format!("SMTP relay error: {}", e)))?
.port(smtp.port);
if let (Some(user), Some(pass)) = (&smtp.username, &smtp.password) {
let creds = Credentials::new(resolve_env(user), resolve_env(pass));
transport_builder = transport_builder.credentials(creds);
}
let transport = transport_builder.build();
transport
.send(email)
.await
.map_err(|e| crate::Error::Config(format!("SMTP send failed: {}", e)))?;
tracing::info!("Email sent via SMTP to {}", message.to);
Ok(())
}
async fn send_via_api(
config: &EmailConfig,
api: &EmailApiConfig,
message: &EmailMessage,
) -> Result<()> {
match api.provider.as_str() {
"resend" => send_via_resend(config, api, message).await,
other => Err(crate::Error::Config(format!(
"Unknown email API provider: {}",
other
))),
}
}
async fn send_via_resend(
config: &EmailConfig,
api: &EmailApiConfig,
message: &EmailMessage,
) -> Result<()> {
let api_key = resolve_env(&api.api_key);
let from_str = if let Some(ref name) = config.from_name {
format!("{} <{}>", name, config.from)
} else {
config.from.clone()
};
let mut body = serde_json::json!({
"from": from_str,
"to": [&message.to],
"subject": &message.subject,
"html": &message.html_body,
});
if let Some(ref text) = message.text_body {
body["text"] = serde_json::json!(text);
}
let client = crate::http_client::build_http_client(None)
.map_err(|e| crate::Error::Config(format!("Failed to build Resend HTTP client: {}", e)))?;
let resp = client
.post("https://api.resend.com/emails")
.header("Authorization", format!("Bearer {}", api_key))
.json(&body)
.send()
.await
.map_err(|e| crate::Error::Config(format!("Resend API request failed: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(crate::Error::Config(format!(
"Resend API error ({}): {}",
status, text
)));
}
tracing::info!("Email sent via Resend API to {}", message.to);
Ok(())
}
fn resolve_env(value: &str) -> String {
if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
std::env::var(var_name).unwrap_or_else(|_| {
tracing::warn!("Environment variable {} not found", var_name);
value.to_string()
})
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_email_message_fields() {
let msg = EmailMessage {
to: "user@example.com".into(),
subject: "Hello".into(),
html_body: "<h1>Hi</h1>".into(),
text_body: Some("Hi".into()),
};
assert_eq!(msg.to, "user@example.com");
assert_eq!(msg.subject, "Hello");
assert_eq!(msg.html_body, "<h1>Hi</h1>");
assert_eq!(msg.text_body.as_deref(), Some("Hi"));
}
#[test]
fn test_email_message_without_text_body() {
let msg = EmailMessage {
to: "user@example.com".into(),
subject: "Hello".into(),
html_body: "<h1>Hi</h1>".into(),
text_body: None,
};
assert!(msg.text_body.is_none());
}
#[test]
fn test_email_message_serialize_deserialize() {
let msg = EmailMessage {
to: "a@b.com".into(),
subject: "Test".into(),
html_body: "<p>body</p>".into(),
text_body: Some("body".into()),
};
let json = serde_json::to_string(&msg).unwrap();
let back: EmailMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back.to, "a@b.com");
assert_eq!(back.subject, "Test");
assert_eq!(back.html_body, "<p>body</p>");
assert_eq!(back.text_body.as_deref(), Some("body"));
}
#[test]
fn test_email_message_deserialize_without_text_body() {
let json = r#"{"to":"a@b.com","subject":"Hi","html_body":"<b>hi</b>"}"#;
let msg: EmailMessage = serde_json::from_str(json).unwrap();
assert!(msg.text_body.is_none());
}
#[test]
fn test_render_email_template_basic() {
let tmp = TempDir::new().unwrap();
let emails_dir = tmp.path().join("emails");
std::fs::create_dir_all(&emails_dir).unwrap();
std::fs::write(
emails_dir.join("welcome.html"),
"<h1>Hello #name#!</h1><p>Your order #order_id# is confirmed.</p>",
)
.unwrap();
let mut ctx = HashMap::new();
ctx.insert("name".into(), serde_json::json!("Alice"));
ctx.insert("order_id".into(), serde_json::json!("ORD-123"));
let result = render_email_template(tmp.path(), "emails", "welcome", &ctx).unwrap();
assert!(result.contains("Hello Alice!"), "got: {}", result);
assert!(result.contains("ORD-123"), "got: {}", result);
}
#[test]
fn test_render_email_template_missing_file() {
let tmp = TempDir::new().unwrap();
let result = render_email_template(tmp.path(), "emails", "nonexistent", &HashMap::new());
assert!(result.is_err());
}
#[test]
fn test_render_email_template_with_filters() {
let tmp = TempDir::new().unwrap();
let emails_dir = tmp.path().join("emails");
std::fs::create_dir_all(&emails_dir).unwrap();
std::fs::write(emails_dir.join("notify.html"), "<p>#name|uppercase#</p>").unwrap();
let mut ctx = HashMap::new();
ctx.insert("name".into(), serde_json::json!("alice"));
let result = render_email_template(tmp.path(), "emails", "notify", &ctx).unwrap();
assert!(result.contains("ALICE"), "got: {}", result);
}
#[test]
fn test_resolve_env_with_env_var() {
unsafe {
std::env::set_var("WHAT_TEST_EMAIL_KEY", "secret123");
}
let resolved = resolve_env("${WHAT_TEST_EMAIL_KEY}");
assert_eq!(resolved, "secret123");
unsafe {
std::env::remove_var("WHAT_TEST_EMAIL_KEY");
}
}
#[test]
fn test_resolve_env_plain_string() {
let resolved = resolve_env("plain-value");
assert_eq!(resolved, "plain-value");
}
#[test]
fn test_resolve_env_missing_var() {
let resolved = resolve_env("${WHAT_NONEXISTENT_EMAIL_VAR_XYZ}");
assert_eq!(resolved, "${WHAT_NONEXISTENT_EMAIL_VAR_XYZ}");
}
#[test]
fn test_email_config_parsing() {
let toml_str = r#"
[email]
from = "app@example.com"
from_name = "My App"
template_dir = "mail-templates"
[email.smtp]
host = "smtp.example.com"
port = 465
username = "${SMTP_USER}"
password = "${SMTP_PASS}"
"#;
let config: crate::config::Config = toml::from_str(toml_str).unwrap();
let email = config.email.unwrap();
assert_eq!(email.from, "app@example.com");
assert_eq!(email.from_name.as_deref(), Some("My App"));
assert_eq!(email.template_dir, "mail-templates");
let smtp = email.smtp.unwrap();
assert_eq!(smtp.host, "smtp.example.com");
assert_eq!(smtp.port, 465);
assert_eq!(smtp.username.as_deref(), Some("${SMTP_USER}"));
assert_eq!(smtp.password.as_deref(), Some("${SMTP_PASS}"));
}
#[test]
fn test_email_config_api_provider() {
let toml_str = r#"
[email]
from = "noreply@example.com"
[email.api]
provider = "resend"
api_key = "${RESEND_API_KEY}"
"#;
let config: crate::config::Config = toml::from_str(toml_str).unwrap();
let email = config.email.unwrap();
assert_eq!(email.from, "noreply@example.com");
assert!(email.smtp.is_none());
let api = email.api.unwrap();
assert_eq!(api.provider, "resend");
assert_eq!(api.api_key, "${RESEND_API_KEY}");
}
#[test]
fn test_email_config_defaults() {
let toml_str = r#"
[email]
from = "app@example.com"
[email.smtp]
host = "smtp.example.com"
"#;
let config: crate::config::Config = toml::from_str(toml_str).unwrap();
let email = config.email.unwrap();
assert_eq!(email.template_dir, "emails"); assert!(email.from_name.is_none());
let smtp = email.smtp.unwrap();
assert_eq!(smtp.port, 587); assert!(smtp.username.is_none());
assert!(smtp.password.is_none());
}
#[test]
fn test_config_without_email_section() {
let config: crate::config::Config = toml::from_str("").unwrap();
assert!(config.email.is_none());
}
#[tokio::test]
async fn test_send_email_no_transport_configured() {
let config = EmailConfig {
from: "test@example.com".into(),
from_name: None,
smtp: None,
api: None,
template_dir: "emails".into(),
};
let msg = EmailMessage {
to: "user@example.com".into(),
subject: "Test".into(),
html_body: "<p>Hi</p>".into(),
text_body: None,
};
let result = send_email(&config, &msg).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("neither"), "got: {}", err);
}
#[tokio::test]
async fn test_send_email_unknown_api_provider() {
let config = EmailConfig {
from: "test@example.com".into(),
from_name: None,
smtp: None,
api: Some(EmailApiConfig {
provider: "mailchimp".into(),
api_key: "key".into(),
}),
template_dir: "emails".into(),
};
let msg = EmailMessage {
to: "user@example.com".into(),
subject: "Test".into(),
html_body: "<p>Hi</p>".into(),
text_body: None,
};
let result = send_email(&config, &msg).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Unknown email API provider"), "got: {}", err);
}
}