Skip to main content

fakecloud_ses/
state.rs

1use chrono::{DateTime, Utc};
2use parking_lot::RwLock;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct EmailIdentity {
9    pub identity_name: String,
10    pub identity_type: String,
11    pub verified: bool,
12    pub created_at: DateTime<Utc>,
13    // DKIM attributes
14    pub dkim_signing_enabled: bool,
15    pub dkim_signing_attributes_origin: String,
16    pub dkim_domain_signing_private_key: Option<String>,
17    pub dkim_domain_signing_selector: Option<String>,
18    pub dkim_next_signing_key_length: Option<String>,
19    /// SubjectPublicKeyInfo DER, base64-encoded. Populated when Easy DKIM
20    /// generates the keypair. BYODKIM imports leave this empty (the user
21    /// publishes their own public record).
22    #[serde(default)]
23    pub dkim_public_key_b64: Option<String>,
24    // Feedback attributes
25    pub email_forwarding_enabled: bool,
26    // Mail-from attributes
27    pub mail_from_domain: Option<String>,
28    pub mail_from_behavior_on_mx_failure: String,
29    /// Real SES walks PENDING -> SUCCESS once it observes MX/TXT records.
30    /// Default `NotStarted`; set to `Pending` on first PutMailFromAttributes;
31    /// auto-advances to `Success` on next read or via admin endpoint.
32    #[serde(default)]
33    pub mail_from_domain_status: String,
34    // Configuration set association
35    pub configuration_set_name: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct EmailTemplate {
40    pub template_name: String,
41    pub subject: Option<String>,
42    pub html_body: Option<String>,
43    pub text_body: Option<String>,
44    pub created_at: DateTime<Utc>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ConfigurationSet {
49    pub name: String,
50    // Sending options
51    pub sending_enabled: bool,
52    // Delivery options
53    pub tls_policy: String,
54    pub sending_pool_name: Option<String>,
55    // Tracking options
56    pub custom_redirect_domain: Option<String>,
57    pub https_policy: Option<String>,
58    // Suppression options
59    pub suppressed_reasons: Vec<String>,
60    // Reputation options
61    pub reputation_metrics_enabled: bool,
62    // VDM options
63    pub vdm_options: Option<serde_json::Value>,
64    // Archiving options
65    pub archive_arn: Option<String>,
66    /// Tracks whether `ArchivingOptions` was set on the configuration set
67    /// (via Create or PutConfigurationSetArchivingOptions). AWS surfaces
68    /// the structure on GetConfigurationSet even when only `ArchiveArn`
69    /// is empty, so a missing field on Create round-trips would be a
70    /// silent input drop. Defaults to `false` for snapshots created
71    /// before the field existed.
72    #[serde(default)]
73    pub archiving_options_present: bool,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CustomVerificationEmailTemplate {
78    pub template_name: String,
79    pub from_email_address: String,
80    pub template_subject: String,
81    pub template_content: String,
82    pub success_redirection_url: String,
83    pub failure_redirection_url: String,
84    pub created_at: DateTime<Utc>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SentEmail {
89    pub message_id: String,
90    pub from: String,
91    pub to: Vec<String>,
92    pub cc: Vec<String>,
93    pub bcc: Vec<String>,
94    pub subject: Option<String>,
95    pub html_body: Option<String>,
96    pub text_body: Option<String>,
97    pub raw_data: Option<String>,
98    pub template_name: Option<String>,
99    pub template_data: Option<String>,
100    /// Computed `DKIM-Signature` header value when the sender's identity
101    /// has DKIM signing enabled. Empty string when sender unverified or
102    /// DKIM not configured.
103    #[serde(default)]
104    pub dkim_signature: Option<String>,
105    /// Synthesized RFC 5322-style headers for the stored message. When
106    /// DKIM signing is active the `DKIM-Signature` header is the first
107    /// entry, ahead of the `From`/`To`/`Subject`/`Date`/`Message-ID`
108    /// headers covered by the signature. Empty for messages stored
109    /// before DKIM was wired up.
110    #[serde(default)]
111    pub headers: Vec<(String, String)>,
112    pub timestamp: DateTime<Utc>,
113    /// Tags applied to the email at send time (EmailTags from v2 SendEmail).
114    #[serde(default)]
115    pub email_tags: Vec<(String, String)>,
116    /// Per-destination delivery insights populated by the event fanout.
117    #[serde(default)]
118    pub delivery_insights: Vec<EmailRecipientInsight>,
119}
120
121/// Per-recipient delivery insights for MessageInsights.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct EmailRecipientInsight {
124    pub destination: String,
125    pub isp: String,
126    pub events: Vec<DeliveryInsightEvent>,
127}
128
129/// A single event within an EmailRecipientInsight.
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
131pub struct DeliveryInsightEvent {
132    pub timestamp: DateTime<Utc>,
133    pub event_type: String,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub bounce_type: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub bounce_sub_type: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub diagnostic_code: Option<String>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub complaint_sub_type: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub complaint_feedback_type: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct SentBounce {
148    pub bounce_message_id: String,
149    pub original_message_id: String,
150    pub bounce_sender: String,
151    pub bounced_recipients: Vec<String>,
152    pub timestamp: DateTime<Utc>,
153    /// Per-recipient bounce details captured from
154    /// `BouncedRecipientInfoList`. Empty for bounces queued before the
155    /// field was added (preserved via `#[serde(default)]`).
156    #[serde(default)]
157    pub bounced_recipient_info: Vec<BouncedRecipientInfo>,
158    /// Optional explanation extracted from the `Explanation` parameter
159    /// of `SendBounce`.
160    #[serde(default)]
161    pub explanation: Option<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct BouncedRecipientInfo {
166    pub recipient: String,
167    pub bounce_type: String,
168    pub action: String,
169    pub status: String,
170    pub diagnostic_code: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ContactList {
175    pub contact_list_name: String,
176    pub description: Option<String>,
177    pub topics: Vec<Topic>,
178    pub created_at: DateTime<Utc>,
179    pub last_updated_at: DateTime<Utc>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Topic {
184    pub topic_name: String,
185    pub display_name: String,
186    pub description: String,
187    pub default_subscription_status: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct Contact {
192    pub email_address: String,
193    pub topic_preferences: Vec<TopicPreference>,
194    pub unsubscribe_all: bool,
195    pub attributes_data: Option<String>,
196    pub created_at: DateTime<Utc>,
197    pub last_updated_at: DateTime<Utc>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct TopicPreference {
202    pub topic_name: String,
203    pub subscription_status: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SuppressedDestination {
208    pub email_address: String,
209    pub reason: String,
210    pub last_update_time: DateTime<Utc>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct EventDestination {
215    pub name: String,
216    pub enabled: bool,
217    pub matching_event_types: Vec<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub kinesis_firehose_destination: Option<serde_json::Value>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub cloud_watch_destination: Option<serde_json::Value>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub sns_destination: Option<serde_json::Value>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub event_bridge_destination: Option<serde_json::Value>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub pinpoint_destination: Option<serde_json::Value>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct DedicatedIpPool {
232    pub pool_name: String,
233    pub scaling_mode: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct DedicatedIp {
238    pub ip: String,
239    pub warmup_status: String,
240    pub warmup_percentage: i32,
241    pub pool_name: String,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct MultiRegionEndpoint {
246    pub endpoint_name: String,
247    pub endpoint_id: String,
248    pub status: String,
249    pub regions: Vec<String>,
250    pub created_at: DateTime<Utc>,
251    pub last_updated_at: DateTime<Utc>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, Default)]
255pub struct AccountDetails {
256    pub mail_type: Option<String>,
257    pub website_url: Option<String>,
258    pub contact_language: Option<String>,
259    pub use_case_description: Option<String>,
260    pub additional_contact_email_addresses: Vec<String>,
261    pub production_access_enabled: Option<bool>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, Default)]
265pub struct AccountSettings {
266    pub sending_enabled: bool,
267    pub dedicated_ip_auto_warmup_enabled: bool,
268    pub suppressed_reasons: Vec<String>,
269    pub vdm_attributes: Option<serde_json::Value>,
270    pub details: Option<AccountDetails>,
271    /// Sandbox vs production. New SES accounts default to sandbox
272    /// (`false`), which gates SendEmail on having every recipient also
273    /// verified. Flipped via PutAccountDetails or the admin endpoint.
274    #[serde(default)]
275    pub production_access_enabled: bool,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct ImportJob {
280    pub job_id: String,
281    pub import_destination: serde_json::Value,
282    pub import_data_source: serde_json::Value,
283    pub job_status: String,
284    pub created_timestamp: DateTime<Utc>,
285    pub completed_timestamp: Option<DateTime<Utc>>,
286    pub processed_records_count: i32,
287    pub failed_records_count: i32,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct ExportJob {
292    pub job_id: String,
293    pub export_source_type: String,
294    pub export_destination: serde_json::Value,
295    pub export_data_source: serde_json::Value,
296    pub job_status: String,
297    pub created_timestamp: DateTime<Utc>,
298    pub completed_timestamp: Option<DateTime<Utc>>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct Tenant {
303    pub tenant_name: String,
304    pub tenant_id: String,
305    pub tenant_arn: String,
306    pub created_timestamp: DateTime<Utc>,
307    pub sending_status: String,
308    pub tags: Vec<serde_json::Value>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct TenantResourceAssociation {
313    pub resource_arn: String,
314    pub associated_timestamp: DateTime<Utc>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ReputationEntityState {
319    pub reputation_entity_reference: String,
320    pub reputation_entity_type: String,
321    pub reputation_management_policy: Option<String>,
322    pub customer_managed_status: String,
323    pub sending_status_aggregate: String,
324}
325
326// ── SES v1 Receipt Rule types ──
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct ReceiptRuleSet {
330    pub name: String,
331    pub rules: Vec<ReceiptRule>,
332    pub created_at: DateTime<Utc>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct ReceiptRule {
337    pub name: String,
338    pub enabled: bool,
339    pub scan_enabled: bool,
340    pub tls_policy: String,
341    pub recipients: Vec<String>,
342    pub actions: Vec<ReceiptAction>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub enum ReceiptAction {
347    S3 {
348        bucket_name: String,
349        object_key_prefix: Option<String>,
350        topic_arn: Option<String>,
351        kms_key_arn: Option<String>,
352    },
353    Sns {
354        topic_arn: String,
355        encoding: Option<String>,
356    },
357    Lambda {
358        function_arn: String,
359        invocation_type: Option<String>,
360        topic_arn: Option<String>,
361    },
362    Bounce {
363        smtp_reply_code: String,
364        message: String,
365        sender: String,
366        status_code: Option<String>,
367        topic_arn: Option<String>,
368    },
369    AddHeader {
370        header_name: String,
371        header_value: String,
372    },
373    Stop {
374        scope: String,
375        topic_arn: Option<String>,
376    },
377    Workmail {
378        organization_arn: String,
379        topic_arn: Option<String>,
380    },
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct ReceiptFilter {
385    pub name: String,
386    pub ip_filter: IpFilter,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct IpFilter {
391    pub cidr: String,
392    pub policy: String, // "Allow" or "Block"
393}
394
395/// One email accepted by the inbound SMTP listener
396/// (`ses_smtp.rs::store_email`). Captured alongside the
397/// matching `SentEmail` so tests can assert SMTP-specific facts
398/// (auth user, raw size) without re-deriving them from `raw_data`.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct SmtpSubmission {
401    pub message_id: String,
402    pub from: String,
403    pub to: Vec<String>,
404    pub subject: Option<String>,
405    pub raw_size_bytes: usize,
406    pub received_at: DateTime<Utc>,
407    pub auth_user: String,
408}
409
410/// One event-destination dispatch logged by the SES fanout. Captured
411/// every time `fanout::deliver_event` actually hands an event off to
412/// SNS/EventBridge/Kinesis/Firehose/CloudWatch so tests can assert the
413/// downstream wiring without scraping the target service's introspection
414/// state.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct EventDestinationDispatch {
417    pub destination_name: String,
418    /// One of `sns` | `eventbridge` | `kinesis` | `firehose` | `cloudwatch`.
419    pub destination_type: String,
420    pub event_type: String,
421    pub message_id: String,
422    pub dispatched_at: DateTime<Utc>,
423    /// ARN / target identifier of the downstream resource the event was
424    /// sent to. Empty for CloudWatch (uses metric namespace, not ARN).
425    pub target_arn: String,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct InboundEmail {
430    pub message_id: String,
431    pub from: String,
432    pub to: Vec<String>,
433    pub subject: String,
434    pub body: String,
435    pub matched_rules: Vec<String>,
436    pub actions_executed: Vec<String>,
437    pub timestamp: DateTime<Utc>,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SesState {
442    pub account_id: String,
443    pub region: String,
444    #[serde(default)]
445    pub identities: BTreeMap<String, EmailIdentity>,
446    #[serde(default)]
447    pub configuration_sets: BTreeMap<String, ConfigurationSet>,
448    #[serde(default)]
449    pub templates: BTreeMap<String, EmailTemplate>,
450    #[serde(default, skip_serializing)]
451    pub sent_emails: Vec<SentEmail>,
452    #[serde(default, skip_serializing)]
453    pub bounces: Vec<SentBounce>,
454    pub contact_lists: BTreeMap<String, ContactList>,
455    pub contacts: BTreeMap<String, BTreeMap<String, Contact>>,
456    /// Tags keyed by resource ARN, value is key→value tag map.
457    pub tags: BTreeMap<String, BTreeMap<String, String>>,
458    /// Suppression list: email → suppressed destination info.
459    pub suppressed_destinations: BTreeMap<String, SuppressedDestination>,
460    /// Event destinations: config set name → list of event destinations.
461    pub event_destinations: BTreeMap<String, Vec<EventDestination>>,
462    /// Identity policies: identity name → policy name → policy JSON document.
463    pub identity_policies: BTreeMap<String, BTreeMap<String, String>>,
464    /// Custom verification email templates: template name → template.
465    pub custom_verification_email_templates: BTreeMap<String, CustomVerificationEmailTemplate>,
466    /// Dedicated IP pools: pool name → pool.
467    pub dedicated_ip_pools: BTreeMap<String, DedicatedIpPool>,
468    /// Dedicated IPs: IP address → dedicated IP info.
469    pub dedicated_ips: BTreeMap<String, DedicatedIp>,
470    /// Multi-region endpoints: endpoint name → endpoint.
471    pub multi_region_endpoints: BTreeMap<String, MultiRegionEndpoint>,
472    /// Account-level settings (sending, suppression, VDM, details).
473    pub account_settings: AccountSettings,
474    /// Import jobs: job_id → ImportJob.
475    pub import_jobs: BTreeMap<String, ImportJob>,
476    /// Export jobs: job_id → ExportJob.
477    pub export_jobs: BTreeMap<String, ExportJob>,
478    /// Tenants: tenant_name → Tenant.
479    pub tenants: BTreeMap<String, Tenant>,
480    /// Tenant resource associations: tenant_name → Vec<resource_arn>.
481    pub tenant_resource_associations: BTreeMap<String, Vec<TenantResourceAssociation>>,
482    /// Reputation entities: "type/reference" → ReputationEntity.
483    pub reputation_entities: BTreeMap<String, ReputationEntityState>,
484    // ── SES v1 Receipt Rule state ──
485    /// Receipt rule sets: name → rule set.
486    pub receipt_rule_sets: BTreeMap<String, ReceiptRuleSet>,
487    /// Which rule set is active (by name).
488    pub active_receipt_rule_set: Option<String>,
489    /// Receipt filters: name → filter.
490    pub receipt_filters: BTreeMap<String, ReceiptFilter>,
491    /// Inbound emails processed by the introspection endpoint.
492    #[serde(default, skip_serializing)]
493    pub inbound_emails: Vec<InboundEmail>,
494    /// Emails accepted via the SMTP submission listener
495    /// (`FAKECLOUD_SES_SMTP_PORT`).
496    #[serde(default, skip_serializing)]
497    pub smtp_submissions: Vec<SmtpSubmission>,
498    /// Log of every event-destination dispatch performed by the SES
499    /// fanout. Used by the
500    /// `/_fakecloud/ses/event-destinations/deliveries` introspection
501    /// endpoint to prove kinesis/firehose/cloudwatch wiring works without
502    /// having to peek into the downstream services' state.
503    #[serde(default, skip_serializing)]
504    pub event_destination_dispatches: Vec<EventDestinationDispatch>,
505    /// Deliverability dashboard subscription state.
506    #[serde(default)]
507    pub deliverability_dashboard: DeliverabilityDashboard,
508    /// Deliverability test reports keyed by ReportId.
509    #[serde(default)]
510    pub deliverability_test_reports: BTreeMap<String, DeliverabilityTestReport>,
511    /// VDM recommendations (read-only, lazily seeded once on first read).
512    #[serde(default)]
513    pub vdm_recommendations: Vec<VdmRecommendation>,
514    /// Running count of recipients dropped because they were on the
515    /// suppression list (gated by the effective `SuppressedReasons`
516    /// filter). Surfaced through the introspection endpoint so tests can
517    /// assert the gate fired.
518    #[serde(default)]
519    pub suppressed_drops_total: u64,
520}
521
522#[derive(Debug, Clone, Default, Serialize, Deserialize)]
523pub struct DeliverabilityDashboard {
524    pub enabled: bool,
525    pub subscribed_domains: Vec<SubscribedDomain>,
526    pub subscription_expiry_date: Option<DateTime<Utc>>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct SubscribedDomain {
531    pub domain: String,
532    pub subscription_start_date: DateTime<Utc>,
533    pub inbox_placement_tracking_option_global: bool,
534    pub inbox_placement_tracking_option_tracked_isps: Vec<String>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct DeliverabilityTestReport {
539    pub report_id: String,
540    pub report_name: String,
541    pub subject: String,
542    pub from_email: String,
543    pub create_date: DateTime<Utc>,
544    pub deliverability_test_status: String, // IN_PROGRESS | COMPLETED
545    pub tags: Vec<(String, String)>,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct VdmRecommendation {
550    pub resource_arn: String,
551    pub recommendation_type: String,
552    pub description: String,
553    pub status: String,
554    pub created_timestamp: DateTime<Utc>,
555    pub last_updated_timestamp: DateTime<Utc>,
556    pub impact: String,
557}
558
559pub const SES_SNAPSHOT_SCHEMA_VERSION: u32 = 2;
560
561#[derive(Debug, Serialize, Deserialize)]
562pub struct SesSnapshot {
563    pub schema_version: u32,
564    #[serde(default)]
565    pub accounts: Option<fakecloud_core::multi_account::MultiAccountState<SesState>>,
566    #[serde(default)]
567    pub state: Option<SesState>,
568}
569
570impl SesState {
571    pub fn new(account_id: &str, region: &str) -> Self {
572        Self {
573            account_id: account_id.to_string(),
574            region: region.to_string(),
575            identities: BTreeMap::new(),
576            configuration_sets: BTreeMap::new(),
577            templates: BTreeMap::new(),
578            sent_emails: Vec::new(),
579            bounces: Vec::new(),
580            contact_lists: BTreeMap::new(),
581            contacts: BTreeMap::new(),
582            tags: BTreeMap::new(),
583            suppressed_destinations: BTreeMap::new(),
584            event_destinations: BTreeMap::new(),
585            identity_policies: BTreeMap::new(),
586            custom_verification_email_templates: BTreeMap::new(),
587            dedicated_ip_pools: BTreeMap::new(),
588            dedicated_ips: BTreeMap::new(),
589            multi_region_endpoints: BTreeMap::new(),
590            // production_access_enabled defaults to true: fakecloud is a
591            // testing tool, not real AWS, and most users want to send to
592            // arbitrary recipients without first jumping the sandbox-only
593            // verified-recipient gate. Users who want to test sandbox
594            // semantics can flip the flag back via the
595            // /_fakecloud/ses/account/sandbox admin endpoint.
596            account_settings: AccountSettings {
597                sending_enabled: true,
598                dedicated_ip_auto_warmup_enabled: false,
599                suppressed_reasons: Vec::new(),
600                vdm_attributes: None,
601                details: None,
602                production_access_enabled: true,
603            },
604            import_jobs: BTreeMap::new(),
605            export_jobs: BTreeMap::new(),
606            tenants: BTreeMap::new(),
607            tenant_resource_associations: BTreeMap::new(),
608            reputation_entities: BTreeMap::new(),
609            receipt_rule_sets: BTreeMap::new(),
610            active_receipt_rule_set: None,
611            receipt_filters: BTreeMap::new(),
612            inbound_emails: Vec::new(),
613            smtp_submissions: Vec::new(),
614            event_destination_dispatches: Vec::new(),
615            deliverability_dashboard: DeliverabilityDashboard::default(),
616            deliverability_test_reports: BTreeMap::new(),
617            vdm_recommendations: Vec::new(),
618            suppressed_drops_total: 0,
619        }
620    }
621
622    /// Reinitialize every field except ``account_id`` / ``region``.
623    pub fn reset(&mut self) {
624        let account_id = std::mem::take(&mut self.account_id);
625        let region = std::mem::take(&mut self.region);
626        *self = Self::new(&account_id, &region);
627    }
628
629    /// Effective `SuppressedReasons` for a send. Configuration-set scope
630    /// wins when populated; otherwise we fall back to the account-level
631    /// list. An empty list at both scopes is treated as "enforce both
632    /// reasons" (BOUNCE + COMPLAINT) — that matches the historical
633    /// fakecloud contract, and AWS callers who never call
634    /// PutAccountSuppressionAttributes still expect the suppression list
635    /// they explicitly populated to take effect.
636    pub fn effective_suppressed_reasons(&self, config_set_name: Option<&str>) -> Vec<String> {
637        if let Some(name) = config_set_name {
638            if let Some(cs) = self.configuration_sets.get(name) {
639                if !cs.suppressed_reasons.is_empty() {
640                    return cs.suppressed_reasons.clone();
641                }
642            }
643        }
644        if !self.account_settings.suppressed_reasons.is_empty() {
645            return self.account_settings.suppressed_reasons.clone();
646        }
647        vec!["BOUNCE".to_string(), "COMPLAINT".to_string()]
648    }
649
650    /// Look up `address` against the suppression list (case-insensitive,
651    /// trimmed). Returns the matching `SuppressedDestination` only when
652    /// the stored reason is enforced under the effective filter for the
653    /// supplied configuration-set scope.
654    pub fn suppressed_match(
655        &self,
656        address: &str,
657        config_set_name: Option<&str>,
658    ) -> Option<&SuppressedDestination> {
659        let key = address.trim().to_ascii_lowercase();
660        let entry = self.suppressed_destinations.iter().find_map(|(k, v)| {
661            if k.trim().eq_ignore_ascii_case(&key) {
662                Some(v)
663            } else {
664                None
665            }
666        })?;
667        let reasons = self.effective_suppressed_reasons(config_set_name);
668        if reasons.iter().any(|r| r == &entry.reason) {
669            Some(entry)
670        } else {
671            None
672        }
673    }
674}
675
676pub type SharedSesState = Arc<RwLock<fakecloud_core::multi_account::MultiAccountState<SesState>>>;
677
678impl fakecloud_core::multi_account::AccountState for SesState {
679    fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
680        Self::new(account_id, region)
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn new_initializes_defaults() {
690        let state = SesState::new("123456789012", "us-east-1");
691        assert_eq!(state.account_id, "123456789012");
692        assert_eq!(state.region, "us-east-1");
693        assert!(state.identities.is_empty());
694        assert!(state.configuration_sets.is_empty());
695        assert!(state.account_settings.sending_enabled);
696    }
697
698    #[test]
699    fn new_initializes_introspection_buffers_empty() {
700        let state = SesState::new("123456789012", "us-east-1");
701        assert!(state.smtp_submissions.is_empty());
702        assert!(state.event_destination_dispatches.is_empty());
703    }
704
705    #[test]
706    fn smtp_submission_round_trips_through_state() {
707        let mut state = SesState::new("123456789012", "us-east-1");
708        state.smtp_submissions.push(SmtpSubmission {
709            message_id: "smtp-1".to_string(),
710            from: "src@example.com".to_string(),
711            to: vec!["dst@example.com".to_string()],
712            subject: Some("hi".to_string()),
713            raw_size_bytes: 42,
714            received_at: Utc::now(),
715            auth_user: "user".to_string(),
716        });
717        assert_eq!(state.smtp_submissions.len(), 1);
718        assert_eq!(state.smtp_submissions[0].auth_user, "user");
719        state.reset();
720        assert!(state.smtp_submissions.is_empty());
721    }
722
723    #[test]
724    fn event_destination_dispatch_round_trips() {
725        let mut state = SesState::new("123456789012", "us-east-1");
726        state
727            .event_destination_dispatches
728            .push(EventDestinationDispatch {
729                destination_name: "fh".to_string(),
730                destination_type: "firehose".to_string(),
731                event_type: "SEND".to_string(),
732                message_id: "msg-1".to_string(),
733                dispatched_at: Utc::now(),
734                target_arn: "arn:aws:firehose:us-east-1:123456789012:deliverystream/ds1"
735                    .to_string(),
736            });
737        assert_eq!(state.event_destination_dispatches.len(), 1);
738        assert_eq!(
739            state.event_destination_dispatches[0].destination_type,
740            "firehose"
741        );
742    }
743
744    #[test]
745    fn reset_preserves_account_region() {
746        let mut state = SesState::new("123456789012", "eu-west-1");
747        state.account_settings.sending_enabled = false;
748        state.reset();
749        assert_eq!(state.account_id, "123456789012");
750        assert_eq!(state.region, "eu-west-1");
751        assert!(state.account_settings.sending_enabled);
752    }
753}