Skip to main content

gatekpr_email/templates/
mod.rs

1//! Email template management and rendering
2//!
3//! This module provides:
4//! - MJML template rendering via [mrml](https://github.com/jdrouet/mrml)
5//! - Variable substitution via [handlebars](https://crates.io/crates/handlebars)
6//! - Type-safe email template definitions
7//!
8//! # Architecture
9//!
10//! ```text
11//! MJML Template → mrml (parse) → HTML Template → handlebars (variables) → Final HTML
12//! ```
13
14mod loader;
15mod renderer;
16
17pub use loader::TemplateLoader;
18pub use renderer::TemplateRenderer;
19
20use serde::{Deserialize, Serialize};
21
22/// Trait for email templates
23///
24/// Implement this trait to define a new email type with its
25/// template data and metadata.
26///
27/// # Example
28///
29/// ```rust,ignore
30/// use serde::Serialize;
31/// use gatekpr_email::templates::EmailTemplate;
32///
33/// #[derive(Serialize)]
34/// pub struct WelcomeEmail {
35///     pub to: String,
36///     pub name: String,
37///     pub verify_url: String,
38/// }
39///
40/// impl EmailTemplate for WelcomeEmail {
41///     fn template_name(&self) -> &'static str {
42///         "welcome"
43///     }
44///
45///     fn subject(&self) -> String {
46///         format!("Welcome to Gatekpr, {}!", self.name)
47///     }
48///
49///     fn recipient(&self) -> String {
50///         self.to.clone()
51///     }
52/// }
53/// ```
54pub trait EmailTemplate: Serialize + Send + Sync {
55    /// Get the template name (without extension)
56    ///
57    /// This corresponds to a file in the templates directory,
58    /// e.g., "welcome" maps to "welcome.mjml"
59    fn template_name(&self) -> &'static str;
60
61    /// Get the email subject line
62    fn subject(&self) -> String;
63
64    /// Get the recipient email address
65    fn recipient(&self) -> String;
66
67    /// Get the priority level (for queue ordering)
68    fn priority(&self) -> EmailPriority {
69        EmailPriority::Normal
70    }
71
72    /// Get optional CC recipients
73    fn cc(&self) -> Option<Vec<String>> {
74        None
75    }
76
77    /// Get optional BCC recipients
78    fn bcc(&self) -> Option<Vec<String>> {
79        None
80    }
81}
82
83/// Email priority levels for queue ordering
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum EmailPriority {
87    /// Low priority (marketing, digests)
88    Low = 0,
89    /// Normal priority (most emails)
90    #[default]
91    Normal = 1,
92    /// High priority (security alerts)
93    High = 2,
94    /// Critical priority (password reset, verification)
95    Critical = 3,
96}
97
98// =============================================================================
99// AUTHENTICATION EMAIL TEMPLATES
100// =============================================================================
101
102/// Welcome email sent after registration
103#[derive(Debug, Clone, Serialize)]
104pub struct WelcomeEmail {
105    /// Recipient email address
106    pub to: String,
107    /// User's display name
108    pub name: String,
109    /// Email verification URL
110    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/// Email verification request
132#[derive(Debug, Clone, Serialize)]
133pub struct VerifyEmailEmail {
134    /// Recipient email address
135    pub to: String,
136    /// User's display name
137    pub name: String,
138    /// Verification URL
139    pub verify_url: String,
140    /// Token expiry in hours
141    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/// Password reset request
163#[derive(Debug, Clone, Serialize)]
164pub struct PasswordResetEmail {
165    /// Recipient email address
166    pub to: String,
167    /// User's display name
168    pub name: String,
169    /// Password reset URL
170    pub reset_url: String,
171    /// Token expiry in hours
172    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/// Password changed confirmation
194#[derive(Debug, Clone, Serialize)]
195pub struct PasswordChangedEmail {
196    /// Recipient email address
197    pub to: String,
198    /// User's display name
199    pub name: String,
200    /// When the password was changed (formatted)
201    pub changed_at: String,
202    /// IP address that made the change
203    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/// API key generated notification
225#[derive(Debug, Clone, Serialize)]
226pub struct ApiKeyGeneratedEmail {
227    /// Recipient email address
228    pub to: String,
229    /// User's display name
230    pub name: String,
231    /// API key name/label
232    pub key_name: String,
233    /// When the key was created (formatted)
234    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// =============================================================================
256// DEVICE AUTH (MAGIC LINK) TEMPLATES
257// =============================================================================
258
259/// Magic link email for passwordless CLI authentication
260#[derive(Debug, Clone, Serialize)]
261pub struct DeviceAuthEmail {
262    /// Recipient email address
263    pub to: String,
264    /// User's display name (may be empty for new users)
265    pub name: String,
266    /// Device code shown to user in CLI
267    pub device_code: String,
268    /// URL to confirm the CLI login
269    pub confirm_url: String,
270    /// Minutes until expiry
271    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// =============================================================================
293// SECURITY ALERT TEMPLATES
294// =============================================================================
295
296/// New login detected from unknown device/location
297#[derive(Debug, Clone, Serialize)]
298pub struct NewLoginEmail {
299    /// Recipient email address
300    pub to: String,
301    /// User's display name
302    pub name: String,
303    /// Device/browser info
304    pub device: String,
305    /// Geographic location
306    pub location: String,
307    /// Login time (formatted)
308    pub time: String,
309    /// IP address
310    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// =============================================================================
332// APPLICATION NOTIFICATION TEMPLATES
333// =============================================================================
334
335/// Validation complete notification
336#[derive(Debug, Clone, Serialize)]
337pub struct ValidationCompleteEmail {
338    /// Recipient email address
339    pub to: String,
340    /// User's display name
341    pub name: String,
342    /// App name
343    pub app_name: String,
344    /// Validation score (0-100)
345    pub score: u32,
346    /// Number of issues found
347    pub issues_count: u32,
348    /// Number of critical issues
349    pub critical_count: u32,
350    /// Link to full report
351    pub report_url: String,
352    /// Status: "passed", "needs_review", "failed"
353    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/// Failed login attempts alert (sent after 3+ failed attempts)
375#[derive(Debug, Clone, Serialize)]
376pub struct FailedLoginAlertEmail {
377    /// Recipient email address
378    pub to: String,
379    /// User's display name
380    pub name: String,
381    /// Number of failed attempts
382    pub attempts: u32,
383    /// Time range of attempts (formatted)
384    pub time_range: String,
385    /// IP addresses that attempted login
386    pub ip_addresses: Vec<String>,
387    /// Last attempt time (formatted)
388    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/// API key usage from new IP alert
410#[derive(Debug, Clone, Serialize)]
411pub struct ApiKeyUsageAlertEmail {
412    /// Recipient email address
413    pub to: String,
414    /// User's display name
415    pub name: String,
416    /// API key name/label
417    pub key_name: String,
418    /// IP address that used the key
419    pub ip_address: String,
420    /// Whether this is the first use of the key
421    pub first_use: bool,
422    /// Time of usage (formatted)
423    pub used_at: String,
424    /// User agent/device info
425    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/// Critical issues found in validation
457#[derive(Debug, Clone, Serialize)]
458pub struct CriticalIssuesEmail {
459    /// Recipient email address
460    pub to: String,
461    /// User's display name
462    pub name: String,
463    /// App name
464    pub app_name: String,
465    /// List of critical issues
466    pub issues: Vec<CriticalIssue>,
467    /// Link to full report
468    pub report_url: String,
469}
470
471/// A critical issue found during validation
472#[derive(Debug, Clone, Serialize)]
473pub struct CriticalIssue {
474    /// Issue rule ID (e.g., "WH001")
475    pub rule_id: String,
476    /// Issue title
477    pub title: String,
478    /// Brief description
479    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/// Rate limit warning (at 80% usage)
505#[derive(Debug, Clone, Serialize)]
506pub struct RateLimitWarningEmail {
507    /// Recipient email address
508    pub to: String,
509    /// User's display name
510    pub name: String,
511    /// Current usage count
512    pub current_usage: u32,
513    /// Usage limit
514    pub limit: u32,
515    /// Usage percentage
516    pub percentage: u32,
517    /// When the limit resets (formatted)
518    pub reset_date: String,
519    /// Plan name
520    pub plan: String,
521    /// Upgrade URL (optional)
522    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// =============================================================================
547// BILLING EMAIL TEMPLATES
548// =============================================================================
549
550/// Subscription activated (checkout completed successfully)
551#[derive(Debug, Clone, Serialize)]
552pub struct SubscriptionActivatedEmail {
553    /// Recipient email address
554    pub to: String,
555    /// User's display name
556    pub name: String,
557    /// Plan name (e.g., "Pro", "Team", "Enterprise")
558    pub plan_name: String,
559    /// Billing interval ("monthly" or "yearly")
560    pub interval: String,
561    /// Monthly/yearly price formatted (e.g., "$19.00")
562    pub price: String,
563    /// Next billing date (formatted)
564    pub next_billing_date: String,
565    /// Link to billing portal
566    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/// Payment receipt (invoice paid successfully)
591#[derive(Debug, Clone, Serialize)]
592pub struct PaymentReceiptEmail {
593    /// Recipient email address
594    pub to: String,
595    /// User's display name
596    pub name: String,
597    /// Invoice number
598    pub invoice_number: String,
599    /// Amount paid (formatted, e.g., "$19.00")
600    pub amount: String,
601    /// Plan name
602    pub plan_name: String,
603    /// Payment date (formatted)
604    pub payment_date: String,
605    /// Next billing date (formatted)
606    pub next_billing_date: String,
607    /// Invoice PDF URL (optional)
608    pub invoice_url: Option<String>,
609    /// Billing portal URL
610    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/// Payment failed notification
635#[derive(Debug, Clone, Serialize)]
636pub struct PaymentFailedEmail {
637    /// Recipient email address
638    pub to: String,
639    /// User's display name
640    pub name: String,
641    /// Amount that failed (formatted)
642    pub amount: String,
643    /// Plan name
644    pub plan_name: String,
645    /// Failure reason (user-friendly message)
646    pub reason: String,
647    /// Next retry date (formatted, if applicable)
648    pub next_retry_date: Option<String>,
649    /// Link to update payment method
650    pub update_payment_url: String,
651    /// Days until subscription is canceled
652    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/// Subscription plan changed
674#[derive(Debug, Clone, Serialize)]
675pub struct SubscriptionUpdatedEmail {
676    /// Recipient email address
677    pub to: String,
678    /// User's display name
679    pub name: String,
680    /// Previous plan name
681    pub previous_plan: String,
682    /// New plan name
683    pub new_plan: String,
684    /// New price (formatted)
685    pub new_price: String,
686    /// Billing interval
687    pub interval: String,
688    /// Effective date (formatted)
689    pub effective_date: String,
690    /// Whether this is an upgrade
691    pub is_upgrade: bool,
692    /// Billing portal URL
693    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/// Subscription canceled
725#[derive(Debug, Clone, Serialize)]
726pub struct SubscriptionCanceledEmail {
727    /// Recipient email address
728    pub to: String,
729    /// User's display name
730    pub name: String,
731    /// Plan name being canceled
732    pub plan_name: String,
733    /// Access end date (formatted)
734    pub access_ends_at: String,
735    /// Link to reactivate subscription
736    pub reactivate_url: String,
737    /// Feedback survey URL (optional)
738    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/// Trial ending soon notification
760#[derive(Debug, Clone, Serialize)]
761pub struct TrialEndingEmail {
762    /// Recipient email address
763    pub to: String,
764    /// User's display name
765    pub name: String,
766    /// Plan name
767    pub plan_name: String,
768    /// Days remaining in trial
769    pub days_remaining: u32,
770    /// Trial end date (formatted)
771    pub trial_ends_at: String,
772    /// Price after trial (formatted)
773    pub price_after_trial: String,
774    /// Billing interval
775    pub interval: String,
776    /// Link to add payment method
777    pub add_payment_url: String,
778    /// Link to cancel (to avoid charges)
779    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/// Subscription reactivated
805#[derive(Debug, Clone, Serialize)]
806pub struct SubscriptionReactivatedEmail {
807    /// Recipient email address
808    pub to: String,
809    /// User's display name
810    pub name: String,
811    /// Plan name
812    pub plan_name: String,
813    /// Next billing date (formatted)
814    pub next_billing_date: String,
815    /// Price (formatted)
816    pub price: String,
817    /// Billing interval
818    pub interval: String,
819    /// Billing portal URL
820    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// ─── Monitoring Email Templates ─────────────────────────────────────────
845
846/// Weekly compliance digest email
847#[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/// Webhook health alert email
887#[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/// API deprecation alert email
921#[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/// Vulnerability alert email
966#[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/// CI validation result email
1008#[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    // =========================================================================
1107    // BILLING EMAIL TESTS
1108    // =========================================================================
1109
1110    #[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}