1use 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
40pub type RecipientResolver = Arc<dyn Fn(&CommerceEvent) -> Option<String> + Send + Sync>;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum EmailTemplate {
52 OrderConfirmation,
54 ShippingNotification,
56 ReturnStatusUpdate,
58 OrderCancellation,
60 RefundConfirmation,
62 LowStockAlert,
64 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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct TransactionalEmail {
90 pub to: String,
92 pub from_name: String,
94 pub from_email: String,
96 pub subject: String,
98 pub template: EmailTemplate,
100 pub template_data: HashMap<String, serde_json::Value>,
102 pub event_type: String,
104 pub event_timestamp: DateTime<Utc>,
106 pub message_id: uuid::Uuid,
108}
109
110pub trait EmailBackend: Send + Sync + fmt::Debug {
119 fn deliver(&self, email: &TransactionalEmail) -> Result<(), String>;
123}
124
125#[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 #[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 #[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 #[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#[derive(Debug, Clone, Default)]
256pub struct LogEmailBackend {
257 emails: Arc<std::sync::Mutex<Vec<TransactionalEmail>>>,
259}
260
261impl LogEmailBackend {
262 #[must_use]
264 pub fn new() -> Self {
265 Self::default()
266 }
267
268 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct NotificationConfig {
298 pub from_name: String,
300 pub from_email: String,
302 pub enabled: bool,
304 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 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
332pub struct NotificationService {
339 config: NotificationConfig,
340 backend: Box<dyn EmailBackend>,
341 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 #[must_use]
359 pub fn new(config: NotificationConfig, backend: Box<dyn EmailBackend>) -> Self {
360 Self { config, backend, recipient_resolver: None }
361 }
362
363 #[must_use]
368 pub fn with_recipient_resolver(mut self, resolver: RecipientResolver) -> Self {
369 self.recipient_resolver = Some(resolver);
370 self
371 }
372
373 #[must_use]
375 pub const fn config(&self) -> &NotificationConfig {
376 &self.config
377 }
378
379 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 #[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 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 fn resolve_recipient(&self, event: &CommerceEvent) -> Option<String> {
531 if let Some(ref resolver) = self.recipient_resolver {
533 if let Some(email) = resolver(event) {
534 return Some(email);
535 }
536 }
537
538 match event {
540 CommerceEvent::CustomerCreated { email, .. } => Some(email.clone()),
541 _ => 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#[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 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 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 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 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 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 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}