1use 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#[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>, pub smtp_host: Option<String>, pub smtp_port: Option<u16>,
27 pub smtp_username: Option<String>,
28 pub smtp_password: Option<String>,
29}
30
31#[derive(Debug, Clone)]
33pub enum EmailProvider {
34 Postmark,
35 Brevo,
36 Smtp,
37 Disabled, }
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#[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
60pub struct EmailService {
62 config: EmailConfig,
63 client: reqwest::Client,
64}
65
66impl EmailService {
67 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 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 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 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 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 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 let from_mailbox: Mailbox =
217 format!("{} <{}>", self.config.from_name, self.config.from_email)
218 .parse()
219 .context("Invalid from email address")?;
220
221 let to_mailbox: Mailbox = message.to.parse().context("Invalid recipient email address")?;
223
224 let log_to = message.to.clone();
226 let log_subject = message.subject.clone();
227
228 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 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 let creds = Credentials::new(username.clone(), password.clone());
254
255 if smtp_port == 465 {
256 AsyncSmtpTransport::<Tokio1Executor>::relay(smtp_host)
258 .context("Failed to create SMTP relay")?
259 .credentials(creds)
260 .port(smtp_port)
261 .build()
262 } else {
263 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 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(smtp_host)
273 .port(smtp_port)
274 .build()
275 };
276
277 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 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 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 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 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 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 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 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 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 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 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 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 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, 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 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 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), 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 let result = service.send(message).await;
1433 assert!(result.is_err());
1434 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 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 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 let current_year = chrono::Utc::now().year();
1511 assert!(email.html_body.contains(¤t_year.to_string()));
1512 assert!(email.text_body.contains(¤t_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 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 let welcome = EmailService::generate_welcome_email("user", "test@example.com");
1862 assert!(welcome.subject.len() < 100); 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}