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