1mod loader;
15mod renderer;
16
17pub use loader::TemplateLoader;
18pub use renderer::TemplateRenderer;
19
20use serde::{Deserialize, Serialize};
21
22pub trait EmailTemplate: Serialize + Send + Sync {
55 fn template_name(&self) -> &'static str;
60
61 fn subject(&self) -> String;
63
64 fn recipient(&self) -> String;
66
67 fn priority(&self) -> EmailPriority {
69 EmailPriority::Normal
70 }
71
72 fn cc(&self) -> Option<Vec<String>> {
74 None
75 }
76
77 fn bcc(&self) -> Option<Vec<String>> {
79 None
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum EmailPriority {
87 Low = 0,
89 #[default]
91 Normal = 1,
92 High = 2,
94 Critical = 3,
96}
97
98#[derive(Debug, Clone, Serialize)]
104pub struct WelcomeEmail {
105 pub to: String,
107 pub name: String,
109 pub verify_url: String,
111}
112
113impl EmailTemplate for WelcomeEmail {
114 fn template_name(&self) -> &'static str {
115 "welcome"
116 }
117
118 fn subject(&self) -> String {
119 format!("Welcome to Gatekpr, {}!", self.name)
120 }
121
122 fn recipient(&self) -> String {
123 self.to.clone()
124 }
125
126 fn priority(&self) -> EmailPriority {
127 EmailPriority::High
128 }
129}
130
131#[derive(Debug, Clone, Serialize)]
133pub struct VerifyEmailEmail {
134 pub to: String,
136 pub name: String,
138 pub verify_url: String,
140 pub expires_in_hours: u32,
142}
143
144impl EmailTemplate for VerifyEmailEmail {
145 fn template_name(&self) -> &'static str {
146 "verify-email"
147 }
148
149 fn subject(&self) -> String {
150 "Verify your email address".to_string()
151 }
152
153 fn recipient(&self) -> String {
154 self.to.clone()
155 }
156
157 fn priority(&self) -> EmailPriority {
158 EmailPriority::Critical
159 }
160}
161
162#[derive(Debug, Clone, Serialize)]
164pub struct PasswordResetEmail {
165 pub to: String,
167 pub name: String,
169 pub reset_url: String,
171 pub expires_in_hours: u32,
173}
174
175impl EmailTemplate for PasswordResetEmail {
176 fn template_name(&self) -> &'static str {
177 "password-reset"
178 }
179
180 fn subject(&self) -> String {
181 "Reset your password".to_string()
182 }
183
184 fn recipient(&self) -> String {
185 self.to.clone()
186 }
187
188 fn priority(&self) -> EmailPriority {
189 EmailPriority::Critical
190 }
191}
192
193#[derive(Debug, Clone, Serialize)]
195pub struct PasswordChangedEmail {
196 pub to: String,
198 pub name: String,
200 pub changed_at: String,
202 pub ip_address: String,
204}
205
206impl EmailTemplate for PasswordChangedEmail {
207 fn template_name(&self) -> &'static str {
208 "password-changed"
209 }
210
211 fn subject(&self) -> String {
212 "Your password has been changed".to_string()
213 }
214
215 fn recipient(&self) -> String {
216 self.to.clone()
217 }
218
219 fn priority(&self) -> EmailPriority {
220 EmailPriority::High
221 }
222}
223
224#[derive(Debug, Clone, Serialize)]
226pub struct ApiKeyGeneratedEmail {
227 pub to: String,
229 pub name: String,
231 pub key_name: String,
233 pub created_at: String,
235}
236
237impl EmailTemplate for ApiKeyGeneratedEmail {
238 fn template_name(&self) -> &'static str {
239 "api-key-generated"
240 }
241
242 fn subject(&self) -> String {
243 "New API key generated".to_string()
244 }
245
246 fn recipient(&self) -> String {
247 self.to.clone()
248 }
249
250 fn priority(&self) -> EmailPriority {
251 EmailPriority::High
252 }
253}
254
255#[derive(Debug, Clone, Serialize)]
261pub struct DeviceAuthEmail {
262 pub to: String,
264 pub name: String,
266 pub device_code: String,
268 pub confirm_url: String,
270 pub expires_in_minutes: u32,
272}
273
274impl EmailTemplate for DeviceAuthEmail {
275 fn template_name(&self) -> &'static str {
276 "device-auth"
277 }
278
279 fn subject(&self) -> String {
280 format!("Confirm CLI Login (Code: {})", self.device_code)
281 }
282
283 fn recipient(&self) -> String {
284 self.to.clone()
285 }
286
287 fn priority(&self) -> EmailPriority {
288 EmailPriority::Critical
289 }
290}
291
292#[derive(Debug, Clone, Serialize)]
298pub struct NewLoginEmail {
299 pub to: String,
301 pub name: String,
303 pub device: String,
305 pub location: String,
307 pub time: String,
309 pub ip_address: String,
311}
312
313impl EmailTemplate for NewLoginEmail {
314 fn template_name(&self) -> &'static str {
315 "new-login"
316 }
317
318 fn subject(&self) -> String {
319 "New login to your account".to_string()
320 }
321
322 fn recipient(&self) -> String {
323 self.to.clone()
324 }
325
326 fn priority(&self) -> EmailPriority {
327 EmailPriority::High
328 }
329}
330
331#[derive(Debug, Clone, Serialize)]
337pub struct ValidationCompleteEmail {
338 pub to: String,
340 pub name: String,
342 pub app_name: String,
344 pub score: u32,
346 pub issues_count: u32,
348 pub critical_count: u32,
350 pub report_url: String,
352 pub status: String,
354}
355
356impl EmailTemplate for ValidationCompleteEmail {
357 fn template_name(&self) -> &'static str {
358 "validation-complete"
359 }
360
361 fn subject(&self) -> String {
362 format!("Validation complete for {}", self.app_name)
363 }
364
365 fn recipient(&self) -> String {
366 self.to.clone()
367 }
368
369 fn priority(&self) -> EmailPriority {
370 EmailPriority::High
371 }
372}
373
374#[derive(Debug, Clone, Serialize)]
376pub struct FailedLoginAlertEmail {
377 pub to: String,
379 pub name: String,
381 pub attempts: u32,
383 pub time_range: String,
385 pub ip_addresses: Vec<String>,
387 pub last_attempt: String,
389}
390
391impl EmailTemplate for FailedLoginAlertEmail {
392 fn template_name(&self) -> &'static str {
393 "failed-login-alert"
394 }
395
396 fn subject(&self) -> String {
397 format!("Security Alert: {} failed login attempts", self.attempts)
398 }
399
400 fn recipient(&self) -> String {
401 self.to.clone()
402 }
403
404 fn priority(&self) -> EmailPriority {
405 EmailPriority::Critical
406 }
407}
408
409#[derive(Debug, Clone, Serialize)]
411pub struct ApiKeyUsageAlertEmail {
412 pub to: String,
414 pub name: String,
416 pub key_name: String,
418 pub ip_address: String,
420 pub first_use: bool,
422 pub used_at: String,
424 pub user_agent: String,
426}
427
428impl EmailTemplate for ApiKeyUsageAlertEmail {
429 fn template_name(&self) -> &'static str {
430 "api-key-usage-alert"
431 }
432
433 fn subject(&self) -> String {
434 if self.first_use {
435 format!(
436 "Your API key '{}' was used for the first time",
437 self.key_name
438 )
439 } else {
440 format!(
441 "Your API key '{}' was used from a new location",
442 self.key_name
443 )
444 }
445 }
446
447 fn recipient(&self) -> String {
448 self.to.clone()
449 }
450
451 fn priority(&self) -> EmailPriority {
452 EmailPriority::High
453 }
454}
455
456#[derive(Debug, Clone, Serialize)]
458pub struct CriticalIssuesEmail {
459 pub to: String,
461 pub name: String,
463 pub app_name: String,
465 pub issues: Vec<CriticalIssue>,
467 pub report_url: String,
469}
470
471#[derive(Debug, Clone, Serialize)]
473pub struct CriticalIssue {
474 pub rule_id: String,
476 pub title: String,
478 pub description: String,
480}
481
482impl EmailTemplate for CriticalIssuesEmail {
483 fn template_name(&self) -> &'static str {
484 "critical-issues"
485 }
486
487 fn subject(&self) -> String {
488 format!(
489 "Action Required: {} critical issues found in {}",
490 self.issues.len(),
491 self.app_name
492 )
493 }
494
495 fn recipient(&self) -> String {
496 self.to.clone()
497 }
498
499 fn priority(&self) -> EmailPriority {
500 EmailPriority::Critical
501 }
502}
503
504#[derive(Debug, Clone, Serialize)]
506pub struct RateLimitWarningEmail {
507 pub to: String,
509 pub name: String,
511 pub current_usage: u32,
513 pub limit: u32,
515 pub percentage: u32,
517 pub reset_date: String,
519 pub plan: String,
521 pub upgrade_url: Option<String>,
523}
524
525impl EmailTemplate for RateLimitWarningEmail {
526 fn template_name(&self) -> &'static str {
527 "rate-limit-warning"
528 }
529
530 fn subject(&self) -> String {
531 format!(
532 "Rate Limit Warning: {}% of your {} plan limit used",
533 self.percentage, self.plan
534 )
535 }
536
537 fn recipient(&self) -> String {
538 self.to.clone()
539 }
540
541 fn priority(&self) -> EmailPriority {
542 EmailPriority::High
543 }
544}
545
546#[derive(Debug, Clone, Serialize)]
552pub struct SubscriptionActivatedEmail {
553 pub to: String,
555 pub name: String,
557 pub plan_name: String,
559 pub interval: String,
561 pub price: String,
563 pub next_billing_date: String,
565 pub billing_portal_url: String,
567}
568
569impl EmailTemplate for SubscriptionActivatedEmail {
570 fn template_name(&self) -> &'static str {
571 "subscription-activated"
572 }
573
574 fn subject(&self) -> String {
575 format!(
576 "Welcome to {} - Your subscription is active!",
577 self.plan_name
578 )
579 }
580
581 fn recipient(&self) -> String {
582 self.to.clone()
583 }
584
585 fn priority(&self) -> EmailPriority {
586 EmailPriority::High
587 }
588}
589
590#[derive(Debug, Clone, Serialize)]
592pub struct PaymentReceiptEmail {
593 pub to: String,
595 pub name: String,
597 pub invoice_number: String,
599 pub amount: String,
601 pub plan_name: String,
603 pub payment_date: String,
605 pub next_billing_date: String,
607 pub invoice_url: Option<String>,
609 pub billing_portal_url: String,
611}
612
613impl EmailTemplate for PaymentReceiptEmail {
614 fn template_name(&self) -> &'static str {
615 "payment-receipt"
616 }
617
618 fn subject(&self) -> String {
619 format!(
620 "Payment Receipt - {} ({})",
621 self.amount, self.invoice_number
622 )
623 }
624
625 fn recipient(&self) -> String {
626 self.to.clone()
627 }
628
629 fn priority(&self) -> EmailPriority {
630 EmailPriority::Normal
631 }
632}
633
634#[derive(Debug, Clone, Serialize)]
636pub struct PaymentFailedEmail {
637 pub to: String,
639 pub name: String,
641 pub amount: String,
643 pub plan_name: String,
645 pub reason: String,
647 pub next_retry_date: Option<String>,
649 pub update_payment_url: String,
651 pub days_until_cancel: Option<u32>,
653}
654
655impl EmailTemplate for PaymentFailedEmail {
656 fn template_name(&self) -> &'static str {
657 "payment-failed"
658 }
659
660 fn subject(&self) -> String {
661 "Action Required: Payment Failed".to_string()
662 }
663
664 fn recipient(&self) -> String {
665 self.to.clone()
666 }
667
668 fn priority(&self) -> EmailPriority {
669 EmailPriority::Critical
670 }
671}
672
673#[derive(Debug, Clone, Serialize)]
675pub struct SubscriptionUpdatedEmail {
676 pub to: String,
678 pub name: String,
680 pub previous_plan: String,
682 pub new_plan: String,
684 pub new_price: String,
686 pub interval: String,
688 pub effective_date: String,
690 pub is_upgrade: bool,
692 pub billing_portal_url: String,
694}
695
696impl EmailTemplate for SubscriptionUpdatedEmail {
697 fn template_name(&self) -> &'static str {
698 "subscription-updated"
699 }
700
701 fn subject(&self) -> String {
702 if self.is_upgrade {
703 format!(
704 "Upgrade Confirmed: You're now on the {} plan!",
705 self.new_plan
706 )
707 } else {
708 format!(
709 "Plan Change Confirmed: {} to {}",
710 self.previous_plan, self.new_plan
711 )
712 }
713 }
714
715 fn recipient(&self) -> String {
716 self.to.clone()
717 }
718
719 fn priority(&self) -> EmailPriority {
720 EmailPriority::High
721 }
722}
723
724#[derive(Debug, Clone, Serialize)]
726pub struct SubscriptionCanceledEmail {
727 pub to: String,
729 pub name: String,
731 pub plan_name: String,
733 pub access_ends_at: String,
735 pub reactivate_url: String,
737 pub feedback_url: Option<String>,
739}
740
741impl EmailTemplate for SubscriptionCanceledEmail {
742 fn template_name(&self) -> &'static str {
743 "subscription-canceled"
744 }
745
746 fn subject(&self) -> String {
747 "Your subscription has been canceled".to_string()
748 }
749
750 fn recipient(&self) -> String {
751 self.to.clone()
752 }
753
754 fn priority(&self) -> EmailPriority {
755 EmailPriority::High
756 }
757}
758
759#[derive(Debug, Clone, Serialize)]
761pub struct TrialEndingEmail {
762 pub to: String,
764 pub name: String,
766 pub plan_name: String,
768 pub days_remaining: u32,
770 pub trial_ends_at: String,
772 pub price_after_trial: String,
774 pub interval: String,
776 pub add_payment_url: String,
778 pub cancel_url: String,
780}
781
782impl EmailTemplate for TrialEndingEmail {
783 fn template_name(&self) -> &'static str {
784 "trial-ending"
785 }
786
787 fn subject(&self) -> String {
788 if self.days_remaining == 1 {
789 "Your trial ends tomorrow".to_string()
790 } else {
791 format!("Your trial ends in {} days", self.days_remaining)
792 }
793 }
794
795 fn recipient(&self) -> String {
796 self.to.clone()
797 }
798
799 fn priority(&self) -> EmailPriority {
800 EmailPriority::High
801 }
802}
803
804#[derive(Debug, Clone, Serialize)]
806pub struct SubscriptionReactivatedEmail {
807 pub to: String,
809 pub name: String,
811 pub plan_name: String,
813 pub next_billing_date: String,
815 pub price: String,
817 pub interval: String,
819 pub billing_portal_url: String,
821}
822
823impl EmailTemplate for SubscriptionReactivatedEmail {
824 fn template_name(&self) -> &'static str {
825 "subscription-reactivated"
826 }
827
828 fn subject(&self) -> String {
829 format!(
830 "Welcome back! Your {} subscription is active",
831 self.plan_name
832 )
833 }
834
835 fn recipient(&self) -> String {
836 self.to.clone()
837 }
838
839 fn priority(&self) -> EmailPriority {
840 EmailPriority::High
841 }
842}
843
844#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct WeeklyDigestEmail {
849 pub to: String,
850 pub name: String,
851 pub week_start: String,
852 pub week_end: String,
853 pub apps_monitored: u32,
854 pub apps_passing: u32,
855 pub apps_with_issues: u32,
856 pub new_issues_count: u32,
857 pub digest_content: String,
858 pub dashboard_url: String,
859}
860
861impl EmailTemplate for WeeklyDigestEmail {
862 fn template_name(&self) -> &'static str {
863 "weekly_digest"
864 }
865
866 fn subject(&self) -> String {
867 if self.new_issues_count > 0 {
868 format!(
869 "Weekly Compliance Report - {} new issues detected",
870 self.new_issues_count
871 )
872 } else {
873 "Weekly Compliance Report - All checks passing".to_string()
874 }
875 }
876
877 fn recipient(&self) -> String {
878 self.to.clone()
879 }
880
881 fn priority(&self) -> EmailPriority {
882 EmailPriority::Low
883 }
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct WebhookHealthAlertEmail {
889 pub to: String,
890 pub name: String,
891 pub app_name: String,
892 pub endpoint_url: String,
893 pub webhook_type: String,
894 pub consecutive_failures: u32,
895 pub last_error: String,
896 pub dashboard_url: String,
897}
898
899impl EmailTemplate for WebhookHealthAlertEmail {
900 fn template_name(&self) -> &'static str {
901 "webhook_health_alert"
902 }
903
904 fn subject(&self) -> String {
905 format!(
906 "Webhook Health Alert: {} endpoint failing ({}x)",
907 self.webhook_type, self.consecutive_failures
908 )
909 }
910
911 fn recipient(&self) -> String {
912 self.to.clone()
913 }
914
915 fn priority(&self) -> EmailPriority {
916 EmailPriority::High
917 }
918}
919
920#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct DeprecationAlertEmail {
923 pub to: String,
924 pub name: String,
925 pub app_name: String,
926 pub api_name: String,
927 pub deprecated_date: String,
928 pub sunset_date: Option<String>,
929 pub days_until_sunset: Option<i64>,
930 pub migration_url: Option<String>,
931 pub replacement: Option<String>,
932 pub affected_files: Vec<String>,
933 pub dashboard_url: String,
934}
935
936impl EmailTemplate for DeprecationAlertEmail {
937 fn template_name(&self) -> &'static str {
938 "deprecation_alert"
939 }
940
941 fn subject(&self) -> String {
942 if let Some(days) = self.days_until_sunset {
943 format!(
944 "Action Required: {} being deprecated ({} days remaining)",
945 self.api_name, days
946 )
947 } else {
948 format!("Action Required: {} is deprecated", self.api_name)
949 }
950 }
951
952 fn recipient(&self) -> String {
953 self.to.clone()
954 }
955
956 fn priority(&self) -> EmailPriority {
957 match self.days_until_sunset {
958 Some(d) if d < 30 => EmailPriority::Critical,
959 Some(d) if d < 90 => EmailPriority::High,
960 _ => EmailPriority::Normal,
961 }
962 }
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct VulnerabilityAlertEmail {
968 pub to: String,
969 pub name: String,
970 pub app_name: String,
971 pub package_name: String,
972 pub installed_version: String,
973 pub severity: String,
974 pub cvss_score: Option<f64>,
975 pub cve_id: Option<String>,
976 pub summary: String,
977 pub fix_version: Option<String>,
978 pub fix_command: Option<String>,
979 pub dashboard_url: String,
980}
981
982impl EmailTemplate for VulnerabilityAlertEmail {
983 fn template_name(&self) -> &'static str {
984 "vulnerability_alert"
985 }
986
987 fn subject(&self) -> String {
988 format!(
989 "{} vulnerability in {} ({})",
990 self.severity, self.package_name, self.app_name
991 )
992 }
993
994 fn recipient(&self) -> String {
995 self.to.clone()
996 }
997
998 fn priority(&self) -> EmailPriority {
999 match self.severity.to_lowercase().as_str() {
1000 "critical" => EmailPriority::Critical,
1001 "high" => EmailPriority::High,
1002 _ => EmailPriority::Normal,
1003 }
1004 }
1005}
1006
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct CiValidationEmail {
1010 pub to: String,
1011 pub name: String,
1012 pub app_name: String,
1013 pub status: String,
1014 pub score: f64,
1015 pub critical_count: u32,
1016 pub high_count: u32,
1017 pub medium_count: u32,
1018 pub repository: String,
1019 pub branch: String,
1020 pub commit_sha: String,
1021 pub pr_number: Option<u64>,
1022 pub dashboard_url: String,
1023}
1024
1025impl EmailTemplate for CiValidationEmail {
1026 fn template_name(&self) -> &'static str {
1027 "ci_validation"
1028 }
1029
1030 fn subject(&self) -> String {
1031 match self.status.as_str() {
1032 "fail" => format!(
1033 "CI Validation Failed: {} critical issues in {}",
1034 self.critical_count, self.app_name
1035 ),
1036 "warning" => format!("CI Validation Warning: Issues found in {}", self.app_name),
1037 _ => format!("CI Validation Passed: {}", self.app_name),
1038 }
1039 }
1040
1041 fn recipient(&self) -> String {
1042 self.to.clone()
1043 }
1044
1045 fn priority(&self) -> EmailPriority {
1046 match self.status.as_str() {
1047 "fail" => EmailPriority::High,
1048 _ => EmailPriority::Normal,
1049 }
1050 }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056
1057 #[test]
1058 fn test_welcome_email() {
1059 let email = WelcomeEmail {
1060 to: "user@example.com".to_string(),
1061 name: "John".to_string(),
1062 verify_url: "https://example.com/verify/abc123".to_string(),
1063 };
1064
1065 assert_eq!(email.template_name(), "welcome");
1066 assert_eq!(email.recipient(), "user@example.com");
1067 assert!(email.subject().contains("John"));
1068 assert_eq!(email.priority(), EmailPriority::High);
1069 }
1070
1071 #[test]
1072 fn test_password_reset_priority() {
1073 let email = PasswordResetEmail {
1074 to: "user@example.com".to_string(),
1075 name: "John".to_string(),
1076 reset_url: "https://example.com/reset/abc123".to_string(),
1077 expires_in_hours: 1,
1078 };
1079
1080 assert_eq!(email.priority(), EmailPriority::Critical);
1081 }
1082
1083 #[test]
1084 fn test_device_auth_email() {
1085 let email = DeviceAuthEmail {
1086 to: "user@example.com".to_string(),
1087 name: "John".to_string(),
1088 device_code: "AB12CD34".to_string(),
1089 confirm_url: "https://example.com/api/v1/auth/device/confirm/token123".to_string(),
1090 expires_in_minutes: 5,
1091 };
1092
1093 assert_eq!(email.template_name(), "device-auth");
1094 assert_eq!(email.recipient(), "user@example.com");
1095 assert!(email.subject().contains("AB12CD34"));
1096 assert_eq!(email.priority(), EmailPriority::Critical);
1097 }
1098
1099 #[test]
1100 fn test_email_priority_ordering() {
1101 assert!(EmailPriority::Critical > EmailPriority::High);
1102 assert!(EmailPriority::High > EmailPriority::Normal);
1103 assert!(EmailPriority::Normal > EmailPriority::Low);
1104 }
1105
1106 #[test]
1111 fn test_subscription_activated_email() {
1112 let email = SubscriptionActivatedEmail {
1113 to: "user@example.com".to_string(),
1114 name: "John".to_string(),
1115 plan_name: "Pro".to_string(),
1116 interval: "monthly".to_string(),
1117 price: "$19.00".to_string(),
1118 next_billing_date: "February 25, 2026".to_string(),
1119 billing_portal_url: "https://billing.stripe.com/portal".to_string(),
1120 };
1121
1122 assert_eq!(email.template_name(), "subscription-activated");
1123 assert_eq!(email.recipient(), "user@example.com");
1124 assert!(email.subject().contains("Pro"));
1125 assert_eq!(email.priority(), EmailPriority::High);
1126 }
1127
1128 #[test]
1129 fn test_payment_receipt_email() {
1130 let email = PaymentReceiptEmail {
1131 to: "user@example.com".to_string(),
1132 name: "John".to_string(),
1133 invoice_number: "INV-2026-001".to_string(),
1134 amount: "$19.00".to_string(),
1135 plan_name: "Pro".to_string(),
1136 payment_date: "January 25, 2026".to_string(),
1137 next_billing_date: "February 25, 2026".to_string(),
1138 invoice_url: Some("https://invoice.stripe.com/i/pdf".to_string()),
1139 billing_portal_url: "https://billing.stripe.com/portal".to_string(),
1140 };
1141
1142 assert_eq!(email.template_name(), "payment-receipt");
1143 assert!(email.subject().contains("$19.00"));
1144 assert!(email.subject().contains("INV-2026-001"));
1145 assert_eq!(email.priority(), EmailPriority::Normal);
1146 }
1147
1148 #[test]
1149 fn test_payment_failed_email_is_critical() {
1150 let email = PaymentFailedEmail {
1151 to: "user@example.com".to_string(),
1152 name: "John".to_string(),
1153 amount: "$19.00".to_string(),
1154 plan_name: "Pro".to_string(),
1155 reason: "Card declined".to_string(),
1156 next_retry_date: Some("January 28, 2026".to_string()),
1157 update_payment_url: "https://billing.stripe.com/portal".to_string(),
1158 days_until_cancel: Some(7),
1159 };
1160
1161 assert_eq!(email.template_name(), "payment-failed");
1162 assert!(email.subject().contains("Action Required"));
1163 assert_eq!(email.priority(), EmailPriority::Critical);
1164 }
1165
1166 #[test]
1167 fn test_subscription_updated_email_upgrade() {
1168 let email = SubscriptionUpdatedEmail {
1169 to: "user@example.com".to_string(),
1170 name: "John".to_string(),
1171 previous_plan: "Pro".to_string(),
1172 new_plan: "Team".to_string(),
1173 new_price: "$49.00".to_string(),
1174 interval: "monthly".to_string(),
1175 effective_date: "January 25, 2026".to_string(),
1176 is_upgrade: true,
1177 billing_portal_url: "https://billing.stripe.com/portal".to_string(),
1178 };
1179
1180 assert_eq!(email.template_name(), "subscription-updated");
1181 assert!(email.subject().contains("Upgrade Confirmed"));
1182 assert!(email.subject().contains("Team"));
1183 }
1184
1185 #[test]
1186 fn test_subscription_updated_email_downgrade() {
1187 let email = SubscriptionUpdatedEmail {
1188 to: "user@example.com".to_string(),
1189 name: "John".to_string(),
1190 previous_plan: "Team".to_string(),
1191 new_plan: "Pro".to_string(),
1192 new_price: "$19.00".to_string(),
1193 interval: "monthly".to_string(),
1194 effective_date: "February 25, 2026".to_string(),
1195 is_upgrade: false,
1196 billing_portal_url: "https://billing.stripe.com/portal".to_string(),
1197 };
1198
1199 assert!(email.subject().contains("Plan Change Confirmed"));
1200 assert!(email.subject().contains("Team"));
1201 assert!(email.subject().contains("Pro"));
1202 }
1203
1204 #[test]
1205 fn test_subscription_canceled_email() {
1206 let email = SubscriptionCanceledEmail {
1207 to: "user@example.com".to_string(),
1208 name: "John".to_string(),
1209 plan_name: "Pro".to_string(),
1210 access_ends_at: "February 25, 2026".to_string(),
1211 reactivate_url: "https://app.example.com/billing/reactivate".to_string(),
1212 feedback_url: Some("https://app.example.com/feedback".to_string()),
1213 };
1214
1215 assert_eq!(email.template_name(), "subscription-canceled");
1216 assert!(email.subject().contains("canceled"));
1217 assert_eq!(email.priority(), EmailPriority::High);
1218 }
1219
1220 #[test]
1221 fn test_trial_ending_email_one_day() {
1222 let email = TrialEndingEmail {
1223 to: "user@example.com".to_string(),
1224 name: "John".to_string(),
1225 plan_name: "Pro".to_string(),
1226 days_remaining: 1,
1227 trial_ends_at: "January 26, 2026".to_string(),
1228 price_after_trial: "$19.00".to_string(),
1229 interval: "monthly".to_string(),
1230 add_payment_url: "https://billing.stripe.com/portal".to_string(),
1231 cancel_url: "https://app.example.com/billing/cancel".to_string(),
1232 };
1233
1234 assert_eq!(email.template_name(), "trial-ending");
1235 assert_eq!(email.subject(), "Your trial ends tomorrow");
1236 }
1237
1238 #[test]
1239 fn test_trial_ending_email_multiple_days() {
1240 let email = TrialEndingEmail {
1241 to: "user@example.com".to_string(),
1242 name: "John".to_string(),
1243 plan_name: "Pro".to_string(),
1244 days_remaining: 3,
1245 trial_ends_at: "January 28, 2026".to_string(),
1246 price_after_trial: "$19.00".to_string(),
1247 interval: "monthly".to_string(),
1248 add_payment_url: "https://billing.stripe.com/portal".to_string(),
1249 cancel_url: "https://app.example.com/billing/cancel".to_string(),
1250 };
1251
1252 assert!(email.subject().contains("3 days"));
1253 }
1254
1255 #[test]
1256 fn test_subscription_reactivated_email() {
1257 let email = SubscriptionReactivatedEmail {
1258 to: "user@example.com".to_string(),
1259 name: "John".to_string(),
1260 plan_name: "Pro".to_string(),
1261 next_billing_date: "February 25, 2026".to_string(),
1262 price: "$19.00".to_string(),
1263 interval: "monthly".to_string(),
1264 billing_portal_url: "https://billing.stripe.com/portal".to_string(),
1265 };
1266
1267 assert_eq!(email.template_name(), "subscription-reactivated");
1268 assert!(email.subject().contains("Welcome back"));
1269 assert!(email.subject().contains("Pro"));
1270 assert_eq!(email.priority(), EmailPriority::High);
1271 }
1272}