use anyhow::{Context, Result};
use chrono::Datelike;
use lettre::{
message::{header::ContentType, Mailbox, MultiPart, SinglePart},
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use serde::Serialize;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct EmailConfig {
pub provider: EmailProvider,
pub from_email: String,
pub from_name: String,
pub api_key: Option<String>, pub smtp_host: Option<String>, pub smtp_port: Option<u16>,
pub smtp_username: Option<String>,
pub smtp_password: Option<String>,
}
#[derive(Debug, Clone)]
pub enum EmailProvider {
Postmark,
Brevo,
Smtp,
Disabled, }
impl EmailProvider {
fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"postmark" => EmailProvider::Postmark,
"brevo" | "sendinblue" => EmailProvider::Brevo,
"smtp" => EmailProvider::Smtp,
_ => EmailProvider::Disabled,
}
}
}
#[derive(Debug, Clone)]
pub struct EmailMessage {
pub to: String,
pub subject: String,
pub html_body: String,
pub text_body: String,
}
pub struct EmailService {
config: EmailConfig,
client: reqwest::Client,
}
impl EmailService {
pub fn new(config: EmailConfig) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?;
Ok(Self { config, client })
}
pub fn from_env() -> anyhow::Result<Self> {
let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "disabled".to_string());
let config = EmailConfig {
provider: EmailProvider::from_str(&provider),
from_email: std::env::var("EMAIL_FROM")
.unwrap_or_else(|_| "noreply@mockforge.dev".to_string()),
from_name: std::env::var("EMAIL_FROM_NAME").unwrap_or_else(|_| "MockForge".to_string()),
api_key: std::env::var("EMAIL_API_KEY").ok(),
smtp_host: std::env::var("SMTP_HOST").ok(),
smtp_port: std::env::var("SMTP_PORT").ok().and_then(|p| p.parse().ok()),
smtp_username: std::env::var("SMTP_USERNAME").ok(),
smtp_password: std::env::var("SMTP_PASSWORD").ok(),
};
Self::new(config)
}
pub async fn send(&self, message: EmailMessage) -> Result<()> {
match &self.config.provider {
EmailProvider::Postmark => self.send_via_postmark(message).await,
EmailProvider::Brevo => self.send_via_brevo(message).await,
EmailProvider::Smtp => self.send_via_smtp(message).await,
EmailProvider::Disabled => {
tracing::info!("Email disabled, would send: {} to {}", message.subject, message.to);
Ok(())
}
}
}
async fn send_via_postmark(&self, message: EmailMessage) -> Result<()> {
let api_key = self.config.api_key.as_ref().context("Postmark requires EMAIL_API_KEY")?;
#[derive(Serialize)]
#[allow(non_snake_case)]
struct PostmarkRequest {
From: String,
To: String,
Subject: String,
HtmlBody: String,
TextBody: String,
}
let request = PostmarkRequest {
From: format!("{} <{}>", self.config.from_name, self.config.from_email),
To: message.to,
Subject: message.subject,
HtmlBody: message.html_body,
TextBody: message.text_body,
};
let response = self
.client
.post("https://api.postmarkapp.com/email")
.header("X-Postmark-Server-Token", api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send email via Postmark")?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Postmark API error: {}", error_text);
}
Ok(())
}
async fn send_via_brevo(&self, message: EmailMessage) -> Result<()> {
let api_key = self.config.api_key.as_ref().context("Brevo requires EMAIL_API_KEY")?;
#[derive(Serialize)]
struct BrevoSender {
name: String,
email: String,
}
#[derive(Serialize)]
struct BrevoTo {
email: String,
}
#[derive(Serialize)]
#[allow(non_snake_case)]
struct BrevoRequest {
sender: BrevoSender,
to: Vec<BrevoTo>,
subject: String,
htmlContent: String,
textContent: String,
}
let request = BrevoRequest {
sender: BrevoSender {
name: self.config.from_name.clone(),
email: self.config.from_email.clone(),
},
to: vec![BrevoTo { email: message.to }],
subject: message.subject,
htmlContent: message.html_body,
textContent: message.text_body,
};
let response = self
.client
.post("https://api.brevo.com/v3/smtp/email")
.header("api-key", api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send email via Brevo")?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Brevo API error: {}", error_text);
}
Ok(())
}
async fn send_via_smtp(&self, message: EmailMessage) -> Result<()> {
let smtp_host = self.config.smtp_host.as_ref().context("SMTP requires SMTP_HOST")?;
let smtp_port = self.config.smtp_port.unwrap_or(587);
let from_mailbox: Mailbox =
format!("{} <{}>", self.config.from_name, self.config.from_email)
.parse()
.context("Invalid from email address")?;
let to_mailbox: Mailbox = message.to.parse().context("Invalid recipient email address")?;
let log_to = message.to.clone();
let log_subject = message.subject.clone();
let email = Message::builder()
.from(from_mailbox)
.to(to_mailbox)
.subject(message.subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(message.text_body),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(message.html_body),
),
)
.context("Failed to build email message")?;
let mailer: AsyncSmtpTransport<Tokio1Executor> = if let (Some(username), Some(password)) =
(self.config.smtp_username.as_ref(), self.config.smtp_password.as_ref())
{
let creds = Credentials::new(username.clone(), password.clone());
if smtp_port == 465 {
AsyncSmtpTransport::<Tokio1Executor>::relay(smtp_host)
.context("Failed to create SMTP relay")?
.credentials(creds)
.port(smtp_port)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(smtp_host)
.context("Failed to create SMTP STARTTLS relay")?
.credentials(creds)
.port(smtp_port)
.build()
}
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(smtp_host)
.port(smtp_port)
.build()
};
mailer.send(email).await.context("Failed to send email via SMTP")?;
tracing::info!("Email sent via SMTP to {} with subject: {}", log_to, log_subject);
Ok(())
}
pub fn generate_welcome_email(username: &str, email: &str) -> EmailMessage {
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="header">
<h1>Welcome to MockForge Cloud! 🎉</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>Welcome to MockForge Cloud! We're excited to have you on board.</p>
<p>MockForge helps you build, test, and deploy API mocks with ease. Here's what you can do:</p>
<ul>
<li>🚀 Deploy hosted mocks with shareable URLs</li>
<li>📦 Browse and install plugins from our marketplace</li>
<li>📋 Use templates and scenarios to accelerate development</li>
<li>🤖 Leverage AI-powered mock generation (BYOK on Free tier)</li>
</ul>
<p style="text-align: center;">
<a href="https://app.mockforge.dev" class="button">Get Started</a>
</p>
<p>If you have any questions, feel free to reach out to our support team.</p>
<p>Happy mocking!<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Welcome to MockForge Cloud!
Hi {},
Welcome to MockForge Cloud! We're excited to have you on board.
MockForge helps you build, test, and deploy API mocks with ease. Here's what you can do:
- Deploy hosted mocks with shareable URLs
- Browse and install plugins from our marketplace
- Use templates and scenarios to accelerate development
- Leverage AI-powered mock generation (BYOK on Free tier)
Get started: https://app.mockforge.dev
If you have any questions, feel free to reach out to our support team.
Happy mocking!
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: "Welcome to MockForge Cloud! 🎉".to_string(),
html_body,
text_body,
}
}
pub fn generate_security_alert_email(
username: &str,
email: &str,
headline: &str,
detail: &str,
) -> EmailMessage {
let year = chrono::Utc::now().year();
let html_body = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #dc2626; color: white; padding: 30px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
.detail {{ background: #fef3f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 16px 0; }}
</style>
</head>
<body>
<div class="header"><h1>🔒 Security Alert</h1></div>
<div class="content">
<p>Hi {username},</p>
<p><strong>{headline}</strong></p>
<div class="detail">{detail}</div>
<p>You're receiving this because you have security alerts enabled. You can manage this preference in <a href="https://app.mockforge.dev">your account settings</a>.</p>
<p>— The MockForge Team</p>
</div>
<div class="footer"><p>© {year} MockForge</p></div>
</body>
</html>"#,
);
let text_body = format!(
"Security Alert — {headline}\n\nHi {username},\n\n{detail}\n\nYou're receiving this because you have security alerts enabled. Manage this preference at https://app.mockforge.dev.\n\n— The MockForge Team\n© {year} MockForge\n",
);
EmailMessage {
to: email.to_string(),
subject: format!("[MockForge] {}", headline),
html_body,
text_body,
}
}
pub fn generate_subscription_confirmation(
username: &str,
email: &str,
plan: &str,
amount: Option<f64>,
period_end: Option<chrono::DateTime<chrono::Utc>>,
) -> EmailMessage {
let amount_text =
amount.map(|a| format!("${:.2}", a)).unwrap_or_else(|| "your plan".to_string());
let period_text = period_end
.map(|d| format!("Your subscription renews on {}", d.format("%B %d, %Y")))
.unwrap_or_else(|| "Your subscription is active".to_string());
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.info-box {{ background: #f8f9fa; border-left: 4px solid #667eea; padding: 15px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="header">
<h1>Subscription Confirmed! ✅</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>Your subscription to MockForge Cloud <strong>{}</strong> plan has been confirmed!</p>
<div class="info-box">
<p><strong>Plan:</strong> {}</p>
<p><strong>Amount:</strong> {}</p>
<p><strong>{}</strong></p>
</div>
<p>You now have access to all features included in your plan. Thank you for choosing MockForge!</p>
<p style="text-align: center;">
<a href="https://app.mockforge.dev/billing" class="button">Manage Subscription</a>
</p>
<p>If you have any questions about your subscription, please contact our support team.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
plan,
plan,
amount_text,
period_text,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Subscription Confirmed!
Hi {},
Your subscription to MockForge Cloud {} plan has been confirmed!
Plan: {}
Amount: {}
{}
You now have access to all features included in your plan. Thank you for choosing MockForge!
Manage your subscription: https://app.mockforge.dev/billing
If you have any questions about your subscription, please contact our support team.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
plan,
plan,
amount_text,
period_text,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: format!("Subscription Confirmed - MockForge Cloud {}", plan),
html_body,
text_body,
}
}
pub fn generate_payment_failed(
username: &str,
email: &str,
plan: &str,
amount: f64,
retry_date: Option<chrono::DateTime<chrono::Utc>>,
) -> EmailMessage {
let retry_text = retry_date
.map(|d| format!("We'll automatically retry on {}.", d.format("%B %d, %Y")))
.unwrap_or_else(|| {
"Please update your payment method to continue service.".to_string()
});
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #e74c3c; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.warning-box {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="header">
<h1>Payment Failed ⚠️</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>We were unable to process your payment for your MockForge Cloud <strong>{}</strong> subscription.</p>
<div class="warning-box">
<p><strong>Amount:</strong> ${:.2}</p>
<p><strong>Plan:</strong> {}</p>
<p>{}</p>
</div>
<p>To avoid service interruption, please update your payment method as soon as possible.</p>
<p style="text-align: center;">
<a href="https://app.mockforge.dev/billing" class="button">Update Payment Method</a>
</p>
<p>If you continue to experience issues, please contact our support team for assistance.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
plan,
amount,
plan,
retry_text,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Payment Failed
Hi {},
We were unable to process your payment for your MockForge Cloud {} subscription.
Amount: ${:.2}
Plan: {}
{}
To avoid service interruption, please update your payment method as soon as possible.
Update payment method: https://app.mockforge.dev/billing
If you continue to experience issues, please contact our support team for assistance.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
plan,
amount,
plan,
retry_text,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: "Payment Failed - Action Required".to_string(),
html_body,
text_body,
}
}
pub fn generate_subscription_canceled(
username: &str,
email: &str,
plan: &str,
access_until: Option<chrono::DateTime<chrono::Utc>>,
) -> EmailMessage {
let access_text = access_until
.map(|d| {
format!(
"You'll continue to have access to {} features until {}.",
plan,
d.format("%B %d, %Y")
)
})
.unwrap_or_else(|| format!("Your {} subscription has been canceled.", plan));
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.info-box {{ background: #f8f9fa; border-left: 4px solid #95a5a6; padding: 15px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="header">
<h1>Subscription Canceled</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>Your MockForge Cloud <strong>{}</strong> subscription has been canceled.</p>
<div class="info-box">
<p>{}</p>
</div>
<p>We're sorry to see you go! If you change your mind, you can reactivate your subscription at any time.</p>
<p style="text-align: center;">
<a href="https://app.mockforge.dev/billing" class="button">Reactivate Subscription</a>
</p>
<p>If you have any feedback about your experience, we'd love to hear from you.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
plan,
access_text,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Subscription Canceled
Hi {},
Your MockForge Cloud {} subscription has been canceled.
{}
We're sorry to see you go! If you change your mind, you can reactivate your subscription at any time.
Reactivate: https://app.mockforge.dev/billing
If you have any feedback about your experience, we'd love to hear from you.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
plan,
access_text,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: "Subscription Canceled".to_string(),
html_body,
text_body,
}
}
pub fn generate_usage_threshold_warning(
username: &str,
email: &str,
metric_label: &str,
plan: &str,
used_pretty: &str,
limit_pretty: &str,
threshold_pct: u16,
) -> EmailMessage {
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #f59e0b; color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.info-box {{ background: #fff7ed; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="header">
<h1>Usage Approaching Limit</h1>
</div>
<div class="content">
<p>Hi {username},</p>
<p>Your MockForge Cloud organization has crossed <strong>{threshold_pct}%</strong> of its <strong>{metric_label}</strong> limit on the current billing period.</p>
<div class="info-box">
<p><strong>Metric:</strong> {metric_label}</p>
<p><strong>Plan:</strong> {plan}</p>
<p><strong>Used:</strong> {used_pretty} of {limit_pretty}</p>
</div>
<p>If you continue at the current rate, you may hit your plan limit before the end of the period. Consider upgrading or contacting us if you expect a temporary spike.</p>
<p style="text-align: center;">
<a href="https://app.mockforge.dev/usage" class="button">View Usage</a>
</p>
<p>You can dismiss this alert from the usage dashboard.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {year} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username = username,
plan = plan,
metric_label = metric_label,
used_pretty = used_pretty,
limit_pretty = limit_pretty,
threshold_pct = threshold_pct,
year = chrono::Utc::now().year()
);
let text_body = format!(
r#"
Usage Approaching Limit
Hi {username},
Your MockForge Cloud organization has crossed {threshold_pct}% of its {metric_label} limit on the current billing period.
Metric: {metric_label}
Plan: {plan}
Used: {used_pretty} of {limit_pretty}
If you continue at the current rate, you may hit your plan limit before the end of the period. Consider upgrading or contact us if you expect a temporary spike.
View usage: https://app.mockforge.dev/usage
Best regards,
The MockForge Team
© {year} MockForge. All rights reserved.
"#,
username = username,
plan = plan,
metric_label = metric_label,
used_pretty = used_pretty,
limit_pretty = limit_pretty,
threshold_pct = threshold_pct,
year = chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: format!(
"MockForge: {}% of {} used on {} plan",
threshold_pct, metric_label, plan
),
html_body,
text_body,
}
}
pub fn generate_support_confirmation(
username: &str,
email: &str,
ticket_id: &str,
subject: &str,
) -> EmailMessage {
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.info-box {{ background: #f8f9fa; border-left: 4px solid #667eea; padding: 15px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="header">
<h1>Support Request Received ✅</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>We've received your support request and will respond as soon as possible based on your plan's SLA.</p>
<div class="info-box">
<p><strong>Ticket ID:</strong> {}</p>
<p><strong>Subject:</strong> {}</p>
</div>
<p>You can track the status of your request using the ticket ID above. We'll send you updates via email.</p>
<p>If you need to add more information to this request, please reply to this email or submit a new request with the ticket ID in the subject.</p>
<p>Thank you for contacting MockForge support!</p>
<p>Best regards,<br>The MockForge Support Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
ticket_id,
subject,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Support Request Received
Hi {},
We've received your support request and will respond as soon as possible based on your plan's SLA.
Ticket ID: {}
Subject: {}
You can track the status of your request using the ticket ID above. We'll send you updates via email.
If you need to add more information to this request, please reply to this email or submit a new request with the ticket ID in the subject.
Thank you for contacting MockForge support!
Best regards,
The MockForge Support Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
ticket_id,
subject,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: format!("Support Request Received - {}", ticket_id),
html_body,
text_body,
}
}
pub fn generate_verification_email(
username: &str,
email: &str,
verification_token: &str,
) -> EmailMessage {
let verification_url = format!(
"{}/verify-email?token={}",
std::env::var("APP_BASE_URL")
.unwrap_or_else(|_| "https://app.mockforge.dev".to_string()),
verification_token
);
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
.code {{ background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; word-break: break-all; }}
</style>
</head>
<body>
<div class="header">
<h1>Verify Your Email Address</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>Thank you for signing up for MockForge Cloud! Please verify your email address to complete your registration.</p>
<p style="text-align: center;">
<a href="{}" class="button">Verify Email Address</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<div class="code">{}</div>
<p>This verification link will expire in 24 hours.</p>
<p>If you didn't create an account with MockForge, you can safely ignore this email.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
verification_url,
verification_url,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Verify Your Email Address
Hi {},
Thank you for signing up for MockForge Cloud! Please verify your email address to complete your registration.
Click this link to verify your email:
{}
This verification link will expire in 24 hours.
If you didn't create an account with MockForge, you can safely ignore this email.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
verification_url,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: "Verify Your Email Address - MockForge Cloud".to_string(),
html_body,
text_body,
}
}
pub fn generate_token_rotation_reminder(
username: &str,
email: &str,
token_name: &str,
token_age_days: i64,
rotation_url: &str,
) -> EmailMessage {
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
.warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="header">
<h1>API Token Rotation Reminder</h1>
</div>
<div class="content">
<p>Hi {},</p>
<div class="warning">
<strong>Security Best Practice:</strong> Your API token "<strong>{}</strong>" is {} days old and should be rotated for security.
</div>
<p>Regularly rotating API tokens is a security best practice that helps protect your account and data. We recommend rotating tokens every 90 days.</p>
<p style="text-align: center;">
<a href="{}" class="button">Rotate Token Now</a>
</p>
<p>Or visit your API tokens page to rotate this token manually.</p>
<p>If you no longer need this token, you can delete it from your settings.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
token_name,
token_age_days,
rotation_url,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
API Token Rotation Reminder
Hi {},
Security Best Practice: Your API token "{}" is {} days old and should be rotated for security.
Regularly rotating API tokens is a security best practice that helps protect your account and data. We recommend rotating tokens every 90 days.
Rotate your token: {}
Or visit your API tokens page to rotate this token manually.
If you no longer need this token, you can delete it from your settings.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
token_name,
token_age_days,
rotation_url,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: format!("Action Required: Rotate Your API Token '{}'", token_name),
html_body,
text_body,
}
}
pub fn generate_password_reset_email(
username: &str,
email: &str,
reset_token: &str,
) -> EmailMessage {
let reset_url = format!(
"{}/reset-password?token={}",
std::env::var("APP_BASE_URL")
.unwrap_or_else(|_| "https://app.mockforge.dev".to_string()),
reset_token
);
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
.warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
.code {{ background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; word-break: break-all; }}
</style>
</head>
<body>
<div class="header">
<h1>Reset Your Password</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>We received a request to reset your password for your MockForge Cloud account.</p>
<p style="text-align: center;">
<a href="{}" class="button">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<div class="code">{}</div>
<div class="warning">
<strong>Security Notice:</strong> This password reset link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</div>
<p>If you continue to have problems, please contact our support team.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
username,
reset_url,
reset_url,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Reset Your Password
Hi {},
We received a request to reset your password for your MockForge Cloud account.
Click this link to reset your password:
{}
This password reset link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
If you continue to have problems, please contact our support team.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
reset_url,
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: "Reset Your Password - MockForge Cloud".to_string(),
html_body,
text_body,
}
}
pub fn generate_deployment_status_email(
username: &str,
email: &str,
deployment_name: &str,
status: &str,
deployment_url: Option<&str>,
error_message: Option<&str>,
) -> EmailMessage {
let (header_color, header_text, status_icon) = match status {
"active" => ("#28a745", "Deployment Successful", "✅"),
"failed" => ("#dc3545", "Deployment Failed", "❌"),
"deploying" => ("#007bff", "Deployment In Progress", "⏳"),
_ => ("#6c757d", "Deployment Status Update", "ℹ️"),
};
let deployment_link = deployment_url
.map(|url| {
format!(
r#"<p style="text-align: center;">
<a href="{}" class="button">View Deployment</a>
</p>"#,
url
)
})
.unwrap_or_default();
let error_section = error_message
.map(|msg| {
format!(
r#"<div class="warning">
<strong>Error Details:</strong><br>
<pre style="white-space: pre-wrap; font-size: 12px;">{}</pre>
</div>"#,
msg
)
})
.unwrap_or_default();
let html_body = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, {} 0%, {} 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 24px; background: {}; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
.warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="header">
<h1>{} {}</h1>
</div>
<div class="content">
<p>Hi {},</p>
<p>Your hosted mock deployment "<strong>{}</strong>" status has been updated to <strong>{}</strong>.</p>
{}
{}
<p>You can view and manage your deployments in the MockForge Cloud dashboard.</p>
<p>Best regards,<br>The MockForge Team</p>
</div>
<div class="footer">
<p>© {} MockForge. All rights reserved.</p>
<p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
</div>
</body>
</html>
"#,
header_color,
header_color,
header_color,
status_icon,
header_text,
username,
deployment_name,
status,
deployment_link,
error_section,
chrono::Utc::now().year()
);
let text_body = format!(
r#"
Deployment Status Update
Hi {},
Your hosted mock deployment "{}" status has been updated to {}.
{}
{}
You can view and manage your deployments in the MockForge Cloud dashboard.
Best regards,
The MockForge Team
© {} MockForge. All rights reserved.
Terms: https://mockforge.dev/terms
Privacy: https://mockforge.dev/privacy
"#,
username,
deployment_name,
status,
deployment_url
.map(|url| format!("View deployment: {}", url))
.unwrap_or_default(),
error_message.map(|msg| format!("Error: {}", msg)).unwrap_or_default(),
chrono::Utc::now().year()
);
EmailMessage {
to: email.to_string(),
subject: format!("{} - Deployment '{}' is {}", status_icon, deployment_name, status),
html_body,
text_body,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email_provider_from_str() {
assert!(matches!(EmailProvider::from_str("postmark"), EmailProvider::Postmark));
assert!(matches!(EmailProvider::from_str("POSTMARK"), EmailProvider::Postmark));
assert!(matches!(EmailProvider::from_str("brevo"), EmailProvider::Brevo));
assert!(matches!(EmailProvider::from_str("sendinblue"), EmailProvider::Brevo));
assert!(matches!(EmailProvider::from_str("smtp"), EmailProvider::Smtp));
assert!(matches!(EmailProvider::from_str("disabled"), EmailProvider::Disabled));
assert!(matches!(EmailProvider::from_str("unknown"), EmailProvider::Disabled));
}
#[test]
fn test_email_service_new() {
let config = EmailConfig {
provider: EmailProvider::Disabled,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: None,
smtp_host: None,
smtp_port: None,
smtp_username: None,
smtp_password: None,
};
let service = EmailService::new(config.clone()).expect("Failed to create email service");
assert!(matches!(service.config.provider, EmailProvider::Disabled));
assert_eq!(service.config.from_email, "test@example.com");
assert_eq!(service.config.from_name, "Test");
}
#[tokio::test]
async fn test_send_email_disabled_provider() {
let config = EmailConfig {
provider: EmailProvider::Disabled,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: None,
smtp_host: None,
smtp_port: None,
smtp_username: None,
smtp_password: None,
};
let service = EmailService::new(config).expect("Failed to create email service");
let message = EmailMessage {
to: "recipient@example.com".to_string(),
subject: "Test".to_string(),
html_body: "<p>Test</p>".to_string(),
text_body: "Test".to_string(),
};
let result = service.send(message).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_email_smtp_missing_host() {
let config = EmailConfig {
provider: EmailProvider::Smtp,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: None,
smtp_host: None, smtp_port: Some(587),
smtp_username: Some("user".to_string()),
smtp_password: Some("pass".to_string()),
};
let service = EmailService::new(config).expect("Failed to create email service");
let message = EmailMessage {
to: "recipient@example.com".to_string(),
subject: "Test".to_string(),
html_body: "<p>Test</p>".to_string(),
text_body: "Test".to_string(),
};
let result = service.send(message).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("SMTP requires SMTP_HOST"));
}
#[tokio::test]
async fn test_send_email_smtp_connection_error() {
let config = EmailConfig {
provider: EmailProvider::Smtp,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: None,
smtp_host: Some("localhost".to_string()),
smtp_port: Some(12345), smtp_username: None,
smtp_password: None,
};
let service = EmailService::new(config).expect("Failed to create email service");
let message = EmailMessage {
to: "recipient@example.com".to_string(),
subject: "Test".to_string(),
html_body: "<p>Test</p>".to_string(),
text_body: "Test".to_string(),
};
let result = service.send(message).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Failed to send email via SMTP") || err.contains("connection"),
"Expected SMTP connection error, got: {}",
err
);
}
#[tokio::test]
async fn test_send_email_postmark_missing_api_key() {
let config = EmailConfig {
provider: EmailProvider::Postmark,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: None,
smtp_host: None,
smtp_port: None,
smtp_username: None,
smtp_password: None,
};
let service = EmailService::new(config).expect("Failed to create email service");
let message = EmailMessage {
to: "recipient@example.com".to_string(),
subject: "Test".to_string(),
html_body: "<p>Test</p>".to_string(),
text_body: "Test".to_string(),
};
let result = service.send(message).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("requires EMAIL_API_KEY"));
}
#[tokio::test]
async fn test_send_email_brevo_missing_api_key() {
let config = EmailConfig {
provider: EmailProvider::Brevo,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: None,
smtp_host: None,
smtp_port: None,
smtp_username: None,
smtp_password: None,
};
let service = EmailService::new(config).expect("Failed to create email service");
let message = EmailMessage {
to: "recipient@example.com".to_string(),
subject: "Test".to_string(),
html_body: "<p>Test</p>".to_string(),
text_body: "Test".to_string(),
};
let result = service.send(message).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("requires EMAIL_API_KEY"));
}
#[test]
fn test_generate_welcome_email() {
let email = EmailService::generate_welcome_email("testuser", "test@example.com");
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Welcome to MockForge Cloud"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Welcome to MockForge Cloud"));
assert!(email.html_body.contains("https://app.mockforge.dev"));
assert!(email.text_body.contains("testuser"));
assert!(email.text_body.contains("Welcome to MockForge Cloud"));
let current_year = chrono::Utc::now().year();
assert!(email.html_body.contains(¤t_year.to_string()));
assert!(email.text_body.contains(¤t_year.to_string()));
}
#[test]
fn test_generate_subscription_confirmation_with_amount() {
let period_end = chrono::Utc::now() + chrono::Duration::days(30);
let email = EmailService::generate_subscription_confirmation(
"testuser",
"test@example.com",
"Pro",
Some(29.99),
Some(period_end),
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Subscription Confirmed"));
assert!(email.subject.contains("Pro"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Pro"));
assert!(email.html_body.contains("$29.99"));
assert!(email.html_body.contains("renews on"));
assert!(email.text_body.contains("testuser"));
assert!(email.text_body.contains("Pro"));
assert!(email.text_body.contains("$29.99"));
}
#[test]
fn test_generate_subscription_confirmation_without_amount() {
let email = EmailService::generate_subscription_confirmation(
"testuser",
"test@example.com",
"Free",
None,
None,
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Subscription Confirmed"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Free"));
assert!(email.html_body.contains("your plan"));
assert!(email.html_body.contains("subscription is active"));
}
#[test]
fn test_generate_payment_failed_with_retry() {
let retry_date = chrono::Utc::now() + chrono::Duration::days(3);
let email = EmailService::generate_payment_failed(
"testuser",
"test@example.com",
"Pro",
29.99,
Some(retry_date),
);
assert_eq!(email.to, "test@example.com");
assert_eq!(email.subject, "Payment Failed - Action Required");
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Pro"));
assert!(email.html_body.contains("$29.99"));
assert!(email.html_body.contains("automatically retry"));
assert!(email.text_body.contains("testuser"));
assert!(email.text_body.contains("$29.99"));
}
#[test]
fn test_generate_payment_failed_without_retry() {
let email = EmailService::generate_payment_failed(
"testuser",
"test@example.com",
"Pro",
29.99,
None,
);
assert_eq!(email.to, "test@example.com");
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("$29.99"));
assert!(email.html_body.contains("update your payment method"));
}
#[test]
fn test_generate_subscription_canceled_with_access() {
let access_until = chrono::Utc::now() + chrono::Duration::days(15);
let email = EmailService::generate_subscription_canceled(
"testuser",
"test@example.com",
"Pro",
Some(access_until),
);
assert_eq!(email.to, "test@example.com");
assert_eq!(email.subject, "Subscription Canceled");
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Pro"));
assert!(email.html_body.contains("continue to have access"));
assert!(email.text_body.contains("testuser"));
assert!(email.text_body.contains("Pro"));
}
#[test]
fn test_generate_subscription_canceled_without_access() {
let email = EmailService::generate_subscription_canceled(
"testuser",
"test@example.com",
"Pro",
None,
);
assert_eq!(email.to, "test@example.com");
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Pro"));
assert!(email.html_body.contains("canceled"));
}
#[test]
fn test_generate_support_confirmation() {
let email = EmailService::generate_support_confirmation(
"testuser",
"test@example.com",
"TICKET-12345",
"Help with API integration",
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Support Request Received"));
assert!(email.subject.contains("TICKET-12345"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("TICKET-12345"));
assert!(email.html_body.contains("Help with API integration"));
assert!(email.text_body.contains("testuser"));
assert!(email.text_body.contains("TICKET-12345"));
assert!(email.text_body.contains("Help with API integration"));
}
#[test]
fn test_generate_verification_email() {
std::env::set_var("APP_BASE_URL", "https://test.mockforge.dev");
let email = EmailService::generate_verification_email(
"testuser",
"test@example.com",
"abc123token",
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Verify Your Email Address"));
assert!(email.html_body.contains("testuser"));
assert!(email
.html_body
.contains("https://test.mockforge.dev/verify-email?token=abc123token"));
assert!(email.html_body.contains("24 hours"));
assert!(email.text_body.contains("testuser"));
assert!(email
.text_body
.contains("https://test.mockforge.dev/verify-email?token=abc123token"));
std::env::remove_var("APP_BASE_URL");
}
#[test]
fn test_generate_verification_email_default_url() {
std::env::remove_var("APP_BASE_URL");
let email = EmailService::generate_verification_email(
"testuser",
"test@example.com",
"abc123token",
);
assert!(email
.html_body
.contains("https://app.mockforge.dev/verify-email?token=abc123token"));
}
#[test]
fn test_generate_token_rotation_reminder() {
let email = EmailService::generate_token_rotation_reminder(
"testuser",
"test@example.com",
"Production API Key",
120,
"https://app.mockforge.dev/tokens/rotate/123",
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Action Required"));
assert!(email.subject.contains("Production API Key"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("Production API Key"));
assert!(email.html_body.contains("120 days"));
assert!(email.html_body.contains("https://app.mockforge.dev/tokens/rotate/123"));
assert!(email.text_body.contains("120 days"));
assert!(email.text_body.contains("Production API Key"));
}
#[test]
fn test_generate_password_reset_email() {
std::env::set_var("APP_BASE_URL", "https://test.mockforge.dev");
let email = EmailService::generate_password_reset_email(
"testuser",
"test@example.com",
"reset123token",
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("Reset Your Password"));
assert!(email.html_body.contains("testuser"));
assert!(email
.html_body
.contains("https://test.mockforge.dev/reset-password?token=reset123token"));
assert!(email.html_body.contains("1 hour"));
assert!(email.text_body.contains("testuser"));
assert!(email
.text_body
.contains("https://test.mockforge.dev/reset-password?token=reset123token"));
std::env::remove_var("APP_BASE_URL");
}
#[test]
fn test_generate_deployment_status_email_active() {
let email = EmailService::generate_deployment_status_email(
"testuser",
"test@example.com",
"my-api-mock",
"active",
Some("https://my-api-mock.mockforge.app"),
None,
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("✅"));
assert!(email.subject.contains("my-api-mock"));
assert!(email.subject.contains("active"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("my-api-mock"));
assert!(email.html_body.contains("active"));
assert!(email.html_body.contains("https://my-api-mock.mockforge.app"));
assert!(!email.html_body.contains("Error Details"));
}
#[test]
fn test_generate_deployment_status_email_failed() {
let email = EmailService::generate_deployment_status_email(
"testuser",
"test@example.com",
"my-api-mock",
"failed",
None,
Some("Build failed: missing dependency"),
);
assert_eq!(email.to, "test@example.com");
assert!(email.subject.contains("❌"));
assert!(email.subject.contains("my-api-mock"));
assert!(email.subject.contains("failed"));
assert!(email.html_body.contains("testuser"));
assert!(email.html_body.contains("my-api-mock"));
assert!(email.html_body.contains("failed"));
assert!(email.html_body.contains("Error Details"));
assert!(email.html_body.contains("Build failed: missing dependency"));
assert!(email.text_body.contains("Build failed: missing dependency"));
}
#[test]
fn test_generate_deployment_status_email_deploying() {
let email = EmailService::generate_deployment_status_email(
"testuser",
"test@example.com",
"my-api-mock",
"deploying",
Some("https://my-api-mock.mockforge.app"),
None,
);
assert!(email.subject.contains("⏳"));
assert!(email.subject.contains("deploying"));
assert!(email.html_body.contains("my-api-mock"));
assert!(email.html_body.contains("deploying"));
}
#[test]
fn test_email_message_clone() {
let message = EmailMessage {
to: "test@example.com".to_string(),
subject: "Test".to_string(),
html_body: "<p>Test</p>".to_string(),
text_body: "Test".to_string(),
};
let cloned = message.clone();
assert_eq!(message.to, cloned.to);
assert_eq!(message.subject, cloned.subject);
assert_eq!(message.html_body, cloned.html_body);
assert_eq!(message.text_body, cloned.text_body);
}
#[test]
fn test_email_config_clone() {
let config = EmailConfig {
provider: EmailProvider::Postmark,
from_email: "test@example.com".to_string(),
from_name: "Test".to_string(),
api_key: Some("key123".to_string()),
smtp_host: Some("localhost".to_string()),
smtp_port: Some(587),
smtp_username: Some("user".to_string()),
smtp_password: Some("pass".to_string()),
};
let cloned = config.clone();
assert_eq!(config.from_email, cloned.from_email);
assert_eq!(config.from_name, cloned.from_name);
assert_eq!(config.api_key, cloned.api_key);
assert_eq!(config.smtp_host, cloned.smtp_host);
}
#[test]
fn test_email_templates_contain_required_links() {
let welcome = EmailService::generate_welcome_email("user", "test@example.com");
assert!(welcome.html_body.contains("mockforge.dev/terms"));
assert!(welcome.html_body.contains("mockforge.dev/privacy"));
assert!(welcome.text_body.contains("mockforge.dev/terms"));
assert!(welcome.text_body.contains("mockforge.dev/privacy"));
let subscription = EmailService::generate_subscription_confirmation(
"user",
"test@example.com",
"Pro",
Some(29.99),
None,
);
assert!(subscription.html_body.contains("app.mockforge.dev/billing"));
let payment_failed =
EmailService::generate_payment_failed("user", "test@example.com", "Pro", 29.99, None);
assert!(payment_failed.html_body.contains("app.mockforge.dev/billing"));
let canceled =
EmailService::generate_subscription_canceled("user", "test@example.com", "Pro", None);
assert!(canceled.html_body.contains("app.mockforge.dev/billing"));
}
#[test]
fn test_email_subject_lines() {
let welcome = EmailService::generate_welcome_email("user", "test@example.com");
assert!(welcome.subject.len() < 100); assert!(welcome.subject.contains("MockForge"));
let verification =
EmailService::generate_verification_email("user", "test@example.com", "token");
assert!(verification.subject.contains("Verify"));
let reset =
EmailService::generate_password_reset_email("user", "test@example.com", "token");
assert!(reset.subject.contains("Reset"));
let support =
EmailService::generate_support_confirmation("user", "test@example.com", "123", "Help");
assert!(support.subject.contains("123"));
}
}