Skip to main content

mockforge_registry_server/
email.rs

1//! Email notification service for user communications
2//!
3//! Supports multiple email providers:
4//! - Postmark (via API)
5//! - Brevo (via API)
6//! - SMTP (direct SMTP sending)
7
8use anyhow::{Context, Result};
9use chrono::Datelike;
10use lettre::{
11    message::{header::ContentType, Mailbox, MultiPart, SinglePart},
12    transport::smtp::authentication::Credentials,
13    AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
14};
15use serde::Serialize;
16use std::time::Duration;
17
18/// Email configuration
19#[derive(Debug, Clone)]
20pub struct EmailConfig {
21    pub provider: EmailProvider,
22    pub from_email: String,
23    pub from_name: String,
24    pub api_key: Option<String>,   // For Postmark/Brevo
25    pub smtp_host: Option<String>, // For SMTP fallback
26    pub smtp_port: Option<u16>,
27    pub smtp_username: Option<String>,
28    pub smtp_password: Option<String>,
29}
30
31/// Email provider type
32#[derive(Debug, Clone)]
33pub enum EmailProvider {
34    Postmark,
35    Brevo,
36    Smtp,
37    Disabled, // For development/testing
38}
39
40impl EmailProvider {
41    fn from_str(s: &str) -> Self {
42        match s.to_lowercase().as_str() {
43            "postmark" => EmailProvider::Postmark,
44            "brevo" | "sendinblue" => EmailProvider::Brevo,
45            "smtp" => EmailProvider::Smtp,
46            _ => EmailProvider::Disabled,
47        }
48    }
49}
50
51/// Email message
52#[derive(Debug, Clone)]
53pub struct EmailMessage {
54    pub to: String,
55    pub subject: String,
56    pub html_body: String,
57    pub text_body: String,
58}
59
60/// Email service
61pub struct EmailService {
62    config: EmailConfig,
63    client: reqwest::Client,
64}
65
66impl EmailService {
67    /// Create a new email service
68    ///
69    /// # Errors
70    /// Returns an error if the HTTP client cannot be created
71    pub fn new(config: EmailConfig) -> Result<Self> {
72        let client = reqwest::Client::builder()
73            .timeout(Duration::from_secs(10))
74            .build()
75            .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?;
76
77        Ok(Self { config, client })
78    }
79
80    /// Create email service from environment variables
81    ///
82    /// # Errors
83    /// Returns an error if the HTTP client cannot be created
84    pub fn from_env() -> Result<Self> {
85        let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "disabled".to_string());
86
87        let config = EmailConfig {
88            provider: EmailProvider::from_str(&provider),
89            from_email: std::env::var("EMAIL_FROM")
90                .unwrap_or_else(|_| "noreply@mockforge.dev".to_string()),
91            from_name: std::env::var("EMAIL_FROM_NAME").unwrap_or_else(|_| "MockForge".to_string()),
92            api_key: std::env::var("EMAIL_API_KEY").ok(),
93            smtp_host: std::env::var("SMTP_HOST").ok(),
94            smtp_port: std::env::var("SMTP_PORT").ok().and_then(|p| p.parse().ok()),
95            smtp_username: std::env::var("SMTP_USERNAME").ok(),
96            smtp_password: std::env::var("SMTP_PASSWORD").ok(),
97        };
98
99        Self::new(config)
100    }
101
102    /// Send an email
103    pub async fn send(&self, message: EmailMessage) -> Result<()> {
104        match &self.config.provider {
105            EmailProvider::Postmark => self.send_via_postmark(message).await,
106            EmailProvider::Brevo => self.send_via_brevo(message).await,
107            EmailProvider::Smtp => self.send_via_smtp(message).await,
108            EmailProvider::Disabled => {
109                tracing::info!("Email disabled, would send: {} to {}", message.subject, message.to);
110                Ok(())
111            }
112        }
113    }
114
115    /// Send email via Postmark API
116    async fn send_via_postmark(&self, message: EmailMessage) -> Result<()> {
117        let api_key = self.config.api_key.as_ref().context("Postmark requires EMAIL_API_KEY")?;
118
119        #[derive(Serialize)]
120        #[allow(non_snake_case)]
121        struct PostmarkRequest {
122            From: String,
123            To: String,
124            Subject: String,
125            HtmlBody: String,
126            TextBody: String,
127        }
128
129        let request = PostmarkRequest {
130            From: format!("{} <{}>", self.config.from_name, self.config.from_email),
131            To: message.to,
132            Subject: message.subject,
133            HtmlBody: message.html_body,
134            TextBody: message.text_body,
135        };
136
137        let response = self
138            .client
139            .post("https://api.postmarkapp.com/email")
140            .header("X-Postmark-Server-Token", api_key)
141            .header("Content-Type", "application/json")
142            .json(&request)
143            .send()
144            .await
145            .context("Failed to send email via Postmark")?;
146
147        if !response.status().is_success() {
148            let error_text = response.text().await.unwrap_or_default();
149            anyhow::bail!("Postmark API error: {}", error_text);
150        }
151
152        Ok(())
153    }
154
155    /// Send email via Brevo API
156    async fn send_via_brevo(&self, message: EmailMessage) -> Result<()> {
157        let api_key = self.config.api_key.as_ref().context("Brevo requires EMAIL_API_KEY")?;
158
159        #[derive(Serialize)]
160        struct BrevoSender {
161            name: String,
162            email: String,
163        }
164
165        #[derive(Serialize)]
166        struct BrevoTo {
167            email: String,
168        }
169
170        #[derive(Serialize)]
171        #[allow(non_snake_case)]
172        struct BrevoRequest {
173            sender: BrevoSender,
174            to: Vec<BrevoTo>,
175            subject: String,
176            htmlContent: String,
177            textContent: String,
178        }
179
180        let request = BrevoRequest {
181            sender: BrevoSender {
182                name: self.config.from_name.clone(),
183                email: self.config.from_email.clone(),
184            },
185            to: vec![BrevoTo { email: message.to }],
186            subject: message.subject,
187            htmlContent: message.html_body,
188            textContent: message.text_body,
189        };
190
191        let response = self
192            .client
193            .post("https://api.brevo.com/v3/smtp/email")
194            .header("api-key", api_key)
195            .header("Content-Type", "application/json")
196            .json(&request)
197            .send()
198            .await
199            .context("Failed to send email via Brevo")?;
200
201        if !response.status().is_success() {
202            let error_text = response.text().await.unwrap_or_default();
203            anyhow::bail!("Brevo API error: {}", error_text);
204        }
205
206        Ok(())
207    }
208
209    /// Send email via SMTP
210    async fn send_via_smtp(&self, message: EmailMessage) -> Result<()> {
211        let smtp_host = self.config.smtp_host.as_ref().context("SMTP requires SMTP_HOST")?;
212
213        let smtp_port = self.config.smtp_port.unwrap_or(587);
214
215        // Parse from address
216        let from_mailbox: Mailbox =
217            format!("{} <{}>", self.config.from_name, self.config.from_email)
218                .parse()
219                .context("Invalid from email address")?;
220
221        // Parse to address
222        let to_mailbox: Mailbox = message.to.parse().context("Invalid recipient email address")?;
223
224        // Store for logging after the message is consumed
225        let log_to = message.to.clone();
226        let log_subject = message.subject.clone();
227
228        // Build the email message with both HTML and plain text parts
229        let email = Message::builder()
230            .from(from_mailbox)
231            .to(to_mailbox)
232            .subject(message.subject)
233            .multipart(
234                MultiPart::alternative()
235                    .singlepart(
236                        SinglePart::builder()
237                            .header(ContentType::TEXT_PLAIN)
238                            .body(message.text_body),
239                    )
240                    .singlepart(
241                        SinglePart::builder()
242                            .header(ContentType::TEXT_HTML)
243                            .body(message.html_body),
244                    ),
245            )
246            .context("Failed to build email message")?;
247
248        // Build the SMTP transport
249        let mailer: AsyncSmtpTransport<Tokio1Executor> = if let (Some(username), Some(password)) =
250            (self.config.smtp_username.as_ref(), self.config.smtp_password.as_ref())
251        {
252            // Authenticated SMTP
253            let creds = Credentials::new(username.clone(), password.clone());
254
255            if smtp_port == 465 {
256                // SMTPS (implicit TLS)
257                AsyncSmtpTransport::<Tokio1Executor>::relay(smtp_host)
258                    .context("Failed to create SMTP relay")?
259                    .credentials(creds)
260                    .port(smtp_port)
261                    .build()
262            } else {
263                // STARTTLS (explicit TLS on port 587 or 25)
264                AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(smtp_host)
265                    .context("Failed to create SMTP STARTTLS relay")?
266                    .credentials(creds)
267                    .port(smtp_port)
268                    .build()
269            }
270        } else {
271            // Unauthenticated SMTP (for local/dev mail servers)
272            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(smtp_host)
273                .port(smtp_port)
274                .build()
275        };
276
277        // Send the email
278        mailer.send(email).await.context("Failed to send email via SMTP")?;
279
280        tracing::info!("Email sent via SMTP to {} with subject: {}", log_to, log_subject);
281
282        Ok(())
283    }
284
285    /// Generate welcome email content
286    pub fn generate_welcome_email(username: &str, email: &str) -> EmailMessage {
287        let html_body = format!(
288            r#"
289<!DOCTYPE html>
290<html>
291<head>
292    <meta charset="utf-8">
293    <style>
294        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; }}
295        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
296        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
297        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
298        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
299    </style>
300</head>
301<body>
302    <div class="header">
303        <h1>Welcome to MockForge Cloud! 🎉</h1>
304    </div>
305    <div class="content">
306        <p>Hi {},</p>
307        <p>Welcome to MockForge Cloud! We're excited to have you on board.</p>
308        <p>MockForge helps you build, test, and deploy API mocks with ease. Here's what you can do:</p>
309        <ul>
310            <li>🚀 Deploy hosted mocks with shareable URLs</li>
311            <li>📦 Browse and install plugins from our marketplace</li>
312            <li>📋 Use templates and scenarios to accelerate development</li>
313            <li>🤖 Leverage AI-powered mock generation (BYOK on Free tier)</li>
314        </ul>
315        <p style="text-align: center;">
316            <a href="https://app.mockforge.dev" class="button">Get Started</a>
317        </p>
318        <p>If you have any questions, feel free to reach out to our support team.</p>
319        <p>Happy mocking!<br>The MockForge Team</p>
320    </div>
321    <div class="footer">
322        <p>© {} MockForge. All rights reserved.</p>
323        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
324    </div>
325</body>
326</html>
327"#,
328            username,
329            chrono::Utc::now().year()
330        );
331
332        let text_body = format!(
333            r#"
334Welcome to MockForge Cloud!
335
336Hi {},
337
338Welcome to MockForge Cloud! We're excited to have you on board.
339
340MockForge helps you build, test, and deploy API mocks with ease. Here's what you can do:
341
342- Deploy hosted mocks with shareable URLs
343- Browse and install plugins from our marketplace
344- Use templates and scenarios to accelerate development
345- Leverage AI-powered mock generation (BYOK on Free tier)
346
347Get started: https://app.mockforge.dev
348
349If you have any questions, feel free to reach out to our support team.
350
351Happy mocking!
352The MockForge Team
353
354© {} MockForge. All rights reserved.
355Terms: https://mockforge.dev/terms
356Privacy: https://mockforge.dev/privacy
357"#,
358            username,
359            chrono::Utc::now().year()
360        );
361
362        EmailMessage {
363            to: email.to_string(),
364            subject: "Welcome to MockForge Cloud! 🎉".to_string(),
365            html_body,
366            text_body,
367        }
368    }
369
370    /// Generate a security-alert email for an account-level event (password
371    /// change, 2FA enabled/disabled, etc). Gated by `users.security_alerts`
372    /// at the call site.
373    pub fn generate_security_alert_email(
374        username: &str,
375        email: &str,
376        headline: &str,
377        detail: &str,
378    ) -> EmailMessage {
379        let year = chrono::Utc::now().year();
380        let html_body = format!(
381            r#"<!DOCTYPE html>
382<html>
383<head>
384    <meta charset="utf-8">
385    <style>
386        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; }}
387        .header {{ background: #dc2626; color: white; padding: 30px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
388        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
389        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
390        .detail {{ background: #fef3f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 16px 0; }}
391    </style>
392</head>
393<body>
394    <div class="header"><h1>🔒 Security Alert</h1></div>
395    <div class="content">
396        <p>Hi {username},</p>
397        <p><strong>{headline}</strong></p>
398        <div class="detail">{detail}</div>
399        <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>
400        <p>— The MockForge Team</p>
401    </div>
402    <div class="footer"><p>© {year} MockForge</p></div>
403</body>
404</html>"#,
405        );
406
407        let text_body = format!(
408            "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",
409        );
410
411        EmailMessage {
412            to: email.to_string(),
413            subject: format!("[MockForge] {}", headline),
414            html_body,
415            text_body,
416        }
417    }
418
419    /// Generate subscription confirmation email
420    pub fn generate_subscription_confirmation(
421        username: &str,
422        email: &str,
423        plan: &str,
424        amount: Option<f64>,
425        period_end: Option<chrono::DateTime<chrono::Utc>>,
426    ) -> EmailMessage {
427        let amount_text =
428            amount.map(|a| format!("${:.2}", a)).unwrap_or_else(|| "your plan".to_string());
429
430        let period_text = period_end
431            .map(|d| format!("Your subscription renews on {}", d.format("%B %d, %Y")))
432            .unwrap_or_else(|| "Your subscription is active".to_string());
433
434        let html_body = format!(
435            r#"
436<!DOCTYPE html>
437<html>
438<head>
439    <meta charset="utf-8">
440    <style>
441        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; }}
442        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
443        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
444        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
445        .info-box {{ background: #f8f9fa; border-left: 4px solid #667eea; padding: 15px; margin: 20px 0; }}
446        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
447    </style>
448</head>
449<body>
450    <div class="header">
451        <h1>Subscription Confirmed! ✅</h1>
452    </div>
453    <div class="content">
454        <p>Hi {},</p>
455        <p>Your subscription to MockForge Cloud <strong>{}</strong> plan has been confirmed!</p>
456        <div class="info-box">
457            <p><strong>Plan:</strong> {}</p>
458            <p><strong>Amount:</strong> {}</p>
459            <p><strong>{}</strong></p>
460        </div>
461        <p>You now have access to all features included in your plan. Thank you for choosing MockForge!</p>
462        <p style="text-align: center;">
463            <a href="https://app.mockforge.dev/billing" class="button">Manage Subscription</a>
464        </p>
465        <p>If you have any questions about your subscription, please contact our support team.</p>
466        <p>Best regards,<br>The MockForge Team</p>
467    </div>
468    <div class="footer">
469        <p>© {} MockForge. All rights reserved.</p>
470        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
471    </div>
472</body>
473</html>
474"#,
475            username,
476            plan,
477            plan,
478            amount_text,
479            period_text,
480            chrono::Utc::now().year()
481        );
482
483        let text_body = format!(
484            r#"
485Subscription Confirmed!
486
487Hi {},
488
489Your subscription to MockForge Cloud {} plan has been confirmed!
490
491Plan: {}
492Amount: {}
493{}
494
495You now have access to all features included in your plan. Thank you for choosing MockForge!
496
497Manage your subscription: https://app.mockforge.dev/billing
498
499If you have any questions about your subscription, please contact our support team.
500
501Best regards,
502The MockForge Team
503
504© {} MockForge. All rights reserved.
505Terms: https://mockforge.dev/terms
506Privacy: https://mockforge.dev/privacy
507"#,
508            username,
509            plan,
510            plan,
511            amount_text,
512            period_text,
513            chrono::Utc::now().year()
514        );
515
516        EmailMessage {
517            to: email.to_string(),
518            subject: format!("Subscription Confirmed - MockForge Cloud {}", plan),
519            html_body,
520            text_body,
521        }
522    }
523
524    /// Generate payment failed email
525    pub fn generate_payment_failed(
526        username: &str,
527        email: &str,
528        plan: &str,
529        amount: f64,
530        retry_date: Option<chrono::DateTime<chrono::Utc>>,
531    ) -> EmailMessage {
532        let retry_text = retry_date
533            .map(|d| format!("We'll automatically retry on {}.", d.format("%B %d, %Y")))
534            .unwrap_or_else(|| {
535                "Please update your payment method to continue service.".to_string()
536            });
537
538        let html_body = format!(
539            r#"
540<!DOCTYPE html>
541<html>
542<head>
543    <meta charset="utf-8">
544    <style>
545        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; }}
546        .header {{ background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
547        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
548        .button {{ display: inline-block; padding: 12px 24px; background: #e74c3c; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
549        .warning-box {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
550        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
551    </style>
552</head>
553<body>
554    <div class="header">
555        <h1>Payment Failed ⚠️</h1>
556    </div>
557    <div class="content">
558        <p>Hi {},</p>
559        <p>We were unable to process your payment for your MockForge Cloud <strong>{}</strong> subscription.</p>
560        <div class="warning-box">
561            <p><strong>Amount:</strong> ${:.2}</p>
562            <p><strong>Plan:</strong> {}</p>
563            <p>{}</p>
564        </div>
565        <p>To avoid service interruption, please update your payment method as soon as possible.</p>
566        <p style="text-align: center;">
567            <a href="https://app.mockforge.dev/billing" class="button">Update Payment Method</a>
568        </p>
569        <p>If you continue to experience issues, please contact our support team for assistance.</p>
570        <p>Best regards,<br>The MockForge Team</p>
571    </div>
572    <div class="footer">
573        <p>© {} MockForge. All rights reserved.</p>
574        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
575    </div>
576</body>
577</html>
578"#,
579            username,
580            plan,
581            amount,
582            plan,
583            retry_text,
584            chrono::Utc::now().year()
585        );
586
587        let text_body = format!(
588            r#"
589Payment Failed
590
591Hi {},
592
593We were unable to process your payment for your MockForge Cloud {} subscription.
594
595Amount: ${:.2}
596Plan: {}
597{}
598
599To avoid service interruption, please update your payment method as soon as possible.
600
601Update payment method: https://app.mockforge.dev/billing
602
603If you continue to experience issues, please contact our support team for assistance.
604
605Best regards,
606The MockForge Team
607
608© {} MockForge. All rights reserved.
609Terms: https://mockforge.dev/terms
610Privacy: https://mockforge.dev/privacy
611"#,
612            username,
613            plan,
614            amount,
615            plan,
616            retry_text,
617            chrono::Utc::now().year()
618        );
619
620        EmailMessage {
621            to: email.to_string(),
622            subject: "Payment Failed - Action Required".to_string(),
623            html_body,
624            text_body,
625        }
626    }
627
628    /// Generate subscription canceled email
629    pub fn generate_subscription_canceled(
630        username: &str,
631        email: &str,
632        plan: &str,
633        access_until: Option<chrono::DateTime<chrono::Utc>>,
634    ) -> EmailMessage {
635        let access_text = access_until
636            .map(|d| {
637                format!(
638                    "You'll continue to have access to {} features until {}.",
639                    plan,
640                    d.format("%B %d, %Y")
641                )
642            })
643            .unwrap_or_else(|| format!("Your {} subscription has been canceled.", plan));
644
645        let html_body = format!(
646            r#"
647<!DOCTYPE html>
648<html>
649<head>
650    <meta charset="utf-8">
651    <style>
652        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; }}
653        .header {{ background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
654        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
655        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
656        .info-box {{ background: #f8f9fa; border-left: 4px solid #95a5a6; padding: 15px; margin: 20px 0; }}
657        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
658    </style>
659</head>
660<body>
661    <div class="header">
662        <h1>Subscription Canceled</h1>
663    </div>
664    <div class="content">
665        <p>Hi {},</p>
666        <p>Your MockForge Cloud <strong>{}</strong> subscription has been canceled.</p>
667        <div class="info-box">
668            <p>{}</p>
669        </div>
670        <p>We're sorry to see you go! If you change your mind, you can reactivate your subscription at any time.</p>
671        <p style="text-align: center;">
672            <a href="https://app.mockforge.dev/billing" class="button">Reactivate Subscription</a>
673        </p>
674        <p>If you have any feedback about your experience, we'd love to hear from you.</p>
675        <p>Best regards,<br>The MockForge Team</p>
676    </div>
677    <div class="footer">
678        <p>© {} MockForge. All rights reserved.</p>
679        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
680    </div>
681</body>
682</html>
683"#,
684            username,
685            plan,
686            access_text,
687            chrono::Utc::now().year()
688        );
689
690        let text_body = format!(
691            r#"
692Subscription Canceled
693
694Hi {},
695
696Your MockForge Cloud {} subscription has been canceled.
697
698{}
699
700We're sorry to see you go! If you change your mind, you can reactivate your subscription at any time.
701
702Reactivate: https://app.mockforge.dev/billing
703
704If you have any feedback about your experience, we'd love to hear from you.
705
706Best regards,
707The MockForge Team
708
709© {} MockForge. All rights reserved.
710Terms: https://mockforge.dev/terms
711Privacy: https://mockforge.dev/privacy
712"#,
713            username,
714            plan,
715            access_text,
716            chrono::Utc::now().year()
717        );
718
719        EmailMessage {
720            to: email.to_string(),
721            subject: "Subscription Canceled".to_string(),
722            html_body,
723            text_body,
724        }
725    }
726
727    /// Generate usage-threshold warning email (e.g. crossed 75% or 90% of a
728    /// metric limit on the current billing period).
729    pub fn generate_usage_threshold_warning(
730        username: &str,
731        email: &str,
732        metric_label: &str,
733        plan: &str,
734        used_pretty: &str,
735        limit_pretty: &str,
736        threshold_pct: u16,
737    ) -> EmailMessage {
738        let html_body = format!(
739            r#"
740<!DOCTYPE html>
741<html>
742<head>
743    <meta charset="utf-8">
744    <style>
745        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; }}
746        .header {{ background: #f59e0b; color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
747        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
748        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
749        .info-box {{ background: #fff7ed; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0; }}
750        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
751    </style>
752</head>
753<body>
754    <div class="header">
755        <h1>Usage Approaching Limit</h1>
756    </div>
757    <div class="content">
758        <p>Hi {username},</p>
759        <p>Your MockForge Cloud organization has crossed <strong>{threshold_pct}%</strong> of its <strong>{metric_label}</strong> limit on the current billing period.</p>
760        <div class="info-box">
761            <p><strong>Metric:</strong> {metric_label}</p>
762            <p><strong>Plan:</strong> {plan}</p>
763            <p><strong>Used:</strong> {used_pretty} of {limit_pretty}</p>
764        </div>
765        <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>
766        <p style="text-align: center;">
767            <a href="https://app.mockforge.dev/usage" class="button">View Usage</a>
768        </p>
769        <p>You can dismiss this alert from the usage dashboard.</p>
770        <p>Best regards,<br>The MockForge Team</p>
771    </div>
772    <div class="footer">
773        <p>© {year} MockForge. All rights reserved.</p>
774        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
775    </div>
776</body>
777</html>
778"#,
779            username = username,
780            plan = plan,
781            metric_label = metric_label,
782            used_pretty = used_pretty,
783            limit_pretty = limit_pretty,
784            threshold_pct = threshold_pct,
785            year = chrono::Utc::now().year()
786        );
787
788        let text_body = format!(
789            r#"
790Usage Approaching Limit
791
792Hi {username},
793
794Your MockForge Cloud organization has crossed {threshold_pct}% of its {metric_label} limit on the current billing period.
795
796Metric: {metric_label}
797Plan: {plan}
798Used: {used_pretty} of {limit_pretty}
799
800If 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.
801
802View usage: https://app.mockforge.dev/usage
803
804Best regards,
805The MockForge Team
806
807© {year} MockForge. All rights reserved.
808"#,
809            username = username,
810            plan = plan,
811            metric_label = metric_label,
812            used_pretty = used_pretty,
813            limit_pretty = limit_pretty,
814            threshold_pct = threshold_pct,
815            year = chrono::Utc::now().year()
816        );
817
818        EmailMessage {
819            to: email.to_string(),
820            subject: format!(
821                "MockForge: {}% of {} used on {} plan",
822                threshold_pct, metric_label, plan
823            ),
824            html_body,
825            text_body,
826        }
827    }
828
829    /// Generate support request confirmation email
830    pub fn generate_support_confirmation(
831        username: &str,
832        email: &str,
833        ticket_id: &str,
834        subject: &str,
835    ) -> EmailMessage {
836        let html_body = format!(
837            r#"
838<!DOCTYPE html>
839<html>
840<head>
841    <meta charset="utf-8">
842    <style>
843        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; }}
844        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
845        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
846        .info-box {{ background: #f8f9fa; border-left: 4px solid #667eea; padding: 15px; margin: 20px 0; }}
847        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
848    </style>
849</head>
850<body>
851    <div class="header">
852        <h1>Support Request Received ✅</h1>
853    </div>
854    <div class="content">
855        <p>Hi {},</p>
856        <p>We've received your support request and will respond as soon as possible based on your plan's SLA.</p>
857        <div class="info-box">
858            <p><strong>Ticket ID:</strong> {}</p>
859            <p><strong>Subject:</strong> {}</p>
860        </div>
861        <p>You can track the status of your request using the ticket ID above. We'll send you updates via email.</p>
862        <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>
863        <p>Thank you for contacting MockForge support!</p>
864        <p>Best regards,<br>The MockForge Support Team</p>
865    </div>
866    <div class="footer">
867        <p>© {} MockForge. All rights reserved.</p>
868        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
869    </div>
870</body>
871</html>
872"#,
873            username,
874            ticket_id,
875            subject,
876            chrono::Utc::now().year()
877        );
878
879        let text_body = format!(
880            r#"
881Support Request Received
882
883Hi {},
884
885We've received your support request and will respond as soon as possible based on your plan's SLA.
886
887Ticket ID: {}
888Subject: {}
889
890You can track the status of your request using the ticket ID above. We'll send you updates via email.
891
892If 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.
893
894Thank you for contacting MockForge support!
895
896Best regards,
897The MockForge Support Team
898
899© {} MockForge. All rights reserved.
900Terms: https://mockforge.dev/terms
901Privacy: https://mockforge.dev/privacy
902"#,
903            username,
904            ticket_id,
905            subject,
906            chrono::Utc::now().year()
907        );
908
909        EmailMessage {
910            to: email.to_string(),
911            subject: format!("Support Request Received - {}", ticket_id),
912            html_body,
913            text_body,
914        }
915    }
916
917    /// Generate email verification email
918    pub fn generate_verification_email(
919        username: &str,
920        email: &str,
921        verification_token: &str,
922    ) -> EmailMessage {
923        let verification_url = format!(
924            "{}/verify-email?token={}",
925            std::env::var("APP_BASE_URL")
926                .unwrap_or_else(|_| "https://app.mockforge.dev".to_string()),
927            verification_token
928        );
929
930        let html_body = format!(
931            r#"
932<!DOCTYPE html>
933<html>
934<head>
935    <meta charset="utf-8">
936    <style>
937        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; }}
938        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
939        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
940        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
941        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
942        .code {{ background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; word-break: break-all; }}
943    </style>
944</head>
945<body>
946    <div class="header">
947        <h1>Verify Your Email Address</h1>
948    </div>
949    <div class="content">
950        <p>Hi {},</p>
951        <p>Thank you for signing up for MockForge Cloud! Please verify your email address to complete your registration.</p>
952        <p style="text-align: center;">
953            <a href="{}" class="button">Verify Email Address</a>
954        </p>
955        <p>Or copy and paste this link into your browser:</p>
956        <div class="code">{}</div>
957        <p>This verification link will expire in 24 hours.</p>
958        <p>If you didn't create an account with MockForge, you can safely ignore this email.</p>
959        <p>Best regards,<br>The MockForge Team</p>
960    </div>
961    <div class="footer">
962        <p>© {} MockForge. All rights reserved.</p>
963        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
964    </div>
965</body>
966</html>
967"#,
968            username,
969            verification_url,
970            verification_url,
971            chrono::Utc::now().year()
972        );
973
974        let text_body = format!(
975            r#"
976Verify Your Email Address
977
978Hi {},
979
980Thank you for signing up for MockForge Cloud! Please verify your email address to complete your registration.
981
982Click this link to verify your email:
983{}
984
985This verification link will expire in 24 hours.
986
987If you didn't create an account with MockForge, you can safely ignore this email.
988
989Best regards,
990The MockForge Team
991
992© {} MockForge. All rights reserved.
993Terms: https://mockforge.dev/terms
994Privacy: https://mockforge.dev/privacy
995"#,
996            username,
997            verification_url,
998            chrono::Utc::now().year()
999        );
1000
1001        EmailMessage {
1002            to: email.to_string(),
1003            subject: "Verify Your Email Address - MockForge Cloud".to_string(),
1004            html_body,
1005            text_body,
1006        }
1007    }
1008
1009    /// Generate API token rotation reminder email
1010    pub fn generate_token_rotation_reminder(
1011        username: &str,
1012        email: &str,
1013        token_name: &str,
1014        token_age_days: i64,
1015        rotation_url: &str,
1016    ) -> EmailMessage {
1017        let html_body = format!(
1018            r#"
1019<!DOCTYPE html>
1020<html>
1021<head>
1022    <meta charset="utf-8">
1023    <style>
1024        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; }}
1025        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
1026        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
1027        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
1028        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
1029        .warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
1030    </style>
1031</head>
1032<body>
1033    <div class="header">
1034        <h1>API Token Rotation Reminder</h1>
1035    </div>
1036    <div class="content">
1037        <p>Hi {},</p>
1038        <div class="warning">
1039            <strong>Security Best Practice:</strong> Your API token "<strong>{}</strong>" is {} days old and should be rotated for security.
1040        </div>
1041        <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>
1042        <p style="text-align: center;">
1043            <a href="{}" class="button">Rotate Token Now</a>
1044        </p>
1045        <p>Or visit your API tokens page to rotate this token manually.</p>
1046        <p>If you no longer need this token, you can delete it from your settings.</p>
1047        <p>Best regards,<br>The MockForge Team</p>
1048    </div>
1049    <div class="footer">
1050        <p>© {} MockForge. All rights reserved.</p>
1051        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
1052    </div>
1053</body>
1054</html>
1055"#,
1056            username,
1057            token_name,
1058            token_age_days,
1059            rotation_url,
1060            chrono::Utc::now().year()
1061        );
1062
1063        let text_body = format!(
1064            r#"
1065API Token Rotation Reminder
1066
1067Hi {},
1068
1069Security Best Practice: Your API token "{}" is {} days old and should be rotated for security.
1070
1071Regularly rotating API tokens is a security best practice that helps protect your account and data. We recommend rotating tokens every 90 days.
1072
1073Rotate your token: {}
1074
1075Or visit your API tokens page to rotate this token manually.
1076
1077If you no longer need this token, you can delete it from your settings.
1078
1079Best regards,
1080The MockForge Team
1081
1082© {} MockForge. All rights reserved.
1083Terms: https://mockforge.dev/terms
1084Privacy: https://mockforge.dev/privacy
1085"#,
1086            username,
1087            token_name,
1088            token_age_days,
1089            rotation_url,
1090            chrono::Utc::now().year()
1091        );
1092
1093        EmailMessage {
1094            to: email.to_string(),
1095            subject: format!("Action Required: Rotate Your API Token '{}'", token_name),
1096            html_body,
1097            text_body,
1098        }
1099    }
1100
1101    /// Generate password reset email
1102    pub fn generate_password_reset_email(
1103        username: &str,
1104        email: &str,
1105        reset_token: &str,
1106    ) -> EmailMessage {
1107        let reset_url = format!(
1108            "{}/reset-password?token={}",
1109            std::env::var("APP_BASE_URL")
1110                .unwrap_or_else(|_| "https://app.mockforge.dev".to_string()),
1111            reset_token
1112        );
1113
1114        let html_body = format!(
1115            r#"
1116<!DOCTYPE html>
1117<html>
1118<head>
1119    <meta charset="utf-8">
1120    <style>
1121        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; }}
1122        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
1123        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
1124        .button {{ display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
1125        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
1126        .warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
1127        .code {{ background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; word-break: break-all; }}
1128    </style>
1129</head>
1130<body>
1131    <div class="header">
1132        <h1>Reset Your Password</h1>
1133    </div>
1134    <div class="content">
1135        <p>Hi {},</p>
1136        <p>We received a request to reset your password for your MockForge Cloud account.</p>
1137        <p style="text-align: center;">
1138            <a href="{}" class="button">Reset Password</a>
1139        </p>
1140        <p>Or copy and paste this link into your browser:</p>
1141        <div class="code">{}</div>
1142        <div class="warning">
1143            <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.
1144        </div>
1145        <p>If you continue to have problems, please contact our support team.</p>
1146        <p>Best regards,<br>The MockForge Team</p>
1147    </div>
1148    <div class="footer">
1149        <p>© {} MockForge. All rights reserved.</p>
1150        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
1151    </div>
1152</body>
1153</html>
1154"#,
1155            username,
1156            reset_url,
1157            reset_url,
1158            chrono::Utc::now().year()
1159        );
1160
1161        let text_body = format!(
1162            r#"
1163Reset Your Password
1164
1165Hi {},
1166
1167We received a request to reset your password for your MockForge Cloud account.
1168
1169Click this link to reset your password:
1170{}
1171
1172This password reset link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
1173
1174If you continue to have problems, please contact our support team.
1175
1176Best regards,
1177The MockForge Team
1178
1179© {} MockForge. All rights reserved.
1180Terms: https://mockforge.dev/terms
1181Privacy: https://mockforge.dev/privacy
1182"#,
1183            username,
1184            reset_url,
1185            chrono::Utc::now().year()
1186        );
1187
1188        EmailMessage {
1189            to: email.to_string(),
1190            subject: "Reset Your Password - MockForge Cloud".to_string(),
1191            html_body,
1192            text_body,
1193        }
1194    }
1195
1196    /// Generate deployment status notification email
1197    pub fn generate_deployment_status_email(
1198        username: &str,
1199        email: &str,
1200        deployment_name: &str,
1201        status: &str,
1202        deployment_url: Option<&str>,
1203        error_message: Option<&str>,
1204    ) -> EmailMessage {
1205        let (header_color, header_text, status_icon) = match status {
1206            "active" => ("#28a745", "Deployment Successful", "✅"),
1207            "failed" => ("#dc3545", "Deployment Failed", "❌"),
1208            "deploying" => ("#007bff", "Deployment In Progress", "⏳"),
1209            _ => ("#6c757d", "Deployment Status Update", "ℹ️"),
1210        };
1211
1212        let deployment_link = deployment_url
1213            .map(|url| {
1214                format!(
1215                    r#"<p style="text-align: center;">
1216            <a href="{}" class="button">View Deployment</a>
1217        </p>"#,
1218                    url
1219                )
1220            })
1221            .unwrap_or_default();
1222
1223        let error_section = error_message
1224            .map(|msg| {
1225                format!(
1226                    r#"<div class="warning">
1227            <strong>Error Details:</strong><br>
1228            <pre style="white-space: pre-wrap; font-size: 12px;">{}</pre>
1229        </div>"#,
1230                    msg
1231                )
1232            })
1233            .unwrap_or_default();
1234
1235        let html_body = format!(
1236            r#"
1237<!DOCTYPE html>
1238<html>
1239<head>
1240    <meta charset="utf-8">
1241    <style>
1242        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; }}
1243        .header {{ background: linear-gradient(135deg, {} 0%, {} 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; }}
1244        .content {{ background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
1245        .button {{ display: inline-block; padding: 12px 24px; background: {}; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
1246        .footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; }}
1247        .warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
1248    </style>
1249</head>
1250<body>
1251    <div class="header">
1252        <h1>{} {}</h1>
1253    </div>
1254    <div class="content">
1255        <p>Hi {},</p>
1256        <p>Your hosted mock deployment "<strong>{}</strong>" status has been updated to <strong>{}</strong>.</p>
1257        {}
1258        {}
1259        <p>You can view and manage your deployments in the MockForge Cloud dashboard.</p>
1260        <p>Best regards,<br>The MockForge Team</p>
1261    </div>
1262    <div class="footer">
1263        <p>© {} MockForge. All rights reserved.</p>
1264        <p><a href="https://mockforge.dev/terms">Terms of Service</a> | <a href="https://mockforge.dev/privacy">Privacy Policy</a></p>
1265    </div>
1266</body>
1267</html>
1268"#,
1269            header_color,
1270            header_color,
1271            header_color,
1272            status_icon,
1273            header_text,
1274            username,
1275            deployment_name,
1276            status,
1277            deployment_link,
1278            error_section,
1279            chrono::Utc::now().year()
1280        );
1281
1282        let text_body = format!(
1283            r#"
1284Deployment Status Update
1285
1286Hi {},
1287
1288Your hosted mock deployment "{}" status has been updated to {}.
1289
1290{}
1291
1292{}
1293
1294You can view and manage your deployments in the MockForge Cloud dashboard.
1295
1296Best regards,
1297The MockForge Team
1298
1299© {} MockForge. All rights reserved.
1300Terms: https://mockforge.dev/terms
1301Privacy: https://mockforge.dev/privacy
1302"#,
1303            username,
1304            deployment_name,
1305            status,
1306            deployment_url
1307                .map(|url| format!("View deployment: {}", url))
1308                .unwrap_or_default(),
1309            error_message.map(|msg| format!("Error: {}", msg)).unwrap_or_default(),
1310            chrono::Utc::now().year()
1311        );
1312
1313        EmailMessage {
1314            to: email.to_string(),
1315            subject: format!("{} - Deployment '{}' is {}", status_icon, deployment_name, status),
1316            html_body,
1317            text_body,
1318        }
1319    }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    #[test]
1327    fn test_email_provider_from_str() {
1328        assert!(matches!(EmailProvider::from_str("postmark"), EmailProvider::Postmark));
1329        assert!(matches!(EmailProvider::from_str("POSTMARK"), EmailProvider::Postmark));
1330        assert!(matches!(EmailProvider::from_str("brevo"), EmailProvider::Brevo));
1331        assert!(matches!(EmailProvider::from_str("sendinblue"), EmailProvider::Brevo));
1332        assert!(matches!(EmailProvider::from_str("smtp"), EmailProvider::Smtp));
1333        assert!(matches!(EmailProvider::from_str("disabled"), EmailProvider::Disabled));
1334        assert!(matches!(EmailProvider::from_str("unknown"), EmailProvider::Disabled));
1335    }
1336
1337    #[test]
1338    fn test_email_service_new() {
1339        let config = EmailConfig {
1340            provider: EmailProvider::Disabled,
1341            from_email: "test@example.com".to_string(),
1342            from_name: "Test".to_string(),
1343            api_key: None,
1344            smtp_host: None,
1345            smtp_port: None,
1346            smtp_username: None,
1347            smtp_password: None,
1348        };
1349
1350        let service = EmailService::new(config.clone()).expect("Failed to create email service");
1351        assert!(matches!(service.config.provider, EmailProvider::Disabled));
1352        assert_eq!(service.config.from_email, "test@example.com");
1353        assert_eq!(service.config.from_name, "Test");
1354    }
1355
1356    #[tokio::test]
1357    async fn test_send_email_disabled_provider() {
1358        let config = EmailConfig {
1359            provider: EmailProvider::Disabled,
1360            from_email: "test@example.com".to_string(),
1361            from_name: "Test".to_string(),
1362            api_key: None,
1363            smtp_host: None,
1364            smtp_port: None,
1365            smtp_username: None,
1366            smtp_password: None,
1367        };
1368
1369        let service = EmailService::new(config).expect("Failed to create email service");
1370        let message = EmailMessage {
1371            to: "recipient@example.com".to_string(),
1372            subject: "Test".to_string(),
1373            html_body: "<p>Test</p>".to_string(),
1374            text_body: "Test".to_string(),
1375        };
1376
1377        // Should succeed without error when disabled
1378        let result = service.send(message).await;
1379        assert!(result.is_ok());
1380    }
1381
1382    #[tokio::test]
1383    async fn test_send_email_smtp_missing_host() {
1384        let config = EmailConfig {
1385            provider: EmailProvider::Smtp,
1386            from_email: "test@example.com".to_string(),
1387            from_name: "Test".to_string(),
1388            api_key: None,
1389            smtp_host: None, // Missing SMTP host
1390            smtp_port: Some(587),
1391            smtp_username: Some("user".to_string()),
1392            smtp_password: Some("pass".to_string()),
1393        };
1394
1395        let service = EmailService::new(config).expect("Failed to create email service");
1396        let message = EmailMessage {
1397            to: "recipient@example.com".to_string(),
1398            subject: "Test".to_string(),
1399            html_body: "<p>Test</p>".to_string(),
1400            text_body: "Test".to_string(),
1401        };
1402
1403        // Should fail due to missing SMTP host
1404        let result = service.send(message).await;
1405        assert!(result.is_err());
1406        assert!(result.unwrap_err().to_string().contains("SMTP requires SMTP_HOST"));
1407    }
1408
1409    #[tokio::test]
1410    async fn test_send_email_smtp_connection_error() {
1411        // Test that SMTP properly attempts connection (will fail with no server)
1412        let config = EmailConfig {
1413            provider: EmailProvider::Smtp,
1414            from_email: "test@example.com".to_string(),
1415            from_name: "Test".to_string(),
1416            api_key: None,
1417            smtp_host: Some("localhost".to_string()),
1418            smtp_port: Some(12345), // Non-existent port
1419            smtp_username: None,
1420            smtp_password: None,
1421        };
1422
1423        let service = EmailService::new(config).expect("Failed to create email service");
1424        let message = EmailMessage {
1425            to: "recipient@example.com".to_string(),
1426            subject: "Test".to_string(),
1427            html_body: "<p>Test</p>".to_string(),
1428            text_body: "Test".to_string(),
1429        };
1430
1431        // Should fail due to connection error (no SMTP server running)
1432        let result = service.send(message).await;
1433        assert!(result.is_err());
1434        // The error should indicate a connection/send failure
1435        let err = result.unwrap_err().to_string();
1436        assert!(
1437            err.contains("Failed to send email via SMTP") || err.contains("connection"),
1438            "Expected SMTP connection error, got: {}",
1439            err
1440        );
1441    }
1442
1443    #[tokio::test]
1444    async fn test_send_email_postmark_missing_api_key() {
1445        let config = EmailConfig {
1446            provider: EmailProvider::Postmark,
1447            from_email: "test@example.com".to_string(),
1448            from_name: "Test".to_string(),
1449            api_key: None,
1450            smtp_host: None,
1451            smtp_port: None,
1452            smtp_username: None,
1453            smtp_password: None,
1454        };
1455
1456        let service = EmailService::new(config).expect("Failed to create email service");
1457        let message = EmailMessage {
1458            to: "recipient@example.com".to_string(),
1459            subject: "Test".to_string(),
1460            html_body: "<p>Test</p>".to_string(),
1461            text_body: "Test".to_string(),
1462        };
1463
1464        // Should fail due to missing API key
1465        let result = service.send(message).await;
1466        assert!(result.is_err());
1467        assert!(result.unwrap_err().to_string().contains("requires EMAIL_API_KEY"));
1468    }
1469
1470    #[tokio::test]
1471    async fn test_send_email_brevo_missing_api_key() {
1472        let config = EmailConfig {
1473            provider: EmailProvider::Brevo,
1474            from_email: "test@example.com".to_string(),
1475            from_name: "Test".to_string(),
1476            api_key: None,
1477            smtp_host: None,
1478            smtp_port: None,
1479            smtp_username: None,
1480            smtp_password: None,
1481        };
1482
1483        let service = EmailService::new(config).expect("Failed to create email service");
1484        let message = EmailMessage {
1485            to: "recipient@example.com".to_string(),
1486            subject: "Test".to_string(),
1487            html_body: "<p>Test</p>".to_string(),
1488            text_body: "Test".to_string(),
1489        };
1490
1491        // Should fail due to missing API key
1492        let result = service.send(message).await;
1493        assert!(result.is_err());
1494        assert!(result.unwrap_err().to_string().contains("requires EMAIL_API_KEY"));
1495    }
1496
1497    #[test]
1498    fn test_generate_welcome_email() {
1499        let email = EmailService::generate_welcome_email("testuser", "test@example.com");
1500
1501        assert_eq!(email.to, "test@example.com");
1502        assert!(email.subject.contains("Welcome to MockForge Cloud"));
1503        assert!(email.html_body.contains("testuser"));
1504        assert!(email.html_body.contains("Welcome to MockForge Cloud"));
1505        assert!(email.html_body.contains("https://app.mockforge.dev"));
1506        assert!(email.text_body.contains("testuser"));
1507        assert!(email.text_body.contains("Welcome to MockForge Cloud"));
1508
1509        // Check for current year in both HTML and text
1510        let current_year = chrono::Utc::now().year();
1511        assert!(email.html_body.contains(&current_year.to_string()));
1512        assert!(email.text_body.contains(&current_year.to_string()));
1513    }
1514
1515    #[test]
1516    fn test_generate_subscription_confirmation_with_amount() {
1517        let period_end = chrono::Utc::now() + chrono::Duration::days(30);
1518        let email = EmailService::generate_subscription_confirmation(
1519            "testuser",
1520            "test@example.com",
1521            "Pro",
1522            Some(29.99),
1523            Some(period_end),
1524        );
1525
1526        assert_eq!(email.to, "test@example.com");
1527        assert!(email.subject.contains("Subscription Confirmed"));
1528        assert!(email.subject.contains("Pro"));
1529        assert!(email.html_body.contains("testuser"));
1530        assert!(email.html_body.contains("Pro"));
1531        assert!(email.html_body.contains("$29.99"));
1532        assert!(email.html_body.contains("renews on"));
1533        assert!(email.text_body.contains("testuser"));
1534        assert!(email.text_body.contains("Pro"));
1535        assert!(email.text_body.contains("$29.99"));
1536    }
1537
1538    #[test]
1539    fn test_generate_subscription_confirmation_without_amount() {
1540        let email = EmailService::generate_subscription_confirmation(
1541            "testuser",
1542            "test@example.com",
1543            "Free",
1544            None,
1545            None,
1546        );
1547
1548        assert_eq!(email.to, "test@example.com");
1549        assert!(email.subject.contains("Subscription Confirmed"));
1550        assert!(email.html_body.contains("testuser"));
1551        assert!(email.html_body.contains("Free"));
1552        assert!(email.html_body.contains("your plan"));
1553        assert!(email.html_body.contains("subscription is active"));
1554    }
1555
1556    #[test]
1557    fn test_generate_payment_failed_with_retry() {
1558        let retry_date = chrono::Utc::now() + chrono::Duration::days(3);
1559        let email = EmailService::generate_payment_failed(
1560            "testuser",
1561            "test@example.com",
1562            "Pro",
1563            29.99,
1564            Some(retry_date),
1565        );
1566
1567        assert_eq!(email.to, "test@example.com");
1568        assert_eq!(email.subject, "Payment Failed - Action Required");
1569        assert!(email.html_body.contains("testuser"));
1570        assert!(email.html_body.contains("Pro"));
1571        assert!(email.html_body.contains("$29.99"));
1572        assert!(email.html_body.contains("automatically retry"));
1573        assert!(email.text_body.contains("testuser"));
1574        assert!(email.text_body.contains("$29.99"));
1575    }
1576
1577    #[test]
1578    fn test_generate_payment_failed_without_retry() {
1579        let email = EmailService::generate_payment_failed(
1580            "testuser",
1581            "test@example.com",
1582            "Pro",
1583            29.99,
1584            None,
1585        );
1586
1587        assert_eq!(email.to, "test@example.com");
1588        assert!(email.html_body.contains("testuser"));
1589        assert!(email.html_body.contains("$29.99"));
1590        assert!(email.html_body.contains("update your payment method"));
1591    }
1592
1593    #[test]
1594    fn test_generate_subscription_canceled_with_access() {
1595        let access_until = chrono::Utc::now() + chrono::Duration::days(15);
1596        let email = EmailService::generate_subscription_canceled(
1597            "testuser",
1598            "test@example.com",
1599            "Pro",
1600            Some(access_until),
1601        );
1602
1603        assert_eq!(email.to, "test@example.com");
1604        assert_eq!(email.subject, "Subscription Canceled");
1605        assert!(email.html_body.contains("testuser"));
1606        assert!(email.html_body.contains("Pro"));
1607        assert!(email.html_body.contains("continue to have access"));
1608        assert!(email.text_body.contains("testuser"));
1609        assert!(email.text_body.contains("Pro"));
1610    }
1611
1612    #[test]
1613    fn test_generate_subscription_canceled_without_access() {
1614        let email = EmailService::generate_subscription_canceled(
1615            "testuser",
1616            "test@example.com",
1617            "Pro",
1618            None,
1619        );
1620
1621        assert_eq!(email.to, "test@example.com");
1622        assert!(email.html_body.contains("testuser"));
1623        assert!(email.html_body.contains("Pro"));
1624        assert!(email.html_body.contains("canceled"));
1625    }
1626
1627    #[test]
1628    fn test_generate_support_confirmation() {
1629        let email = EmailService::generate_support_confirmation(
1630            "testuser",
1631            "test@example.com",
1632            "TICKET-12345",
1633            "Help with API integration",
1634        );
1635
1636        assert_eq!(email.to, "test@example.com");
1637        assert!(email.subject.contains("Support Request Received"));
1638        assert!(email.subject.contains("TICKET-12345"));
1639        assert!(email.html_body.contains("testuser"));
1640        assert!(email.html_body.contains("TICKET-12345"));
1641        assert!(email.html_body.contains("Help with API integration"));
1642        assert!(email.text_body.contains("testuser"));
1643        assert!(email.text_body.contains("TICKET-12345"));
1644        assert!(email.text_body.contains("Help with API integration"));
1645    }
1646
1647    #[test]
1648    fn test_generate_verification_email() {
1649        std::env::set_var("APP_BASE_URL", "https://test.mockforge.dev");
1650
1651        let email = EmailService::generate_verification_email(
1652            "testuser",
1653            "test@example.com",
1654            "abc123token",
1655        );
1656
1657        assert_eq!(email.to, "test@example.com");
1658        assert!(email.subject.contains("Verify Your Email Address"));
1659        assert!(email.html_body.contains("testuser"));
1660        assert!(email
1661            .html_body
1662            .contains("https://test.mockforge.dev/verify-email?token=abc123token"));
1663        assert!(email.html_body.contains("24 hours"));
1664        assert!(email.text_body.contains("testuser"));
1665        assert!(email
1666            .text_body
1667            .contains("https://test.mockforge.dev/verify-email?token=abc123token"));
1668
1669        std::env::remove_var("APP_BASE_URL");
1670    }
1671
1672    #[test]
1673    fn test_generate_verification_email_default_url() {
1674        std::env::remove_var("APP_BASE_URL");
1675
1676        let email = EmailService::generate_verification_email(
1677            "testuser",
1678            "test@example.com",
1679            "abc123token",
1680        );
1681
1682        assert!(email
1683            .html_body
1684            .contains("https://app.mockforge.dev/verify-email?token=abc123token"));
1685    }
1686
1687    #[test]
1688    fn test_generate_token_rotation_reminder() {
1689        let email = EmailService::generate_token_rotation_reminder(
1690            "testuser",
1691            "test@example.com",
1692            "Production API Key",
1693            120,
1694            "https://app.mockforge.dev/tokens/rotate/123",
1695        );
1696
1697        assert_eq!(email.to, "test@example.com");
1698        assert!(email.subject.contains("Action Required"));
1699        assert!(email.subject.contains("Production API Key"));
1700        assert!(email.html_body.contains("testuser"));
1701        assert!(email.html_body.contains("Production API Key"));
1702        assert!(email.html_body.contains("120 days"));
1703        assert!(email.html_body.contains("https://app.mockforge.dev/tokens/rotate/123"));
1704        assert!(email.text_body.contains("120 days"));
1705        assert!(email.text_body.contains("Production API Key"));
1706    }
1707
1708    #[test]
1709    fn test_generate_password_reset_email() {
1710        std::env::set_var("APP_BASE_URL", "https://test.mockforge.dev");
1711
1712        let email = EmailService::generate_password_reset_email(
1713            "testuser",
1714            "test@example.com",
1715            "reset123token",
1716        );
1717
1718        assert_eq!(email.to, "test@example.com");
1719        assert!(email.subject.contains("Reset Your Password"));
1720        assert!(email.html_body.contains("testuser"));
1721        assert!(email
1722            .html_body
1723            .contains("https://test.mockforge.dev/reset-password?token=reset123token"));
1724        assert!(email.html_body.contains("1 hour"));
1725        assert!(email.text_body.contains("testuser"));
1726        assert!(email
1727            .text_body
1728            .contains("https://test.mockforge.dev/reset-password?token=reset123token"));
1729
1730        std::env::remove_var("APP_BASE_URL");
1731    }
1732
1733    #[test]
1734    fn test_generate_deployment_status_email_active() {
1735        let email = EmailService::generate_deployment_status_email(
1736            "testuser",
1737            "test@example.com",
1738            "my-api-mock",
1739            "active",
1740            Some("https://my-api-mock.mockforge.app"),
1741            None,
1742        );
1743
1744        assert_eq!(email.to, "test@example.com");
1745        assert!(email.subject.contains("✅"));
1746        assert!(email.subject.contains("my-api-mock"));
1747        assert!(email.subject.contains("active"));
1748        assert!(email.html_body.contains("testuser"));
1749        assert!(email.html_body.contains("my-api-mock"));
1750        assert!(email.html_body.contains("active"));
1751        assert!(email.html_body.contains("https://my-api-mock.mockforge.app"));
1752        assert!(!email.html_body.contains("Error Details"));
1753    }
1754
1755    #[test]
1756    fn test_generate_deployment_status_email_failed() {
1757        let email = EmailService::generate_deployment_status_email(
1758            "testuser",
1759            "test@example.com",
1760            "my-api-mock",
1761            "failed",
1762            None,
1763            Some("Build failed: missing dependency"),
1764        );
1765
1766        assert_eq!(email.to, "test@example.com");
1767        assert!(email.subject.contains("❌"));
1768        assert!(email.subject.contains("my-api-mock"));
1769        assert!(email.subject.contains("failed"));
1770        assert!(email.html_body.contains("testuser"));
1771        assert!(email.html_body.contains("my-api-mock"));
1772        assert!(email.html_body.contains("failed"));
1773        assert!(email.html_body.contains("Error Details"));
1774        assert!(email.html_body.contains("Build failed: missing dependency"));
1775        assert!(email.text_body.contains("Build failed: missing dependency"));
1776    }
1777
1778    #[test]
1779    fn test_generate_deployment_status_email_deploying() {
1780        let email = EmailService::generate_deployment_status_email(
1781            "testuser",
1782            "test@example.com",
1783            "my-api-mock",
1784            "deploying",
1785            Some("https://my-api-mock.mockforge.app"),
1786            None,
1787        );
1788
1789        assert!(email.subject.contains("⏳"));
1790        assert!(email.subject.contains("deploying"));
1791        assert!(email.html_body.contains("my-api-mock"));
1792        assert!(email.html_body.contains("deploying"));
1793    }
1794
1795    #[test]
1796    fn test_email_message_clone() {
1797        let message = EmailMessage {
1798            to: "test@example.com".to_string(),
1799            subject: "Test".to_string(),
1800            html_body: "<p>Test</p>".to_string(),
1801            text_body: "Test".to_string(),
1802        };
1803
1804        let cloned = message.clone();
1805        assert_eq!(message.to, cloned.to);
1806        assert_eq!(message.subject, cloned.subject);
1807        assert_eq!(message.html_body, cloned.html_body);
1808        assert_eq!(message.text_body, cloned.text_body);
1809    }
1810
1811    #[test]
1812    fn test_email_config_clone() {
1813        let config = EmailConfig {
1814            provider: EmailProvider::Postmark,
1815            from_email: "test@example.com".to_string(),
1816            from_name: "Test".to_string(),
1817            api_key: Some("key123".to_string()),
1818            smtp_host: Some("localhost".to_string()),
1819            smtp_port: Some(587),
1820            smtp_username: Some("user".to_string()),
1821            smtp_password: Some("pass".to_string()),
1822        };
1823
1824        let cloned = config.clone();
1825        assert_eq!(config.from_email, cloned.from_email);
1826        assert_eq!(config.from_name, cloned.from_name);
1827        assert_eq!(config.api_key, cloned.api_key);
1828        assert_eq!(config.smtp_host, cloned.smtp_host);
1829    }
1830
1831    #[test]
1832    fn test_email_templates_contain_required_links() {
1833        // Verify all email templates contain necessary links
1834        let welcome = EmailService::generate_welcome_email("user", "test@example.com");
1835        assert!(welcome.html_body.contains("mockforge.dev/terms"));
1836        assert!(welcome.html_body.contains("mockforge.dev/privacy"));
1837        assert!(welcome.text_body.contains("mockforge.dev/terms"));
1838        assert!(welcome.text_body.contains("mockforge.dev/privacy"));
1839
1840        let subscription = EmailService::generate_subscription_confirmation(
1841            "user",
1842            "test@example.com",
1843            "Pro",
1844            Some(29.99),
1845            None,
1846        );
1847        assert!(subscription.html_body.contains("app.mockforge.dev/billing"));
1848
1849        let payment_failed =
1850            EmailService::generate_payment_failed("user", "test@example.com", "Pro", 29.99, None);
1851        assert!(payment_failed.html_body.contains("app.mockforge.dev/billing"));
1852
1853        let canceled =
1854            EmailService::generate_subscription_canceled("user", "test@example.com", "Pro", None);
1855        assert!(canceled.html_body.contains("app.mockforge.dev/billing"));
1856    }
1857
1858    #[test]
1859    fn test_email_subject_lines() {
1860        // Verify subject lines are appropriate
1861        let welcome = EmailService::generate_welcome_email("user", "test@example.com");
1862        assert!(welcome.subject.len() < 100); // Reasonable length
1863        assert!(welcome.subject.contains("MockForge"));
1864
1865        let verification =
1866            EmailService::generate_verification_email("user", "test@example.com", "token");
1867        assert!(verification.subject.contains("Verify"));
1868
1869        let reset =
1870            EmailService::generate_password_reset_email("user", "test@example.com", "token");
1871        assert!(reset.subject.contains("Reset"));
1872
1873        let support =
1874            EmailService::generate_support_confirmation("user", "test@example.com", "123", "Help");
1875        assert!(support.subject.contains("123"));
1876    }
1877}