Skip to main content

stateset_embedded/
notifications.rs

1//! Transactional email notification service.
2//!
3//! Maps [`CommerceEvent`]s to structured email payloads and delivers them via
4//! configurable backends. The default backend POSTs a JSON payload to a
5//! webhook URL, making it trivial to integrate with any email provider
6//! (`SendGrid`, Mailgun, Postmark, etc.) through a thin relay.
7//!
8//! # Architecture
9//!
10//! ```text
11//! CommerceEvent ──► NotificationService ──► EmailBackend ──► Webhook / Log
12//!                    (event→email map)       (delivery)
13//! ```
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use stateset_embedded::notifications::{NotificationConfig, NotificationService, WebhookEmailBackend};
19//!
20//! let backend = WebhookEmailBackend::new("https://relay.example.com/email", Some("hmac-secret"))
21//!     .with_outbound_allowlist(["relay.example.com"]);
22//! let config = NotificationConfig {
23//!     from_name: "Acme Store".into(),
24//!     from_email: "orders@acme.com".into(),
25//!     ..Default::default()
26//! };
27//! let service = NotificationService::new(config, Box::new(backend));
28//! ```
29
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use stateset_core::{CommerceEvent, validate_email};
33use std::collections::HashMap;
34use std::fmt;
35use std::sync::Arc;
36
37#[cfg(feature = "events")]
38use crate::events::validate_outbound_webhook_target_url;
39
40/// A function that resolves a commerce event to a recipient email address.
41pub type RecipientResolver = Arc<dyn Fn(&CommerceEvent) -> Option<String> + Send + Sync>;
42
43// ---------------------------------------------------------------------------
44// Email template
45// ---------------------------------------------------------------------------
46
47/// Typed email template identifiers for commerce transactional emails.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum EmailTemplate {
52    /// Confirmation sent when an order is placed.
53    OrderConfirmation,
54    /// Notification when an order ships.
55    ShippingNotification,
56    /// Update when a return request changes status.
57    ReturnStatusUpdate,
58    /// Notification when an order is cancelled.
59    OrderCancellation,
60    /// Notification when a refund is issued.
61    RefundConfirmation,
62    /// Alert when inventory drops below reorder point.
63    LowStockAlert,
64    /// Welcome email when a customer account is created.
65    CustomerWelcome,
66}
67
68impl fmt::Display for EmailTemplate {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        let s = match self {
71            Self::OrderConfirmation => "order_confirmation",
72            Self::ShippingNotification => "shipping_notification",
73            Self::ReturnStatusUpdate => "return_status_update",
74            Self::OrderCancellation => "order_cancellation",
75            Self::RefundConfirmation => "refund_confirmation",
76            Self::LowStockAlert => "low_stock_alert",
77            Self::CustomerWelcome => "customer_welcome",
78        };
79        f.write_str(s)
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Transactional email payload
85// ---------------------------------------------------------------------------
86
87/// A structured transactional email payload ready for delivery.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct TransactionalEmail {
90    /// Recipient email address.
91    pub to: String,
92    /// Sender display name.
93    pub from_name: String,
94    /// Sender email address.
95    pub from_email: String,
96    /// Email subject line.
97    pub subject: String,
98    /// Template identifier.
99    pub template: EmailTemplate,
100    /// Template variables (key-value pairs injected into the template).
101    pub template_data: HashMap<String, serde_json::Value>,
102    /// The source commerce event type that triggered this email.
103    pub event_type: String,
104    /// Timestamp of the originating event.
105    pub event_timestamp: DateTime<Utc>,
106    /// Unique message ID for idempotency / deduplication.
107    pub message_id: uuid::Uuid,
108}
109
110// ---------------------------------------------------------------------------
111// Delivery backend trait
112// ---------------------------------------------------------------------------
113
114/// Trait for email delivery backends.
115///
116/// Implementations receive a fully-formed [`TransactionalEmail`] and are
117/// responsible for delivering it (or enqueuing it for delivery).
118pub trait EmailBackend: Send + Sync + fmt::Debug {
119    /// Deliver a transactional email.
120    ///
121    /// Returns `Ok(())` on successful delivery/enqueue, or an error message.
122    fn deliver(&self, email: &TransactionalEmail) -> Result<(), String>;
123}
124
125// ---------------------------------------------------------------------------
126// Webhook backend
127// ---------------------------------------------------------------------------
128
129/// Delivers email payloads by posting JSON to a webhook URL.
130///
131/// The payload is signed with HMAC-SHA256 when a secret is configured,
132/// using the `X-Signature-256` header (same scheme as the event webhook
133/// system).
134#[derive(Clone)]
135pub struct WebhookEmailBackend {
136    url: String,
137    secret: Option<String>,
138    outbound_allowlist: Vec<String>,
139    #[cfg(feature = "events")]
140    client: reqwest::blocking::Client,
141}
142
143impl fmt::Debug for WebhookEmailBackend {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.debug_struct("WebhookEmailBackend")
146            .field("url", &self.url)
147            .field("has_secret", &self.secret.is_some())
148            .field("outbound_allowlist", &self.outbound_allowlist)
149            .finish()
150    }
151}
152
153impl WebhookEmailBackend {
154    /// Create a new webhook email backend.
155    #[must_use]
156    pub fn new(url: impl Into<String>, secret: Option<String>) -> Self {
157        #[cfg(feature = "events")]
158        let client = reqwest::blocking::Client::builder()
159            .timeout(std::time::Duration::from_secs(30))
160            .build()
161            .unwrap_or_else(|_| reqwest::blocking::Client::new());
162
163        Self {
164            url: url.into(),
165            secret,
166            outbound_allowlist: Vec::new(),
167            #[cfg(feature = "events")]
168            client,
169        }
170    }
171
172    /// Restrict outbound webhook delivery to a normalized host allowlist.
173    ///
174    /// Supported entries:
175    /// - `example.com`
176    /// - `*.example.com`
177    #[must_use]
178    pub fn with_outbound_allowlist<I, S>(mut self, entries: I) -> Self
179    where
180        I: IntoIterator<Item = S>,
181        S: AsRef<str>,
182    {
183        self.outbound_allowlist = entries
184            .into_iter()
185            .filter_map(|entry| {
186                let normalized = entry.as_ref().trim().trim_end_matches('.').to_ascii_lowercase();
187                if normalized.is_empty() { None } else { Some(normalized) }
188            })
189            .collect();
190        self
191    }
192
193    /// Compute HMAC-SHA256 signature for a payload.
194    #[cfg(feature = "events")]
195    fn sign(&self, payload: &[u8]) -> Option<String> {
196        use hmac::{Hmac, Mac};
197        use sha2::Sha256;
198
199        let secret = self.secret.as_deref()?;
200        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
201        mac.update(payload);
202        let result = mac.finalize();
203        Some(format!("sha256={}", hex::encode(result.into_bytes())))
204    }
205}
206
207impl EmailBackend for WebhookEmailBackend {
208    fn deliver(&self, email: &TransactionalEmail) -> Result<(), String> {
209        validate_transactional_email(email)?;
210        let payload =
211            serde_json::to_vec(email).map_err(|e| format!("Failed to serialize email: {e}"))?;
212
213        #[cfg(feature = "events")]
214        {
215            validate_outbound_webhook_target_url(&self.url, &self.outbound_allowlist)
216                .map_err(|err| format!("Unsafe webhook delivery URL: {err}"))?;
217
218            let mut request = self
219                .client
220                .post(&self.url)
221                .header("Content-Type", "application/json")
222                .header("User-Agent", "stateset-notifications/1.0");
223
224            if let Some(sig) = self.sign(&payload) {
225                request = request.header("X-Signature-256", sig);
226            }
227
228            let response = request
229                .body(payload)
230                .send()
231                .map_err(|e| format!("Webhook delivery failed: {e}"))?;
232
233            if response.status().is_success() {
234                Ok(())
235            } else {
236                Err(format!("Webhook returned HTTP {}", response.status().as_u16()))
237            }
238        }
239
240        #[cfg(not(feature = "events"))]
241        {
242            let _ = payload;
243            Err("Webhook backend requires the `events` feature".to_string())
244        }
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Log backend (for testing / development)
250// ---------------------------------------------------------------------------
251
252/// A backend that logs emails via `tracing` instead of delivering them.
253///
254/// Useful for development, testing, and dry-run scenarios.
255#[derive(Debug, Clone, Default)]
256pub struct LogEmailBackend {
257    /// Collected emails (for test assertions).
258    emails: Arc<std::sync::Mutex<Vec<TransactionalEmail>>>,
259}
260
261impl LogEmailBackend {
262    /// Create a new log backend.
263    #[must_use]
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    /// Get all emails that have been "delivered" (logged).
269    #[must_use]
270    pub fn emails(&self) -> Vec<TransactionalEmail> {
271        self.emails.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
272    }
273}
274
275impl EmailBackend for LogEmailBackend {
276    fn deliver(&self, email: &TransactionalEmail) -> Result<(), String> {
277        validate_transactional_email(email)?;
278        tracing::info!(
279            template = %email.template,
280            to = %email.to,
281            subject = %email.subject,
282            event_type = %email.event_type,
283            message_id = %email.message_id,
284            "Notification email (log backend)"
285        );
286        self.emails.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(email.clone());
287        Ok(())
288    }
289}
290
291// ---------------------------------------------------------------------------
292// Configuration
293// ---------------------------------------------------------------------------
294
295/// Configuration for the notification service.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct NotificationConfig {
298    /// Default sender display name.
299    pub from_name: String,
300    /// Default sender email address.
301    pub from_email: String,
302    /// Whether notifications are enabled.
303    pub enabled: bool,
304    /// Email templates that are enabled. Empty means all templates are enabled.
305    pub enabled_templates: Vec<EmailTemplate>,
306}
307
308impl Default for NotificationConfig {
309    fn default() -> Self {
310        Self {
311            from_name: "StateSet Commerce".into(),
312            from_email: "noreply@stateset.io".into(),
313            enabled: true,
314            enabled_templates: Vec::new(),
315        }
316    }
317}
318
319impl NotificationConfig {
320    /// Check if a template is enabled.
321    fn is_template_enabled(&self, template: EmailTemplate) -> bool {
322        if !self.enabled {
323            return false;
324        }
325        if self.enabled_templates.is_empty() {
326            return true;
327        }
328        self.enabled_templates.contains(&template)
329    }
330}
331
332// ---------------------------------------------------------------------------
333// Notification service
334// ---------------------------------------------------------------------------
335
336/// Maps commerce events to transactional emails and delivers them via a
337/// configurable backend.
338pub struct NotificationService {
339    config: NotificationConfig,
340    backend: Box<dyn EmailBackend>,
341    /// Optional recipient resolver: given an event, returns the email address.
342    /// When `None`, only events that carry an email directly are deliverable.
343    recipient_resolver: Option<RecipientResolver>,
344}
345
346impl fmt::Debug for NotificationService {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        f.debug_struct("NotificationService")
349            .field("config", &self.config)
350            .field("backend", &self.backend)
351            .field("has_resolver", &self.recipient_resolver.is_some())
352            .finish()
353    }
354}
355
356impl NotificationService {
357    /// Create a new notification service.
358    #[must_use]
359    pub fn new(config: NotificationConfig, backend: Box<dyn EmailBackend>) -> Self {
360        Self { config, backend, recipient_resolver: None }
361    }
362
363    /// Set a custom recipient resolver function.
364    ///
365    /// The resolver receives a `CommerceEvent` and should return the
366    /// recipient email address, or `None` to skip sending.
367    #[must_use]
368    pub fn with_recipient_resolver(mut self, resolver: RecipientResolver) -> Self {
369        self.recipient_resolver = Some(resolver);
370        self
371    }
372
373    /// Get the service configuration.
374    #[must_use]
375    pub const fn config(&self) -> &NotificationConfig {
376        &self.config
377    }
378
379    /// Process a commerce event, potentially sending an email notification.
380    ///
381    /// Returns `Ok(Some(message_id))` if an email was sent, `Ok(None)` if
382    /// the event does not map to an email template or the template is
383    /// disabled, and `Err` if delivery failed.
384    pub fn process_event(&self, event: &CommerceEvent) -> Result<Option<uuid::Uuid>, String> {
385        if !self.config.enabled {
386            return Ok(None);
387        }
388
389        let Some(email) = self.event_to_email(event) else {
390            return Ok(None);
391        };
392
393        validate_transactional_email(&email)?;
394        self.backend.deliver(&email)?;
395
396        tracing::debug!(
397            template = %email.template,
398            to = %email.to,
399            message_id = %email.message_id,
400            "Notification delivered"
401        );
402
403        Ok(Some(email.message_id))
404    }
405
406    /// Map a commerce event to a transactional email.
407    ///
408    /// Returns `None` if the event does not map to any email template, if
409    /// the template is disabled, or if no recipient can be resolved.
410    #[must_use]
411    fn event_to_email(&self, event: &CommerceEvent) -> Option<TransactionalEmail> {
412        let (template, subject, data) = self.extract_template_info(event)?;
413
414        if !self.config.is_template_enabled(template) {
415            return None;
416        }
417
418        let to = self.resolve_recipient(event)?;
419
420        Some(TransactionalEmail {
421            to,
422            from_name: self.config.from_name.clone(),
423            from_email: self.config.from_email.clone(),
424            subject,
425            template,
426            template_data: data,
427            event_type: event.event_type().to_string(),
428            event_timestamp: event.timestamp(),
429            message_id: uuid::Uuid::new_v4(),
430        })
431    }
432
433    /// Extract template info from a commerce event.
434    fn extract_template_info(
435        &self,
436        event: &CommerceEvent,
437    ) -> Option<(EmailTemplate, String, HashMap<String, serde_json::Value>)> {
438        match event {
439            CommerceEvent::OrderCreated { order_id, total_amount, item_count, .. } => {
440                let mut data = HashMap::new();
441                data.insert("order_id".into(), serde_json::json!(order_id.to_string()));
442                data.insert("total_amount".into(), serde_json::json!(total_amount.to_string()));
443                data.insert("item_count".into(), serde_json::json!(item_count));
444                Some((
445                    EmailTemplate::OrderConfirmation,
446                    format!("Order Confirmation — {order_id}"),
447                    data,
448                ))
449            }
450            CommerceEvent::OrderCancelled { order_id, reason, .. } => {
451                let mut data = HashMap::new();
452                data.insert("order_id".into(), serde_json::json!(order_id.to_string()));
453                if let Some(reason) = reason {
454                    data.insert("reason".into(), serde_json::json!(reason));
455                }
456                Some((
457                    EmailTemplate::OrderCancellation,
458                    format!("Order Cancelled — {order_id}"),
459                    data,
460                ))
461            }
462            CommerceEvent::OrderFulfillmentStatusChanged { order_id, to_status, .. } => {
463                use stateset_core::FulfillmentStatus;
464                if *to_status != FulfillmentStatus::Shipped {
465                    return None;
466                }
467                let mut data = HashMap::new();
468                data.insert("order_id".into(), serde_json::json!(order_id.to_string()));
469                data.insert("status".into(), serde_json::json!(to_status.to_string()));
470                Some((
471                    EmailTemplate::ShippingNotification,
472                    format!("Your Order Has Shipped — {order_id}"),
473                    data,
474                ))
475            }
476            CommerceEvent::ReturnStatusChanged { return_id, from_status, to_status, .. } => {
477                let mut data = HashMap::new();
478                data.insert("return_id".into(), serde_json::json!(return_id.to_string()));
479                data.insert("from_status".into(), serde_json::json!(from_status.to_string()));
480                data.insert("to_status".into(), serde_json::json!(to_status.to_string()));
481                Some((
482                    EmailTemplate::ReturnStatusUpdate,
483                    format!("Return Update — {return_id}"),
484                    data,
485                ))
486            }
487            CommerceEvent::ReturnApproved { return_id, order_id, .. } => {
488                let mut data = HashMap::new();
489                data.insert("return_id".into(), serde_json::json!(return_id.to_string()));
490                data.insert("order_id".into(), serde_json::json!(order_id.to_string()));
491                Some((
492                    EmailTemplate::ReturnStatusUpdate,
493                    format!("Return Approved — {return_id}"),
494                    data,
495                ))
496            }
497            CommerceEvent::RefundIssued { return_id, order_id, amount, method, .. } => {
498                let mut data = HashMap::new();
499                data.insert("return_id".into(), serde_json::json!(return_id.to_string()));
500                data.insert("order_id".into(), serde_json::json!(order_id.to_string()));
501                data.insert("amount".into(), serde_json::json!(amount.to_string()));
502                data.insert("method".into(), serde_json::json!(method));
503                Some((
504                    EmailTemplate::RefundConfirmation,
505                    format!("Refund Issued — {order_id}"),
506                    data,
507                ))
508            }
509            CommerceEvent::LowStockAlert { sku, current_quantity, reorder_point, .. } => {
510                let mut data = HashMap::new();
511                data.insert("sku".into(), serde_json::json!(sku));
512                data.insert(
513                    "current_quantity".into(),
514                    serde_json::json!(current_quantity.to_string()),
515                );
516                data.insert("reorder_point".into(), serde_json::json!(reorder_point.to_string()));
517                Some((EmailTemplate::LowStockAlert, format!("Low Stock Alert — {sku}"), data))
518            }
519            CommerceEvent::CustomerCreated { customer_id, email, .. } => {
520                let mut data = HashMap::new();
521                data.insert("customer_id".into(), serde_json::json!(customer_id.to_string()));
522                data.insert("email".into(), serde_json::json!(email));
523                Some((EmailTemplate::CustomerWelcome, "Welcome to Our Store!".into(), data))
524            }
525            _ => None,
526        }
527    }
528
529    /// Resolve the recipient email for an event.
530    fn resolve_recipient(&self, event: &CommerceEvent) -> Option<String> {
531        // First try the custom resolver
532        if let Some(ref resolver) = self.recipient_resolver {
533            if let Some(email) = resolver(event) {
534                return Some(email);
535            }
536        }
537
538        // Fall back to extracting email directly from event data
539        match event {
540            CommerceEvent::CustomerCreated { email, .. } => Some(email.clone()),
541            // Other events don't carry emails directly — resolver is needed
542            _ => None,
543        }
544    }
545}
546
547fn validate_transactional_email(email: &TransactionalEmail) -> Result<(), String> {
548    validate_email(&email.to)
549        .map_err(|err| format!("Invalid recipient email `{}`: {err}", email.to))?;
550    validate_email(&email.from_email)
551        .map_err(|err| format!("Invalid sender email `{}`: {err}", email.from_email))?;
552    Ok(())
553}
554
555// ---------------------------------------------------------------------------
556// Tests
557// ---------------------------------------------------------------------------
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use chrono::Utc;
563    use rust_decimal::Decimal;
564    use stateset_core::{
565        CustomerId, FulfillmentStatus, OrderId, ProductId, ReturnId, ReturnReason, ReturnStatus,
566    };
567
568    fn default_service() -> (NotificationService, Arc<std::sync::Mutex<Vec<TransactionalEmail>>>) {
569        let backend = LogEmailBackend::new();
570        let emails = backend.emails.clone();
571        let config = NotificationConfig {
572            from_name: "Test Store".into(),
573            from_email: "test@example.com".into(),
574            ..Default::default()
575        };
576        let service = NotificationService::new(config, Box::new(backend));
577        (service, emails)
578    }
579
580    fn service_with_resolver()
581    -> (NotificationService, Arc<std::sync::Mutex<Vec<TransactionalEmail>>>) {
582        let (service, emails) = default_service();
583        let service =
584            service.with_recipient_resolver(Arc::new(|_| Some("customer@example.com".into())));
585        (service, emails)
586    }
587
588    #[test]
589    fn order_created_generates_confirmation() {
590        let (service, emails) = service_with_resolver();
591        let event = CommerceEvent::OrderCreated {
592            order_id: OrderId::new(),
593            customer_id: CustomerId::new(),
594            total_amount: Decimal::new(9999, 2),
595            item_count: 3,
596            timestamp: Utc::now(),
597        };
598
599        let result = service.process_event(&event);
600        assert!(result.is_ok());
601        assert!(result.as_ref().ok().unwrap().is_some());
602
603        let sent = emails.lock().unwrap();
604        assert_eq!(sent.len(), 1);
605        assert_eq!(sent[0].template, EmailTemplate::OrderConfirmation);
606        assert_eq!(sent[0].to, "customer@example.com");
607        assert_eq!(sent[0].from_name, "Test Store");
608        assert!(sent[0].subject.contains("Order Confirmation"));
609        assert!(sent[0].template_data.contains_key("order_id"));
610        assert!(sent[0].template_data.contains_key("total_amount"));
611        assert!(sent[0].template_data.contains_key("item_count"));
612    }
613
614    #[test]
615    fn order_cancelled_generates_cancellation() {
616        let (service, emails) = service_with_resolver();
617        let event = CommerceEvent::OrderCancelled {
618            order_id: OrderId::new(),
619            reason: Some("Customer request".into()),
620            timestamp: Utc::now(),
621        };
622
623        let result = service.process_event(&event);
624        assert!(result.unwrap().is_some());
625
626        let sent = emails.lock().unwrap();
627        assert_eq!(sent[0].template, EmailTemplate::OrderCancellation);
628        assert_eq!(sent[0].template_data["reason"], "Customer request");
629    }
630
631    #[test]
632    fn shipped_generates_shipping_notification() {
633        let (service, emails) = service_with_resolver();
634        let event = CommerceEvent::OrderFulfillmentStatusChanged {
635            order_id: OrderId::new(),
636            from_status: FulfillmentStatus::Unfulfilled,
637            to_status: FulfillmentStatus::Shipped,
638            timestamp: Utc::now(),
639        };
640
641        let result = service.process_event(&event);
642        assert!(result.unwrap().is_some());
643
644        let sent = emails.lock().unwrap();
645        assert_eq!(sent[0].template, EmailTemplate::ShippingNotification);
646        assert!(sent[0].subject.contains("Shipped"));
647    }
648
649    #[test]
650    fn non_shipped_fulfillment_skipped() {
651        let (service, emails) = service_with_resolver();
652        let event = CommerceEvent::OrderFulfillmentStatusChanged {
653            order_id: OrderId::new(),
654            from_status: FulfillmentStatus::Unfulfilled,
655            to_status: FulfillmentStatus::Fulfilled,
656            timestamp: Utc::now(),
657        };
658
659        let result = service.process_event(&event);
660        assert!(result.unwrap().is_none());
661        assert!(emails.lock().unwrap().is_empty());
662    }
663
664    #[test]
665    fn return_status_change_generates_update() {
666        let (service, emails) = service_with_resolver();
667        let event = CommerceEvent::ReturnStatusChanged {
668            return_id: ReturnId::new(),
669            from_status: ReturnStatus::Requested,
670            to_status: ReturnStatus::Approved,
671            timestamp: Utc::now(),
672        };
673
674        let result = service.process_event(&event);
675        assert!(result.unwrap().is_some());
676
677        let sent = emails.lock().unwrap();
678        assert_eq!(sent[0].template, EmailTemplate::ReturnStatusUpdate);
679    }
680
681    #[test]
682    fn return_approved_generates_update() {
683        let (service, emails) = service_with_resolver();
684        let event = CommerceEvent::ReturnApproved {
685            return_id: ReturnId::new(),
686            order_id: OrderId::new(),
687            timestamp: Utc::now(),
688        };
689
690        let result = service.process_event(&event);
691        assert!(result.unwrap().is_some());
692
693        let sent = emails.lock().unwrap();
694        assert_eq!(sent[0].template, EmailTemplate::ReturnStatusUpdate);
695        assert!(sent[0].subject.contains("Approved"));
696    }
697
698    #[test]
699    fn refund_issued_generates_confirmation() {
700        let (service, emails) = service_with_resolver();
701        let event = CommerceEvent::RefundIssued {
702            return_id: ReturnId::new(),
703            order_id: OrderId::new(),
704            amount: Decimal::new(2500, 2),
705            method: "credit_card".into(),
706            timestamp: Utc::now(),
707        };
708
709        let result = service.process_event(&event);
710        assert!(result.unwrap().is_some());
711
712        let sent = emails.lock().unwrap();
713        assert_eq!(sent[0].template, EmailTemplate::RefundConfirmation);
714        assert_eq!(sent[0].template_data["method"], "credit_card");
715    }
716
717    #[test]
718    fn low_stock_alert_generates_email() {
719        let (service, emails) = service_with_resolver();
720        let event = CommerceEvent::LowStockAlert {
721            sku: "WIDGET-001".into(),
722            location_id: 1,
723            current_quantity: Decimal::new(5, 0),
724            reorder_point: Decimal::new(10, 0),
725            timestamp: Utc::now(),
726        };
727
728        let result = service.process_event(&event);
729        assert!(result.unwrap().is_some());
730
731        let sent = emails.lock().unwrap();
732        assert_eq!(sent[0].template, EmailTemplate::LowStockAlert);
733        assert!(sent[0].subject.contains("WIDGET-001"));
734    }
735
736    #[test]
737    fn customer_created_generates_welcome() {
738        let (service, emails) = default_service();
739        // CustomerCreated carries email directly — no resolver needed
740        let event = CommerceEvent::CustomerCreated {
741            customer_id: CustomerId::new(),
742            email: "alice@example.com".into(),
743            timestamp: Utc::now(),
744        };
745
746        let result = service.process_event(&event);
747        assert!(result.unwrap().is_some());
748
749        let sent = emails.lock().unwrap();
750        assert_eq!(sent[0].template, EmailTemplate::CustomerWelcome);
751        assert_eq!(sent[0].to, "alice@example.com");
752        assert!(sent[0].subject.contains("Welcome"));
753    }
754
755    #[test]
756    fn unhandled_event_returns_none() {
757        let (service, emails) = service_with_resolver();
758        let event = CommerceEvent::ProductCreated {
759            product_id: ProductId::new(),
760            name: "Widget".into(),
761            slug: "widget".into(),
762            timestamp: Utc::now(),
763        };
764
765        let result = service.process_event(&event);
766        assert!(result.unwrap().is_none());
767        assert!(emails.lock().unwrap().is_empty());
768    }
769
770    #[test]
771    fn disabled_service_skips_all() {
772        let backend = LogEmailBackend::new();
773        let emails = backend.emails.clone();
774        let config = NotificationConfig { enabled: false, ..Default::default() };
775        let service = NotificationService::new(config, Box::new(backend))
776            .with_recipient_resolver(Arc::new(|_| Some("a@b.com".into())));
777
778        let event = CommerceEvent::OrderCreated {
779            order_id: OrderId::new(),
780            customer_id: CustomerId::new(),
781            total_amount: Decimal::new(100, 0),
782            item_count: 1,
783            timestamp: Utc::now(),
784        };
785
786        assert!(service.process_event(&event).unwrap().is_none());
787        assert!(emails.lock().unwrap().is_empty());
788    }
789
790    #[test]
791    fn template_filtering_works() {
792        let backend = LogEmailBackend::new();
793        let emails = backend.emails.clone();
794        let config = NotificationConfig {
795            enabled_templates: vec![EmailTemplate::LowStockAlert],
796            ..Default::default()
797        };
798        let service = NotificationService::new(config, Box::new(backend))
799            .with_recipient_resolver(Arc::new(|_| Some("a@b.com".into())));
800
801        // OrderCreated is not in enabled list → skipped
802        let event = CommerceEvent::OrderCreated {
803            order_id: OrderId::new(),
804            customer_id: CustomerId::new(),
805            total_amount: Decimal::new(100, 0),
806            item_count: 1,
807            timestamp: Utc::now(),
808        };
809        assert!(service.process_event(&event).unwrap().is_none());
810
811        // LowStockAlert IS in enabled list → sent
812        let event = CommerceEvent::LowStockAlert {
813            sku: "SKU-001".into(),
814            location_id: 1,
815            current_quantity: Decimal::new(2, 0),
816            reorder_point: Decimal::new(10, 0),
817            timestamp: Utc::now(),
818        };
819        assert!(service.process_event(&event).unwrap().is_some());
820        assert_eq!(emails.lock().unwrap().len(), 1);
821    }
822
823    #[test]
824    fn no_resolver_no_email_for_order_events() {
825        let (service, emails) = default_service();
826        // No resolver set — OrderCreated has no email in the event itself
827        let event = CommerceEvent::OrderCreated {
828            order_id: OrderId::new(),
829            customer_id: CustomerId::new(),
830            total_amount: Decimal::new(100, 0),
831            item_count: 1,
832            timestamp: Utc::now(),
833        };
834
835        assert!(service.process_event(&event).unwrap().is_none());
836        assert!(emails.lock().unwrap().is_empty());
837    }
838
839    #[test]
840    fn email_template_display() {
841        assert_eq!(EmailTemplate::OrderConfirmation.to_string(), "order_confirmation");
842        assert_eq!(EmailTemplate::ShippingNotification.to_string(), "shipping_notification");
843        assert_eq!(EmailTemplate::ReturnStatusUpdate.to_string(), "return_status_update");
844        assert_eq!(EmailTemplate::OrderCancellation.to_string(), "order_cancellation");
845        assert_eq!(EmailTemplate::RefundConfirmation.to_string(), "refund_confirmation");
846        assert_eq!(EmailTemplate::LowStockAlert.to_string(), "low_stock_alert");
847        assert_eq!(EmailTemplate::CustomerWelcome.to_string(), "customer_welcome");
848    }
849
850    #[test]
851    fn transactional_email_serializes() {
852        let email = TransactionalEmail {
853            to: "test@example.com".into(),
854            from_name: "Store".into(),
855            from_email: "noreply@store.com".into(),
856            subject: "Test".into(),
857            template: EmailTemplate::OrderConfirmation,
858            template_data: HashMap::new(),
859            event_type: "order_created".into(),
860            event_timestamp: Utc::now(),
861            message_id: uuid::Uuid::new_v4(),
862        };
863
864        let json = serde_json::to_string(&email).unwrap();
865        assert!(json.contains("order_confirmation"));
866        assert!(json.contains("test@example.com"));
867
868        let roundtrip: TransactionalEmail = serde_json::from_str(&json).unwrap();
869        assert_eq!(roundtrip.template, EmailTemplate::OrderConfirmation);
870        assert_eq!(roundtrip.to, "test@example.com");
871    }
872
873    #[test]
874    fn notification_config_default() {
875        let config = NotificationConfig::default();
876        assert!(config.enabled);
877        assert!(config.enabled_templates.is_empty());
878        assert_eq!(config.from_name, "StateSet Commerce");
879        assert_eq!(config.from_email, "noreply@stateset.io");
880    }
881
882    #[test]
883    fn webhook_backend_debug_hides_secret() {
884        let backend =
885            WebhookEmailBackend::new("https://example.com/email", Some("my_token_123".into()));
886        let debug = format!("{backend:?}");
887        assert!(debug.contains("has_secret: true"));
888        // The actual secret value must not appear in debug output
889        assert!(!debug.contains("my_token_123"));
890    }
891
892    #[test]
893    fn log_backend_collects_emails() {
894        let backend = LogEmailBackend::new();
895        let email = TransactionalEmail {
896            to: "a@b.com".into(),
897            from_name: "S".into(),
898            from_email: "n@s.com".into(),
899            subject: "Hi".into(),
900            template: EmailTemplate::CustomerWelcome,
901            template_data: HashMap::new(),
902            event_type: "customer_created".into(),
903            event_timestamp: Utc::now(),
904            message_id: uuid::Uuid::new_v4(),
905        };
906
907        backend.deliver(&email).unwrap();
908        backend.deliver(&email).unwrap();
909
910        let collected = backend.emails();
911        assert_eq!(collected.len(), 2);
912    }
913
914    #[test]
915    fn notification_service_rejects_invalid_sender_email() {
916        let backend = LogEmailBackend::new();
917        let config = NotificationConfig {
918            from_name: "Test Store".into(),
919            from_email: "not-an-email".into(),
920            ..Default::default()
921        };
922        let service = NotificationService::new(config, Box::new(backend))
923            .with_recipient_resolver(Arc::new(|_| Some("customer@example.com".into())));
924
925        let event = CommerceEvent::OrderCreated {
926            order_id: OrderId::new(),
927            customer_id: CustomerId::new(),
928            total_amount: Decimal::new(100, 0),
929            item_count: 1,
930            timestamp: Utc::now(),
931        };
932
933        let error = service.process_event(&event).unwrap_err();
934        assert!(error.contains("Invalid sender email"));
935    }
936
937    #[test]
938    fn notification_service_rejects_invalid_recipient_email() {
939        let backend = LogEmailBackend::new();
940        let service = NotificationService::new(NotificationConfig::default(), Box::new(backend))
941            .with_recipient_resolver(Arc::new(|_| Some("not-an-email".into())));
942
943        let event = CommerceEvent::OrderCreated {
944            order_id: OrderId::new(),
945            customer_id: CustomerId::new(),
946            total_amount: Decimal::new(100, 0),
947            item_count: 1,
948            timestamp: Utc::now(),
949        };
950
951        let error = service.process_event(&event).unwrap_err();
952        assert!(error.contains("Invalid recipient email"));
953    }
954
955    #[cfg(feature = "events")]
956    #[test]
957    fn webhook_backend_rejects_unsafe_delivery_url_before_network_io() {
958        let backend = WebhookEmailBackend::new("http://127.0.0.1:8080/email", None);
959        let email = TransactionalEmail {
960            to: "customer@example.com".into(),
961            from_name: "Store".into(),
962            from_email: "noreply@store.com".into(),
963            subject: "Test".into(),
964            template: EmailTemplate::CustomerWelcome,
965            template_data: HashMap::new(),
966            event_type: "customer_created".into(),
967            event_timestamp: Utc::now(),
968            message_id: uuid::Uuid::new_v4(),
969        };
970
971        let error = backend.deliver(&email).unwrap_err();
972        assert!(error.contains("Unsafe webhook delivery URL"));
973    }
974
975    #[test]
976    fn return_requested_not_mapped() {
977        // ReturnRequested is not directly mapped (ReturnStatusChanged is)
978        let (service, emails) = service_with_resolver();
979        let event = CommerceEvent::ReturnRequested {
980            return_id: ReturnId::new(),
981            order_id: OrderId::new(),
982            customer_id: CustomerId::new(),
983            reason: ReturnReason::Defective,
984            item_count: 1,
985            timestamp: Utc::now(),
986        };
987
988        assert!(service.process_event(&event).unwrap().is_none());
989        assert!(emails.lock().unwrap().is_empty());
990    }
991
992    #[test]
993    fn message_id_is_unique() {
994        let (service, emails) = service_with_resolver();
995
996        for _ in 0..3 {
997            let event = CommerceEvent::OrderCreated {
998                order_id: OrderId::new(),
999                customer_id: CustomerId::new(),
1000                total_amount: Decimal::new(100, 0),
1001                item_count: 1,
1002                timestamp: Utc::now(),
1003            };
1004            service.process_event(&event).unwrap();
1005        }
1006
1007        let sent = emails.lock().unwrap();
1008        let ids: Vec<_> = sent.iter().map(|e| e.message_id).collect();
1009        assert_ne!(ids[0], ids[1]);
1010        assert_ne!(ids[1], ids[2]);
1011    }
1012}