fakecloud-ses 0.25.0

SES implementation for FakeCloud (v2 REST + v1 inbound Query)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailIdentity {
    pub identity_name: String,
    pub identity_type: String,
    pub verified: bool,
    pub created_at: DateTime<Utc>,
    // DKIM attributes
    pub dkim_signing_enabled: bool,
    pub dkim_signing_attributes_origin: String,
    pub dkim_domain_signing_private_key: Option<String>,
    pub dkim_domain_signing_selector: Option<String>,
    pub dkim_next_signing_key_length: Option<String>,
    /// SubjectPublicKeyInfo DER, base64-encoded. Populated when Easy DKIM
    /// generates the keypair. BYODKIM imports leave this empty (the user
    /// publishes their own public record).
    #[serde(default)]
    pub dkim_public_key_b64: Option<String>,
    // Feedback attributes
    pub email_forwarding_enabled: bool,
    // Mail-from attributes
    pub mail_from_domain: Option<String>,
    pub mail_from_behavior_on_mx_failure: String,
    /// Real SES walks PENDING -> SUCCESS once it observes MX/TXT records.
    /// Default `NotStarted`; set to `Pending` on first PutMailFromAttributes;
    /// auto-advances to `Success` on next read or via admin endpoint.
    #[serde(default)]
    pub mail_from_domain_status: String,
    // Configuration set association
    pub configuration_set_name: Option<String>,
    // SNS notification topics per type, set by SetIdentityNotificationTopic and
    // surfaced by GetIdentityNotificationAttributes (bug-audit 2026-06-20, 1.19).
    #[serde(default)]
    pub bounce_topic: Option<String>,
    #[serde(default)]
    pub complaint_topic: Option<String>,
    #[serde(default)]
    pub delivery_topic: Option<String>,
    /// Domain-verification TXT token. Deterministic per identity and stored so
    /// VerifyDomainIdentity and GetIdentityVerificationAttributes report the
    /// same value (the Terraform data source asserts they match).
    #[serde(default)]
    pub verification_token: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailTemplate {
    pub template_name: String,
    pub subject: Option<String>,
    pub html_body: Option<String>,
    pub text_body: Option<String>,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigurationSet {
    pub name: String,
    // Sending options
    pub sending_enabled: bool,
    // Delivery options
    pub tls_policy: String,
    pub sending_pool_name: Option<String>,
    /// Max seconds SES retries delivery before giving up. Optional; only
    /// reported on GetConfigurationSet when explicitly configured.
    #[serde(default)]
    pub max_delivery_seconds: Option<i64>,
    // Tracking options
    pub custom_redirect_domain: Option<String>,
    pub https_policy: Option<String>,
    // Suppression options
    pub suppressed_reasons: Vec<String>,
    // Reputation options
    pub reputation_metrics_enabled: bool,
    // VDM options
    pub vdm_options: Option<serde_json::Value>,
    // Archiving options
    pub archive_arn: Option<String>,
    /// Tracks whether `ArchivingOptions` was set on the configuration set
    /// (via Create or PutConfigurationSetArchivingOptions). AWS surfaces
    /// the structure on GetConfigurationSet even when only `ArchiveArn`
    /// is empty, so a missing field on Create round-trips would be a
    /// silent input drop. Defaults to `false` for snapshots created
    /// before the field existed.
    #[serde(default)]
    pub archiving_options_present: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomVerificationEmailTemplate {
    pub template_name: String,
    pub from_email_address: String,
    pub template_subject: String,
    pub template_content: String,
    pub success_redirection_url: String,
    pub failure_redirection_url: String,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentEmail {
    pub message_id: String,
    pub from: String,
    pub to: Vec<String>,
    pub cc: Vec<String>,
    pub bcc: Vec<String>,
    pub subject: Option<String>,
    pub html_body: Option<String>,
    pub text_body: Option<String>,
    pub raw_data: Option<String>,
    pub template_name: Option<String>,
    pub template_data: Option<String>,
    /// Computed `DKIM-Signature` header value when the sender's identity
    /// has DKIM signing enabled. Empty string when sender unverified or
    /// DKIM not configured.
    #[serde(default)]
    pub dkim_signature: Option<String>,
    /// Synthesized RFC 5322-style headers for the stored message. When
    /// DKIM signing is active the `DKIM-Signature` header is the first
    /// entry, ahead of the `From`/`To`/`Subject`/`Date`/`Message-ID`
    /// headers covered by the signature. Empty for messages stored
    /// before DKIM was wired up.
    #[serde(default)]
    pub headers: Vec<(String, String)>,
    pub timestamp: DateTime<Utc>,
    /// Tags applied to the email at send time (EmailTags from v2 SendEmail).
    #[serde(default)]
    pub email_tags: Vec<(String, String)>,
    /// Per-destination delivery insights populated by the event fanout.
    #[serde(default)]
    pub delivery_insights: Vec<EmailRecipientInsight>,
}

/// Per-recipient delivery insights for MessageInsights.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailRecipientInsight {
    pub destination: String,
    pub isp: String,
    pub events: Vec<DeliveryInsightEvent>,
}

/// A single event within an EmailRecipientInsight.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeliveryInsightEvent {
    pub timestamp: DateTime<Utc>,
    pub event_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bounce_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bounce_sub_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub diagnostic_code: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub complaint_sub_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub complaint_feedback_type: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentBounce {
    pub bounce_message_id: String,
    pub original_message_id: String,
    pub bounce_sender: String,
    pub bounced_recipients: Vec<String>,
    pub timestamp: DateTime<Utc>,
    /// Per-recipient bounce details captured from
    /// `BouncedRecipientInfoList`. Empty for bounces queued before the
    /// field was added (preserved via `#[serde(default)]`).
    #[serde(default)]
    pub bounced_recipient_info: Vec<BouncedRecipientInfo>,
    /// Optional explanation extracted from the `Explanation` parameter
    /// of `SendBounce`.
    #[serde(default)]
    pub explanation: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BouncedRecipientInfo {
    pub recipient: String,
    pub bounce_type: String,
    pub action: String,
    pub status: String,
    pub diagnostic_code: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactList {
    pub contact_list_name: String,
    pub description: Option<String>,
    pub topics: Vec<Topic>,
    pub created_at: DateTime<Utc>,
    pub last_updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Topic {
    pub topic_name: String,
    pub display_name: String,
    pub description: String,
    pub default_subscription_status: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
    pub email_address: String,
    pub topic_preferences: Vec<TopicPreference>,
    pub unsubscribe_all: bool,
    pub attributes_data: Option<String>,
    pub created_at: DateTime<Utc>,
    pub last_updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicPreference {
    pub topic_name: String,
    pub subscription_status: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuppressedDestination {
    pub email_address: String,
    pub reason: String,
    pub last_update_time: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventDestination {
    pub name: String,
    pub enabled: bool,
    pub matching_event_types: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kinesis_firehose_destination: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cloud_watch_destination: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sns_destination: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub event_bridge_destination: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pinpoint_destination: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DedicatedIpPool {
    pub pool_name: String,
    pub scaling_mode: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DedicatedIp {
    pub ip: String,
    pub warmup_status: String,
    pub warmup_percentage: i32,
    pub pool_name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiRegionEndpoint {
    pub endpoint_name: String,
    pub endpoint_id: String,
    pub status: String,
    pub regions: Vec<String>,
    pub created_at: DateTime<Utc>,
    pub last_updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountDetails {
    pub mail_type: Option<String>,
    pub website_url: Option<String>,
    pub contact_language: Option<String>,
    pub use_case_description: Option<String>,
    pub additional_contact_email_addresses: Vec<String>,
    pub production_access_enabled: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountSettings {
    pub sending_enabled: bool,
    pub dedicated_ip_auto_warmup_enabled: bool,
    pub suppressed_reasons: Vec<String>,
    pub vdm_attributes: Option<serde_json::Value>,
    pub details: Option<AccountDetails>,
    /// Sandbox vs production. New SES accounts default to sandbox
    /// (`false`), which gates SendEmail on having every recipient also
    /// verified. Flipped via PutAccountDetails or the admin endpoint.
    #[serde(default)]
    pub production_access_enabled: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportJob {
    pub job_id: String,
    pub import_destination: serde_json::Value,
    pub import_data_source: serde_json::Value,
    pub job_status: String,
    pub created_timestamp: DateTime<Utc>,
    pub completed_timestamp: Option<DateTime<Utc>>,
    pub processed_records_count: i32,
    pub failed_records_count: i32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportJob {
    pub job_id: String,
    pub export_source_type: String,
    pub export_destination: serde_json::Value,
    pub export_data_source: serde_json::Value,
    pub job_status: String,
    pub created_timestamp: DateTime<Utc>,
    pub completed_timestamp: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tenant {
    pub tenant_name: String,
    pub tenant_id: String,
    pub tenant_arn: String,
    pub created_timestamp: DateTime<Utc>,
    pub sending_status: String,
    pub tags: Vec<serde_json::Value>,
    /// Suppression-list preferences for the tenant: `{ SuppressedReasons,
    /// ValidationAttributes }`. Set on CreateTenant or via
    /// PutTenantSuppressionAttributes. `None` until configured.
    #[serde(default)]
    pub suppression_attributes: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantResourceAssociation {
    pub resource_arn: String,
    pub associated_timestamp: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReputationEntityState {
    pub reputation_entity_reference: String,
    pub reputation_entity_type: String,
    pub reputation_management_policy: Option<String>,
    pub customer_managed_status: String,
    pub sending_status_aggregate: String,
}

// ── SES v1 Receipt Rule types ──

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptRuleSet {
    pub name: String,
    pub rules: Vec<ReceiptRule>,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptRule {
    pub name: String,
    pub enabled: bool,
    pub scan_enabled: bool,
    pub tls_policy: String,
    pub recipients: Vec<String>,
    pub actions: Vec<ReceiptAction>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReceiptAction {
    S3 {
        bucket_name: String,
        object_key_prefix: Option<String>,
        topic_arn: Option<String>,
        kms_key_arn: Option<String>,
    },
    Sns {
        topic_arn: String,
        encoding: Option<String>,
    },
    Lambda {
        function_arn: String,
        invocation_type: Option<String>,
        topic_arn: Option<String>,
    },
    Bounce {
        smtp_reply_code: String,
        message: String,
        sender: String,
        status_code: Option<String>,
        topic_arn: Option<String>,
    },
    AddHeader {
        header_name: String,
        header_value: String,
    },
    Stop {
        scope: String,
        topic_arn: Option<String>,
    },
    Workmail {
        organization_arn: String,
        topic_arn: Option<String>,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptFilter {
    pub name: String,
    pub ip_filter: IpFilter,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpFilter {
    pub cidr: String,
    pub policy: String, // "Allow" or "Block"
}

/// One email accepted by the inbound SMTP listener
/// (`ses_smtp.rs::store_email`). Captured alongside the
/// matching `SentEmail` so tests can assert SMTP-specific facts
/// (auth user, raw size) without re-deriving them from `raw_data`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpSubmission {
    pub message_id: String,
    pub from: String,
    pub to: Vec<String>,
    pub subject: Option<String>,
    pub raw_size_bytes: usize,
    pub received_at: DateTime<Utc>,
    pub auth_user: String,
}

/// One event-destination dispatch logged by the SES fanout. Captured
/// every time `fanout::deliver_event` actually hands an event off to
/// SNS/EventBridge/Kinesis/Firehose/CloudWatch so tests can assert the
/// downstream wiring without scraping the target service's introspection
/// state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventDestinationDispatch {
    pub destination_name: String,
    /// One of `sns` | `eventbridge` | `kinesis` | `firehose` | `cloudwatch`.
    pub destination_type: String,
    pub event_type: String,
    pub message_id: String,
    pub dispatched_at: DateTime<Utc>,
    /// ARN / target identifier of the downstream resource the event was
    /// sent to. Empty for CloudWatch (uses metric namespace, not ARN).
    pub target_arn: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboundEmail {
    pub message_id: String,
    pub from: String,
    pub to: Vec<String>,
    pub subject: String,
    pub body: String,
    pub matched_rules: Vec<String>,
    pub actions_executed: Vec<String>,
    pub timestamp: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SesState {
    pub account_id: String,
    pub region: String,
    #[serde(default)]
    pub identities: BTreeMap<String, EmailIdentity>,
    #[serde(default)]
    pub configuration_sets: BTreeMap<String, ConfigurationSet>,
    #[serde(default)]
    pub templates: BTreeMap<String, EmailTemplate>,
    #[serde(default, skip_serializing)]
    pub sent_emails: Vec<SentEmail>,
    #[serde(default, skip_serializing)]
    pub bounces: Vec<SentBounce>,
    pub contact_lists: BTreeMap<String, ContactList>,
    pub contacts: BTreeMap<String, BTreeMap<String, Contact>>,
    /// Tags keyed by resource ARN, value is key→value tag map.
    pub tags: BTreeMap<String, BTreeMap<String, String>>,
    /// Suppression list: email → suppressed destination info.
    pub suppressed_destinations: BTreeMap<String, SuppressedDestination>,
    /// Event destinations: config set name → list of event destinations.
    pub event_destinations: BTreeMap<String, Vec<EventDestination>>,
    /// Identity policies: identity name → policy name → policy JSON document.
    pub identity_policies: BTreeMap<String, BTreeMap<String, String>>,
    /// Custom verification email templates: template name → template.
    pub custom_verification_email_templates: BTreeMap<String, CustomVerificationEmailTemplate>,
    /// Dedicated IP pools: pool name → pool.
    pub dedicated_ip_pools: BTreeMap<String, DedicatedIpPool>,
    /// Dedicated IPs: IP address → dedicated IP info.
    pub dedicated_ips: BTreeMap<String, DedicatedIp>,
    /// Multi-region endpoints: endpoint name → endpoint.
    pub multi_region_endpoints: BTreeMap<String, MultiRegionEndpoint>,
    /// Account-level settings (sending, suppression, VDM, details).
    pub account_settings: AccountSettings,
    /// Import jobs: job_id → ImportJob.
    pub import_jobs: BTreeMap<String, ImportJob>,
    /// Export jobs: job_id → ExportJob.
    pub export_jobs: BTreeMap<String, ExportJob>,
    /// Tenants: tenant_name → Tenant.
    pub tenants: BTreeMap<String, Tenant>,
    /// Tenant resource associations: tenant_name → Vec<resource_arn>.
    pub tenant_resource_associations: BTreeMap<String, Vec<TenantResourceAssociation>>,
    /// Reputation entities: "type/reference" → ReputationEntity.
    pub reputation_entities: BTreeMap<String, ReputationEntityState>,
    // ── SES v1 Receipt Rule state ──
    /// Receipt rule sets: name → rule set.
    pub receipt_rule_sets: BTreeMap<String, ReceiptRuleSet>,
    /// Which rule set is active (by name).
    pub active_receipt_rule_set: Option<String>,
    /// Receipt filters: name → filter.
    pub receipt_filters: BTreeMap<String, ReceiptFilter>,
    /// Inbound emails processed by the introspection endpoint.
    #[serde(default, skip_serializing)]
    pub inbound_emails: Vec<InboundEmail>,
    /// Emails accepted via the SMTP submission listener
    /// (`FAKECLOUD_SES_SMTP_PORT`).
    #[serde(default, skip_serializing)]
    pub smtp_submissions: Vec<SmtpSubmission>,
    /// Log of every event-destination dispatch performed by the SES
    /// fanout. Used by the
    /// `/_fakecloud/ses/event-destinations/deliveries` introspection
    /// endpoint to prove kinesis/firehose/cloudwatch wiring works without
    /// having to peek into the downstream services' state.
    #[serde(default, skip_serializing)]
    pub event_destination_dispatches: Vec<EventDestinationDispatch>,
    /// Deliverability dashboard subscription state.
    #[serde(default)]
    pub deliverability_dashboard: DeliverabilityDashboard,
    /// Deliverability test reports keyed by ReportId.
    #[serde(default)]
    pub deliverability_test_reports: BTreeMap<String, DeliverabilityTestReport>,
    /// VDM recommendations (read-only, lazily seeded once on first read).
    #[serde(default)]
    pub vdm_recommendations: Vec<VdmRecommendation>,
    /// Running count of recipients dropped because they were on the
    /// suppression list (gated by the effective `SuppressedReasons`
    /// filter). Surfaced through the introspection endpoint so tests can
    /// assert the gate fired.
    #[serde(default)]
    pub suppressed_drops_total: u64,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeliverabilityDashboard {
    pub enabled: bool,
    pub subscribed_domains: Vec<SubscribedDomain>,
    pub subscription_expiry_date: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscribedDomain {
    pub domain: String,
    pub subscription_start_date: DateTime<Utc>,
    pub inbox_placement_tracking_option_global: bool,
    pub inbox_placement_tracking_option_tracked_isps: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliverabilityTestReport {
    pub report_id: String,
    pub report_name: String,
    pub subject: String,
    pub from_email: String,
    pub create_date: DateTime<Utc>,
    pub deliverability_test_status: String, // IN_PROGRESS | COMPLETED
    pub tags: Vec<(String, String)>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VdmRecommendation {
    pub resource_arn: String,
    pub recommendation_type: String,
    pub description: String,
    pub status: String,
    pub created_timestamp: DateTime<Utc>,
    pub last_updated_timestamp: DateTime<Utc>,
    pub impact: String,
}

pub const SES_SNAPSHOT_SCHEMA_VERSION: u32 = 2;

#[derive(Debug, Serialize, Deserialize)]
pub struct SesSnapshot {
    pub schema_version: u32,
    #[serde(default)]
    pub accounts: Option<fakecloud_core::multi_account::MultiAccountState<SesState>>,
    #[serde(default)]
    pub state: Option<SesState>,
}

impl SesState {
    pub fn new(account_id: &str, region: &str) -> Self {
        Self {
            account_id: account_id.to_string(),
            region: region.to_string(),
            identities: BTreeMap::new(),
            configuration_sets: BTreeMap::new(),
            templates: BTreeMap::new(),
            sent_emails: Vec::new(),
            bounces: Vec::new(),
            contact_lists: BTreeMap::new(),
            contacts: BTreeMap::new(),
            tags: BTreeMap::new(),
            suppressed_destinations: BTreeMap::new(),
            event_destinations: BTreeMap::new(),
            identity_policies: BTreeMap::new(),
            custom_verification_email_templates: BTreeMap::new(),
            dedicated_ip_pools: BTreeMap::new(),
            dedicated_ips: BTreeMap::new(),
            multi_region_endpoints: BTreeMap::new(),
            // production_access_enabled defaults to true: fakecloud is a
            // testing tool, not real AWS, and most users want to send to
            // arbitrary recipients without first jumping the sandbox-only
            // verified-recipient gate. Users who want to test sandbox
            // semantics can flip the flag back via the
            // /_fakecloud/ses/account/sandbox admin endpoint.
            account_settings: AccountSettings {
                sending_enabled: true,
                dedicated_ip_auto_warmup_enabled: false,
                suppressed_reasons: Vec::new(),
                vdm_attributes: None,
                details: None,
                production_access_enabled: true,
            },
            import_jobs: BTreeMap::new(),
            export_jobs: BTreeMap::new(),
            tenants: BTreeMap::new(),
            tenant_resource_associations: BTreeMap::new(),
            reputation_entities: BTreeMap::new(),
            receipt_rule_sets: BTreeMap::new(),
            active_receipt_rule_set: None,
            receipt_filters: BTreeMap::new(),
            inbound_emails: Vec::new(),
            smtp_submissions: Vec::new(),
            event_destination_dispatches: Vec::new(),
            deliverability_dashboard: DeliverabilityDashboard::default(),
            deliverability_test_reports: BTreeMap::new(),
            vdm_recommendations: Vec::new(),
            suppressed_drops_total: 0,
        }
    }

    /// Reinitialize every field except ``account_id`` / ``region``.
    pub fn reset(&mut self) {
        let account_id = std::mem::take(&mut self.account_id);
        let region = std::mem::take(&mut self.region);
        *self = Self::new(&account_id, &region);
    }

    /// Effective `SuppressedReasons` for a send. Configuration-set scope
    /// wins when populated; otherwise we fall back to the account-level
    /// list. An empty list at both scopes is treated as "enforce both
    /// reasons" (BOUNCE + COMPLAINT) — that matches the historical
    /// fakecloud contract, and AWS callers who never call
    /// PutAccountSuppressionAttributes still expect the suppression list
    /// they explicitly populated to take effect.
    pub fn effective_suppressed_reasons(&self, config_set_name: Option<&str>) -> Vec<String> {
        if let Some(name) = config_set_name {
            if let Some(cs) = self.configuration_sets.get(name) {
                if !cs.suppressed_reasons.is_empty() {
                    return cs.suppressed_reasons.clone();
                }
            }
        }
        if !self.account_settings.suppressed_reasons.is_empty() {
            return self.account_settings.suppressed_reasons.clone();
        }
        vec!["BOUNCE".to_string(), "COMPLAINT".to_string()]
    }

    /// Look up `address` against the suppression list (case-insensitive,
    /// trimmed). Returns the matching `SuppressedDestination` only when
    /// the stored reason is enforced under the effective filter for the
    /// supplied configuration-set scope.
    pub fn suppressed_match(
        &self,
        address: &str,
        config_set_name: Option<&str>,
    ) -> Option<&SuppressedDestination> {
        let key = address.trim().to_ascii_lowercase();
        let entry = self.suppressed_destinations.iter().find_map(|(k, v)| {
            if k.trim().eq_ignore_ascii_case(&key) {
                Some(v)
            } else {
                None
            }
        })?;
        let reasons = self.effective_suppressed_reasons(config_set_name);
        if reasons.iter().any(|r| r == &entry.reason) {
            Some(entry)
        } else {
            None
        }
    }
}

pub type SharedSesState = Arc<RwLock<fakecloud_core::multi_account::MultiAccountState<SesState>>>;

impl fakecloud_core::multi_account::AccountState for SesState {
    fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
        Self::new(account_id, region)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_initializes_defaults() {
        let state = SesState::new("123456789012", "us-east-1");
        assert_eq!(state.account_id, "123456789012");
        assert_eq!(state.region, "us-east-1");
        assert!(state.identities.is_empty());
        assert!(state.configuration_sets.is_empty());
        assert!(state.account_settings.sending_enabled);
    }

    #[test]
    fn new_initializes_introspection_buffers_empty() {
        let state = SesState::new("123456789012", "us-east-1");
        assert!(state.smtp_submissions.is_empty());
        assert!(state.event_destination_dispatches.is_empty());
    }

    #[test]
    fn smtp_submission_round_trips_through_state() {
        let mut state = SesState::new("123456789012", "us-east-1");
        state.smtp_submissions.push(SmtpSubmission {
            message_id: "smtp-1".to_string(),
            from: "src@example.com".to_string(),
            to: vec!["dst@example.com".to_string()],
            subject: Some("hi".to_string()),
            raw_size_bytes: 42,
            received_at: Utc::now(),
            auth_user: "user".to_string(),
        });
        assert_eq!(state.smtp_submissions.len(), 1);
        assert_eq!(state.smtp_submissions[0].auth_user, "user");
        state.reset();
        assert!(state.smtp_submissions.is_empty());
    }

    #[test]
    fn event_destination_dispatch_round_trips() {
        let mut state = SesState::new("123456789012", "us-east-1");
        state
            .event_destination_dispatches
            .push(EventDestinationDispatch {
                destination_name: "fh".to_string(),
                destination_type: "firehose".to_string(),
                event_type: "SEND".to_string(),
                message_id: "msg-1".to_string(),
                dispatched_at: Utc::now(),
                target_arn: "arn:aws:firehose:us-east-1:123456789012:deliverystream/ds1"
                    .to_string(),
            });
        assert_eq!(state.event_destination_dispatches.len(), 1);
        assert_eq!(
            state.event_destination_dispatches[0].destination_type,
            "firehose"
        );
    }

    #[test]
    fn reset_preserves_account_region() {
        let mut state = SesState::new("123456789012", "eu-west-1");
        state.account_settings.sending_enabled = false;
        state.reset();
        assert_eq!(state.account_id, "123456789012");
        assert_eq!(state.region, "eu-west-1");
        assert!(state.account_settings.sending_enabled);
    }
}