Skip to main content

rustack_ses_core/
retrospection.rs

1//! Email store for retrospection via `/_aws/ses`.
2//!
3//! Captures all "sent" emails in an append-only store backed by `DashMap`.
4//! No actual email delivery occurs -- all emails are captured in memory
5//! for test inspection.
6
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use dashmap::DashMap;
10use serde::{Deserialize, Serialize};
11
12/// Store for all sent emails, enabling retrospection via `/_aws/ses`.
13///
14/// Append-only: emails are added when `SendEmail`, `SendRawEmail`, or
15/// `SendTemplatedEmail` is called. Emails can be queried by message ID
16/// or source address, and cleared for test isolation.
17#[derive(Debug)]
18pub struct EmailStore {
19    /// All sent emails keyed by message ID.
20    emails: DashMap<String, SentEmail>,
21    /// Total number of emails sent (monotonically increasing, not reset on clear).
22    total_sent: AtomicU64,
23}
24
25impl Default for EmailStore {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl EmailStore {
32    /// Create a new empty email store.
33    #[must_use]
34    pub fn new() -> Self {
35        Self {
36            emails: DashMap::new(),
37            total_sent: AtomicU64::new(0),
38        }
39    }
40
41    /// Store a sent email for retrospection. Returns the generated message ID.
42    pub fn capture(&self, email: SentEmail) -> String {
43        let id = email.id.clone();
44        self.emails.insert(id.clone(), email);
45        self.total_sent.fetch_add(1, Ordering::Relaxed);
46        id
47    }
48
49    /// Query emails with optional filters.
50    #[must_use]
51    pub fn query(&self, filter_id: Option<&str>, filter_source: Option<&str>) -> Vec<SentEmail> {
52        self.emails
53            .iter()
54            .filter(|entry| {
55                let email = entry.value();
56                let id_match = filter_id.is_none_or(|id| id.is_empty() || email.id == id);
57                let source_match =
58                    filter_source.is_none_or(|src| src.is_empty() || email.source == src);
59                id_match && source_match
60            })
61            .map(|entry| entry.value().clone())
62            .collect()
63    }
64
65    /// Remove a specific email by message ID.
66    pub fn remove(&self, id: &str) {
67        self.emails.remove(id);
68    }
69
70    /// Clear all captured emails. Does NOT reset the `total_sent` counter.
71    pub fn clear(&self) {
72        self.emails.clear();
73    }
74
75    /// Get the total number of emails sent (lifetime, not reset on clear).
76    #[must_use]
77    pub fn total_sent(&self) -> u64 {
78        self.total_sent.load(Ordering::Relaxed)
79    }
80
81    /// Get the current number of stored emails.
82    #[must_use]
83    pub fn count(&self) -> usize {
84        self.emails.len()
85    }
86}
87
88/// A single captured email, stored for retrospection.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct SentEmail {
92    /// Unique message ID (UUID).
93    pub id: String,
94    /// AWS region where the email was sent.
95    pub region: String,
96    /// ISO 8601 timestamp of when the email was captured.
97    pub timestamp: String,
98    /// Source (From) email address.
99    pub source: String,
100    /// Destination addresses.
101    pub destination: SentEmailDestination,
102    /// Email subject line (for `SendEmail`, `SendTemplatedEmail` after rendering).
103    pub subject: Option<String>,
104    /// Email body.
105    pub body: Option<SentEmailBody>,
106    /// Raw MIME data (for `SendRawEmail`).
107    pub raw_data: Option<String>,
108    /// Template name (for `SendTemplatedEmail`).
109    pub template: Option<String>,
110    /// Template data JSON string (for `SendTemplatedEmail`).
111    pub template_data: Option<String>,
112    /// Message tags from the send request.
113    pub tags: Vec<SentEmailTag>,
114}
115
116/// Destination addresses for a sent email.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct SentEmailDestination {
120    /// To addresses.
121    pub to_addresses: Vec<String>,
122    /// CC addresses.
123    pub cc_addresses: Vec<String>,
124    /// BCC addresses.
125    pub bcc_addresses: Vec<String>,
126}
127
128/// Body of a sent email.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct SentEmailBody {
132    /// Plain text body part.
133    pub text_part: Option<String>,
134    /// HTML body part.
135    pub html_part: Option<String>,
136}
137
138/// A message tag.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct SentEmailTag {
142    /// Tag name.
143    pub name: String,
144    /// Tag value.
145    pub value: String,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn make_test_email(id: &str, source: &str) -> SentEmail {
153        SentEmail {
154            id: id.to_owned(),
155            region: "us-east-1".to_owned(),
156            timestamp: "2026-03-19T10:00:00Z".to_owned(),
157            source: source.to_owned(),
158            destination: SentEmailDestination {
159                to_addresses: vec!["recipient@example.com".to_owned()],
160                cc_addresses: Vec::new(),
161                bcc_addresses: Vec::new(),
162            },
163            subject: Some("Test Subject".to_owned()),
164            body: Some(SentEmailBody {
165                text_part: Some("Hello".to_owned()),
166                html_part: None,
167            }),
168            raw_data: None,
169            template: None,
170            template_data: None,
171            tags: Vec::new(),
172        }
173    }
174
175    #[test]
176    fn test_should_capture_and_query_email() {
177        let store = EmailStore::new();
178        let email = make_test_email("msg-1", "sender@example.com");
179        let id = store.capture(email);
180        assert_eq!(id, "msg-1");
181        assert_eq!(store.count(), 1);
182        assert_eq!(store.total_sent(), 1);
183
184        let results = store.query(None, None);
185        assert_eq!(results.len(), 1);
186        assert_eq!(results[0].source, "sender@example.com");
187    }
188
189    #[test]
190    fn test_should_query_by_id() {
191        let store = EmailStore::new();
192        store.capture(make_test_email("msg-1", "a@b.com"));
193        store.capture(make_test_email("msg-2", "c@d.com"));
194
195        let results = store.query(Some("msg-1"), None);
196        assert_eq!(results.len(), 1);
197        assert_eq!(results[0].id, "msg-1");
198    }
199
200    #[test]
201    fn test_should_query_by_source() {
202        let store = EmailStore::new();
203        store.capture(make_test_email("msg-1", "a@b.com"));
204        store.capture(make_test_email("msg-2", "c@d.com"));
205
206        let results = store.query(None, Some("a@b.com"));
207        assert_eq!(results.len(), 1);
208        assert_eq!(results[0].source, "a@b.com");
209    }
210
211    #[test]
212    fn test_should_query_with_both_filters() {
213        let store = EmailStore::new();
214        store.capture(make_test_email("msg-1", "a@b.com"));
215        store.capture(make_test_email("msg-2", "a@b.com"));
216
217        let results = store.query(Some("msg-1"), Some("a@b.com"));
218        assert_eq!(results.len(), 1);
219        assert_eq!(results[0].id, "msg-1");
220    }
221
222    #[test]
223    fn test_should_remove_single_email() {
224        let store = EmailStore::new();
225        store.capture(make_test_email("msg-1", "a@b.com"));
226        store.capture(make_test_email("msg-2", "c@d.com"));
227        store.remove("msg-1");
228        assert_eq!(store.count(), 1);
229        assert!(store.query(Some("msg-1"), None).is_empty());
230    }
231
232    #[test]
233    fn test_should_clear_all_emails() {
234        let store = EmailStore::new();
235        store.capture(make_test_email("msg-1", "a@b.com"));
236        store.capture(make_test_email("msg-2", "c@d.com"));
237        store.clear();
238        assert_eq!(store.count(), 0);
239        // total_sent is NOT reset on clear
240        assert_eq!(store.total_sent(), 2);
241    }
242
243    #[test]
244    fn test_should_serialize_to_json() {
245        let email = make_test_email("msg-1", "sender@example.com");
246        let json = serde_json::to_string(&email);
247        assert!(json.is_ok());
248        let json = json.unwrap_or_default();
249        assert!(json.contains("\"id\":\"msg-1\""));
250        assert!(json.contains("\"source\":\"sender@example.com\""));
251        assert!(json.contains("\"toAddresses\""));
252    }
253}