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