Skip to main content

fakecloud_ses/service/
mod.rs

1mod account;
2mod configuration_sets;
3mod contact_lists;
4mod identities;
5mod misc;
6mod sending;
7mod suppression;
8pub(crate) mod templates;
9
10use async_trait::async_trait;
11use http::{Method, StatusCode};
12use serde_json::{json, Value};
13use std::sync::Arc;
14use tokio::sync::Mutex as AsyncMutex;
15
16use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
17use fakecloud_persistence::SnapshotStore;
18
19use crate::fanout::SesDeliveryContext;
20use crate::state::{
21    EventDestination, SesSnapshot, SharedSesState, Topic, TopicPreference,
22    SES_SNAPSHOT_SCHEMA_VERSION,
23};
24
25pub struct SesV2Service {
26    state: SharedSesState,
27    delivery_ctx: Option<SesDeliveryContext>,
28    snapshot_store: Option<Arc<dyn SnapshotStore>>,
29    snapshot_lock: Arc<AsyncMutex<()>>,
30}
31
32impl SesV2Service {
33    pub fn new(state: SharedSesState) -> Self {
34        Self {
35            state,
36            delivery_ctx: None,
37            snapshot_store: None,
38            snapshot_lock: Arc::new(AsyncMutex::new(())),
39        }
40    }
41
42    /// Attach a delivery context for cross-service event fanout.
43    pub fn with_delivery(mut self, ctx: SesDeliveryContext) -> Self {
44        self.delivery_ctx = Some(ctx);
45        self
46    }
47
48    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
49        self.snapshot_store = Some(store);
50        self
51    }
52
53    /// Persist current state as a snapshot. Held across the
54    /// clone-serialize-write sequence to prevent stale-last writes,
55    /// with serde + file I/O offloaded to the blocking pool.
56    async fn save_snapshot(&self) {
57        save_ses_snapshot(
58            &self.state,
59            self.snapshot_store.clone(),
60            &self.snapshot_lock,
61        )
62        .await;
63    }
64
65    /// Build a hook that persists the current SES state when invoked, or
66    /// `None` in memory mode (no snapshot store). The CloudFormation provisioner
67    /// mutates `state` directly and uses this to write a CFN-provisioned
68    /// resource through to disk, the same way a direct mutating API call would.
69    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
70        let store = self.snapshot_store.clone()?;
71        let state = self.state.clone();
72        let lock = self.snapshot_lock.clone();
73        Some(Arc::new(move || {
74            let state = state.clone();
75            let store = store.clone();
76            let lock = lock.clone();
77            Box::pin(async move {
78                save_ses_snapshot(&state, Some(store), &lock).await;
79            })
80        }))
81    }
82
83    /// Determine the action from the HTTP method and path segments.
84    /// SES v2 uses REST-style routing with base path /v2/email/:
85    ///   GET    /v2/email/account                         -> GetAccount
86    ///   POST   /v2/email/identities                      -> CreateEmailIdentity
87    ///   GET    /v2/email/identities                      -> ListEmailIdentities
88    ///   GET    /v2/email/identities/{id}                 -> GetEmailIdentity
89    ///   DELETE /v2/email/identities/{id}                 -> DeleteEmailIdentity
90    ///   POST   /v2/email/configuration-sets              -> CreateConfigurationSet
91    ///   GET    /v2/email/configuration-sets              -> ListConfigurationSets
92    ///   GET    /v2/email/configuration-sets/{name}       -> GetConfigurationSet
93    ///   DELETE /v2/email/configuration-sets/{name}       -> DeleteConfigurationSet
94    ///   POST   /v2/email/templates                       -> CreateEmailTemplate
95    ///   GET    /v2/email/templates                       -> ListEmailTemplates
96    ///   GET    /v2/email/templates/{name}                -> GetEmailTemplate
97    ///   PUT    /v2/email/templates/{name}                -> UpdateEmailTemplate
98    ///   DELETE /v2/email/templates/{name}                -> DeleteEmailTemplate
99    ///   POST   /v2/email/outbound-emails                 -> SendEmail
100    ///   POST   /v2/email/outbound-bulk-emails            -> SendBulkEmail
101    ///   POST   /v2/email/tags                            -> TagResource
102    ///   DELETE /v2/email/tags                            -> UntagResource
103    ///   GET    /v2/email/tags                            -> ListTagsForResource
104    ///   POST   /v2/email/contact-lists                   -> CreateContactList
105    ///   GET    /v2/email/contact-lists                   -> ListContactLists
106    ///   GET    /v2/email/contact-lists/{name}            -> GetContactList
107    ///   PUT    /v2/email/contact-lists/{name}            -> UpdateContactList
108    ///   DELETE /v2/email/contact-lists/{name}            -> DeleteContactList
109    ///   POST   /v2/email/contact-lists/{name}/contacts   -> CreateContact
110    ///   GET    /v2/email/contact-lists/{name}/contacts   -> ListContacts
111    ///   GET    /v2/email/contact-lists/{name}/contacts/{email} -> GetContact
112    ///   PUT    /v2/email/contact-lists/{name}/contacts/{email} -> UpdateContact
113    ///   DELETE /v2/email/contact-lists/{name}/contacts/{email} -> DeleteContact
114    ///   PUT    /v2/email/suppression/addresses            -> PutSuppressedDestination
115    ///   GET    /v2/email/suppression/addresses            -> ListSuppressedDestinations
116    ///   GET    /v2/email/suppression/addresses/{email}    -> GetSuppressedDestination
117    ///   DELETE /v2/email/suppression/addresses/{email}    -> DeleteSuppressedDestination
118    ///   POST   /v2/email/configuration-sets/{name}/event-destinations -> CreateConfigurationSetEventDestination
119    ///   GET    /v2/email/configuration-sets/{name}/event-destinations -> GetConfigurationSetEventDestinations
120    ///   PUT    /v2/email/configuration-sets/{name}/event-destinations/{dest} -> UpdateConfigurationSetEventDestination
121    ///   DELETE /v2/email/configuration-sets/{name}/event-destinations/{dest} -> DeleteConfigurationSetEventDestination
122    ///   POST   /v2/email/identities/{id}/policies/{policy} -> CreateEmailIdentityPolicy
123    ///   GET    /v2/email/identities/{id}/policies         -> GetEmailIdentityPolicies
124    ///   PUT    /v2/email/identities/{id}/policies/{policy} -> UpdateEmailIdentityPolicy
125    ///   DELETE /v2/email/identities/{id}/policies/{policy} -> DeleteEmailIdentityPolicy
126    ///   PUT    /v2/email/identities/{id}/dkim              -> PutEmailIdentityDkimAttributes
127    ///   PUT    /v2/email/identities/{id}/dkim/signing      -> PutEmailIdentityDkimSigningAttributes
128    ///   PUT    /v2/email/identities/{id}/feedback          -> PutEmailIdentityFeedbackAttributes
129    ///   PUT    /v2/email/identities/{id}/mail-from         -> PutEmailIdentityMailFromAttributes
130    ///   PUT    /v2/email/identities/{id}/configuration-set -> PutEmailIdentityConfigurationSetAttributes
131    ///   PUT    /v2/email/configuration-sets/{name}/sending             -> PutConfigurationSetSendingOptions
132    ///   PUT    /v2/email/configuration-sets/{name}/delivery-options    -> PutConfigurationSetDeliveryOptions
133    ///   PUT    /v2/email/configuration-sets/{name}/tracking-options    -> PutConfigurationSetTrackingOptions
134    ///   PUT    /v2/email/configuration-sets/{name}/suppression-options -> PutConfigurationSetSuppressionOptions
135    ///   PUT    /v2/email/configuration-sets/{name}/reputation-options  -> PutConfigurationSetReputationOptions
136    ///   PUT    /v2/email/configuration-sets/{name}/vdm-options         -> PutConfigurationSetVdmOptions
137    ///   PUT    /v2/email/configuration-sets/{name}/archiving-options   -> PutConfigurationSetArchivingOptions
138    ///   POST   /v2/email/custom-verification-email-templates           -> CreateCustomVerificationEmailTemplate
139    ///   GET    /v2/email/custom-verification-email-templates            -> ListCustomVerificationEmailTemplates
140    ///   GET    /v2/email/custom-verification-email-templates/{name}     -> GetCustomVerificationEmailTemplate
141    ///   PUT    /v2/email/custom-verification-email-templates/{name}     -> UpdateCustomVerificationEmailTemplate
142    ///   DELETE /v2/email/custom-verification-email-templates/{name}     -> DeleteCustomVerificationEmailTemplate
143    ///   POST   /v2/email/outbound-custom-verification-emails            -> SendCustomVerificationEmail
144    ///   POST   /v2/email/templates/{name}/render                        -> TestRenderEmailTemplate
145    ///   POST   /v2/email/import-jobs                                     -> CreateImportJob
146    ///   POST   /v2/email/import-jobs/list                                -> ListImportJobs
147    ///   GET    /v2/email/import-jobs/{id}                                -> GetImportJob
148    ///   POST   /v2/email/export-jobs                                     -> CreateExportJob
149    ///   POST   /v2/email/list-export-jobs                                -> ListExportJobs
150    ///   PUT    /v2/email/export-jobs/{id}/cancel                         -> CancelExportJob
151    ///   GET    /v2/email/export-jobs/{id}                                -> GetExportJob
152    ///   POST   /v2/email/tenants                                         -> CreateTenant
153    ///   POST   /v2/email/tenants/list                                    -> ListTenants
154    ///   POST   /v2/email/tenants/get                                     -> GetTenant
155    ///   POST   /v2/email/tenants/delete                                  -> DeleteTenant
156    ///   POST   /v2/email/tenants/resources                               -> CreateTenantResourceAssociation
157    ///   POST   /v2/email/tenants/resources/delete                        -> DeleteTenantResourceAssociation
158    ///   POST   /v2/email/tenants/resources/list                          -> ListTenantResources
159    ///   POST   /v2/email/resources/tenants/list                          -> ListResourceTenants
160    ///   POST   /v2/email/reputation/entities                             -> ListReputationEntities
161    ///   PUT    /v2/email/reputation/entities/{type}/{ref}/customer-managed-status -> UpdateReputationEntityCustomerManagedStatus
162    ///   PUT    /v2/email/reputation/entities/{type}/{ref}/policy          -> UpdateReputationEntityPolicy
163    ///   GET    /v2/email/reputation/entities/{type}/{ref}                 -> GetReputationEntity
164    ///   POST   /v2/email/metrics/batch                                   -> BatchGetMetricData
165    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Option<String>, Option<String>)> {
166        // The shared dispatcher filters empty path segments, so
167        // `/v2/email/identities/` and `/v2/email/identities` both yield
168        // `["v2","email","identities"]`. Treat an empty inner or trailing
169        // segment as "unroutable" so an empty path label
170        // (e.g. EmailIdentity="") doesn't accidentally collapse to the
171        // collection root and resolve to a list/create operation.
172        let raw = req
173            .raw_path
174            .split_once('?')
175            .map(|(p, _)| p)
176            .unwrap_or(&req.raw_path);
177        let trimmed = raw.trim_start_matches('/');
178        if trimmed.is_empty() {
179            return None;
180        }
181        let raw_segs: Vec<&str> = trimmed.split('/').collect();
182        // Bail on any empty segment (other than the unavoidable trailing
183        // empty produced by a single trailing slash on an otherwise valid
184        // collection path — that still indicates a missing label).
185        if raw_segs.iter().any(|s| s.is_empty()) {
186            return None;
187        }
188        // Reject unsubstituted URI-template placeholders left behind
189        // when the SDK (or conformance probe) failed to bind a path
190        // label. Such requests would never reach a real AWS endpoint.
191        let has_placeholder = raw_segs.iter().any(|seg| {
192            let decoded = percent_encoding::percent_decode_str(seg)
193                .decode_utf8_lossy()
194                .into_owned();
195            decoded.starts_with('{') && decoded.ends_with('}')
196        });
197        if has_placeholder {
198            return None;
199        }
200
201        let segs = &req.path_segments;
202
203        if segs.len() < 3 || segs[0] != "v2" || segs[1] != "email" {
204            return None;
205        }
206
207        let method = &req.method;
208        let resource = segs.get(3).map(|s| decode_segment(s));
209        let collection = segs[2].as_str();
210
211        match collection {
212            "account" => resolve_account_action(method, segs),
213            "identities" => resolve_identities_action(method, segs, resource),
214            "configuration-sets" => resolve_configuration_sets_action(method, segs, resource),
215            "templates" => resolve_templates_action(method, segs, resource),
216            "contact-lists" => resolve_contact_lists_action(method, segs, resource),
217            "suppression" => resolve_suppression_action(method, segs),
218            "tags" if segs.len() == 3 => match *method {
219                Method::POST => Some(("TagResource", None, None)),
220                Method::DELETE => Some(("UntagResource", None, None)),
221                Method::GET => Some(("ListTagsForResource", None, None)),
222                _ => None,
223            },
224            "outbound-emails" if segs.len() == 3 && *method == Method::POST => {
225                Some(("SendEmail", None, None))
226            }
227            "outbound-bulk-emails" if segs.len() == 3 && *method == Method::POST => {
228                Some(("SendBulkEmail", None, None))
229            }
230            "outbound-custom-verification-emails" if segs.len() == 3 && *method == Method::POST => {
231                Some(("SendCustomVerificationEmail", None, None))
232            }
233            "custom-verification-email-templates" => {
234                resolve_custom_verification_template_action(method, segs, resource)
235            }
236            "dedicated-ip-pools" => resolve_dedicated_ip_pools_action(method, segs, resource),
237            "dedicated-ips" => resolve_dedicated_ips_action(method, segs, resource),
238            "multi-region-endpoints" => {
239                resolve_multi_region_endpoints_action(method, segs, resource)
240            }
241            "import-jobs" => resolve_import_jobs_action(method, segs, resource),
242            "export-jobs" => resolve_export_jobs_action(method, segs, resource),
243            "list-export-jobs" if segs.len() == 3 && *method == Method::POST => {
244                Some(("ListExportJobs", None, None))
245            }
246            "tenants" => resolve_tenants_action(method, segs),
247            "tenant" if segs.len() == 4 && segs[3] == "suppression" && *method == Method::POST => {
248                Some(("PutTenantSuppressionAttributes", None, None))
249            }
250            "resources" => resolve_resources_action(method, segs),
251            "reputation" => resolve_reputation_action(method, segs),
252            "metrics" if segs.len() == 4 && segs[3] == "batch" && *method == Method::POST => {
253                Some(("BatchGetMetricData", None, None))
254            }
255            "deliverability-dashboard" => resolve_deliverability_dashboard_action(method, segs),
256            "email-address-insights" if segs.len() == 3 && *method == Method::POST => {
257                Some(("GetEmailAddressInsights", None, None))
258            }
259            "insights" if segs.len() == 4 && *method == Method::GET => {
260                Some(("GetMessageInsights", resource, None))
261            }
262            "vdm" if segs.len() == 4 && segs[3] == "recommendations" && *method == Method::POST => {
263                Some(("ListRecommendations", None, None))
264            }
265            _ => None,
266        }
267    }
268
269    fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
270        serde_json::from_slice(&req.body).map_err(|_| {
271            AwsServiceError::aws_error(
272                StatusCode::BAD_REQUEST,
273                "BadRequestException",
274                "Invalid JSON in request body",
275            )
276        })
277    }
278
279    /// Reject empty URL-bound parameters with a Smithy-shaped BadRequestException.
280    fn require_nonempty(field: &str, value: &str) -> Result<(), AwsServiceError> {
281        if value.is_empty() {
282            Err(AwsServiceError::aws_error(
283                StatusCode::BAD_REQUEST,
284                "BadRequestException",
285                format!("{field} is required"),
286            ))
287        } else {
288            Ok(())
289        }
290    }
291
292    fn json_error(status: StatusCode, code: &str, message: &str) -> AwsResponse {
293        let body = json!({
294            "__type": code,
295            "message": message,
296        });
297        AwsResponse::json(status, body.to_string())
298    }
299}
300
301/// Persist the current SES state as a snapshot. Offloads the serde +
302/// blocking file write to the Tokio blocking pool. Noop when `store` is `None`
303/// (memory mode). Shared by `SesV2Service::save_snapshot` and the CloudFormation
304/// provisioner's post-provision persist hook so both route through the same
305/// serialize-and-write path.
306pub async fn save_ses_snapshot(
307    state: &SharedSesState,
308    store: Option<Arc<dyn SnapshotStore>>,
309    lock: &AsyncMutex<()>,
310) {
311    let Some(store) = store else {
312        return;
313    };
314    let _guard = lock.lock().await;
315    let snapshot = SesSnapshot {
316        schema_version: SES_SNAPSHOT_SCHEMA_VERSION,
317        accounts: Some(state.read().clone()),
318        state: None,
319    };
320    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
321        let bytes = serde_json::to_vec(&snapshot)
322            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
323        store.save(&bytes)
324    })
325    .await;
326    match join {
327        Ok(Ok(())) => {}
328        Ok(Err(err)) => tracing::error!(%err, "failed to write ses snapshot"),
329        Err(err) => tracing::error!(%err, "ses snapshot task panicked"),
330    }
331}
332
333type ResolvedAction = Option<(&'static str, Option<String>, Option<String>)>;
334
335#[async_trait]
336impl fakecloud_core::service::AwsService for SesV2Service {
337    fn service_name(&self) -> &str {
338        "ses"
339    }
340
341    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
342        // Route v1 Query protocol requests to the v1 module.
343        if req.is_query_protocol {
344            let mutates = is_mutating_action(req.action.as_str());
345            let result = crate::v1::handle_v1_action(&self.state, &req);
346            if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
347                self.save_snapshot().await;
348            }
349            return result;
350        }
351
352        let (action, resource_name, sub_resource) =
353            Self::resolve_action(&req).ok_or_else(|| {
354                // SES v2 is a REST-style API. An unresolved path under
355                // /v2/email/ typically means a required path label (e.g.
356                // {EmailIdentity}, {TemplateName}) was supplied as the
357                // empty string. Real SES rejects this with 400
358                // BadRequestException. A path that doesn't start with
359                // /v2/email/ at all is a genuinely unknown operation
360                // (404).
361                // 400 only when the failure is structurally a missing/empty path
362                // label or an unsubstituted `{Placeholder}` — otherwise it's a
363                // genuinely unknown operation and must surface as 404.
364                let raw = req
365                    .raw_path
366                    .split_once('?')
367                    .map(|(p, _)| p)
368                    .unwrap_or(&req.raw_path);
369                let trimmed = raw.trim_start_matches('/');
370                let raw_segs: Vec<&str> = if trimmed.is_empty() {
371                    Vec::new()
372                } else {
373                    trimmed.split('/').collect()
374                };
375                // Only treat path-label issues as 400 inside /v2/email/; outside
376                // SES, any unresolved path is a genuinely unknown operation.
377                let inside_ses = raw_segs.first().map(|s| *s == "v2").unwrap_or(false)
378                    && raw_segs.get(1).map(|s| *s == "email").unwrap_or(false);
379                let label_problem = inside_ses
380                    && (raw_segs.iter().any(|s| s.is_empty())
381                        || raw_segs.iter().any(|seg| {
382                            let decoded = percent_encoding::percent_decode_str(seg)
383                                .decode_utf8_lossy()
384                                .into_owned();
385                            decoded.starts_with('{') && decoded.ends_with('}')
386                        }));
387                if label_problem {
388                    AwsServiceError::aws_error(
389                        StatusCode::BAD_REQUEST,
390                        "BadRequestException",
391                        format!("Invalid request: {} {}", req.method, req.raw_path),
392                    )
393                } else {
394                    AwsServiceError::aws_error(
395                        StatusCode::NOT_FOUND,
396                        "UnknownOperationException",
397                        format!("Unknown operation: {} {}", req.method, req.raw_path),
398                    )
399                }
400            })?;
401
402        let res = resource_name.as_deref().unwrap_or("");
403        let sub = sub_resource.as_deref().unwrap_or("");
404        let mutates = is_mutating_action(action);
405
406        let result = match action {
407            "GetAccount" => self.get_account(&req),
408            "CreateEmailIdentity" => self.create_email_identity(&req),
409            "ListEmailIdentities" => self.list_email_identities(&req),
410            "GetEmailIdentity" => self.get_email_identity(res, &req),
411            "DeleteEmailIdentity" => self.delete_email_identity(res, &req),
412            "CreateConfigurationSet" => self.create_configuration_set(&req),
413            "ListConfigurationSets" => self.list_configuration_sets(&req),
414            "GetConfigurationSet" => self.get_configuration_set(res, &req),
415            "DeleteConfigurationSet" => self.delete_configuration_set(res, &req),
416            "CreateEmailTemplate" => self.create_email_template(&req),
417            "ListEmailTemplates" => self.list_email_templates(&req),
418            "GetEmailTemplate" => self.get_email_template(res, &req),
419            "UpdateEmailTemplate" => self.update_email_template(res, &req),
420            "DeleteEmailTemplate" => self.delete_email_template(res, &req),
421            "SendEmail" => self.send_email(&req),
422            "SendBulkEmail" => self.send_bulk_email(&req),
423            "TagResource" => self.tag_resource(&req),
424            "UntagResource" => self.untag_resource(&req),
425            "ListTagsForResource" => self.list_tags_for_resource(&req),
426            "CreateContactList" => self.create_contact_list(&req),
427            "GetContactList" => self.get_contact_list(res, &req),
428            "ListContactLists" => self.list_contact_lists(&req),
429            "UpdateContactList" => self.update_contact_list(res, &req),
430            "DeleteContactList" => self.delete_contact_list(res, &req),
431            "CreateContact" => self.create_contact(res, &req),
432            "GetContact" => self.get_contact(res, sub, &req),
433            "ListContacts" => self.list_contacts(res, &req),
434            "UpdateContact" => self.update_contact(res, sub, &req),
435            "DeleteContact" => self.delete_contact(res, sub, &req),
436            "PutSuppressedDestination" => self.put_suppressed_destination(&req),
437            "GetSuppressedDestination" => self.get_suppressed_destination(res, &req),
438            "DeleteSuppressedDestination" => self.delete_suppressed_destination(res, &req),
439            "ListSuppressedDestinations" => self.list_suppressed_destinations(&req),
440            "CreateConfigurationSetEventDestination" => {
441                self.create_configuration_set_event_destination(res, &req)
442            }
443            "GetConfigurationSetEventDestinations" => {
444                self.get_configuration_set_event_destinations(res, &req)
445            }
446            "UpdateConfigurationSetEventDestination" => {
447                self.update_configuration_set_event_destination(res, sub, &req)
448            }
449            "DeleteConfigurationSetEventDestination" => {
450                self.delete_configuration_set_event_destination(res, sub, &req)
451            }
452            "CreateEmailIdentityPolicy" => self.create_email_identity_policy(res, sub, &req),
453            "GetEmailIdentityPolicies" => self.get_email_identity_policies(res, &req),
454            "UpdateEmailIdentityPolicy" => self.update_email_identity_policy(res, sub, &req),
455            "DeleteEmailIdentityPolicy" => self.delete_email_identity_policy(res, sub, &req),
456            "PutEmailIdentityDkimAttributes" => self.put_email_identity_dkim_attributes(res, &req),
457            "PutEmailIdentityDkimSigningAttributes" => {
458                self.put_email_identity_dkim_signing_attributes(res, &req)
459            }
460            "PutEmailIdentityFeedbackAttributes" => {
461                self.put_email_identity_feedback_attributes(res, &req)
462            }
463            "PutEmailIdentityMailFromAttributes" => {
464                self.put_email_identity_mail_from_attributes(res, &req)
465            }
466            "PutEmailIdentityConfigurationSetAttributes" => {
467                self.put_email_identity_configuration_set_attributes(res, &req)
468            }
469            "PutConfigurationSetSendingOptions" => {
470                self.put_configuration_set_sending_options(res, &req)
471            }
472            "PutConfigurationSetDeliveryOptions" => {
473                self.put_configuration_set_delivery_options(res, &req)
474            }
475            "PutConfigurationSetTrackingOptions" => {
476                self.put_configuration_set_tracking_options(res, &req)
477            }
478            "PutConfigurationSetSuppressionOptions" => {
479                self.put_configuration_set_suppression_options(res, &req)
480            }
481            "PutConfigurationSetReputationOptions" => {
482                self.put_configuration_set_reputation_options(res, &req)
483            }
484            "PutConfigurationSetVdmOptions" => self.put_configuration_set_vdm_options(res, &req),
485            "PutConfigurationSetArchivingOptions" => {
486                self.put_configuration_set_archiving_options(res, &req)
487            }
488            "CreateCustomVerificationEmailTemplate" => {
489                self.create_custom_verification_email_template(&req)
490            }
491            "GetCustomVerificationEmailTemplate" => {
492                self.get_custom_verification_email_template(res, &req)
493            }
494            "ListCustomVerificationEmailTemplates" => {
495                self.list_custom_verification_email_templates(&req)
496            }
497            "UpdateCustomVerificationEmailTemplate" => {
498                self.update_custom_verification_email_template(res, &req)
499            }
500            "DeleteCustomVerificationEmailTemplate" => {
501                self.delete_custom_verification_email_template(res, &req)
502            }
503            "SendCustomVerificationEmail" => self.send_custom_verification_email(&req),
504            "TestRenderEmailTemplate" => self.test_render_email_template(res, &req),
505            "CreateDedicatedIpPool" => self.create_dedicated_ip_pool(&req),
506            "ListDedicatedIpPools" => self.list_dedicated_ip_pools(&req),
507            "DeleteDedicatedIpPool" => self.delete_dedicated_ip_pool(res, &req),
508            "GetDedicatedIp" => self.get_dedicated_ip(res, &req),
509            "GetDedicatedIps" => self.get_dedicated_ips(&req),
510            "PutDedicatedIpInPool" => self.put_dedicated_ip_in_pool(res, &req),
511            "PutDedicatedIpPoolScalingAttributes" => {
512                self.put_dedicated_ip_pool_scaling_attributes(res, &req)
513            }
514            "PutDedicatedIpWarmupAttributes" => self.put_dedicated_ip_warmup_attributes(res, &req),
515            "PutAccountDedicatedIpWarmupAttributes" => {
516                self.put_account_dedicated_ip_warmup_attributes(&req)
517            }
518            "CreateMultiRegionEndpoint" => self.create_multi_region_endpoint(&req),
519            "GetMultiRegionEndpoint" => self.get_multi_region_endpoint(res, &req),
520            "ListMultiRegionEndpoints" => self.list_multi_region_endpoints(&req),
521            "DeleteMultiRegionEndpoint" => self.delete_multi_region_endpoint(res, &req),
522            "PutAccountDetails" => self.put_account_details(&req),
523            "PutAccountSendingAttributes" => self.put_account_sending_attributes(&req),
524            "PutAccountSuppressionAttributes" => self.put_account_suppression_attributes(&req),
525            "PutAccountVdmAttributes" => self.put_account_vdm_attributes(&req),
526            "CreateImportJob" => self.create_import_job(&req),
527            "GetImportJob" => self.get_import_job(res, &req),
528            "ListImportJobs" => self.list_import_jobs(&req),
529            "CreateExportJob" => self.create_export_job(&req),
530            "GetExportJob" => self.get_export_job(res, &req),
531            "ListExportJobs" => self.list_export_jobs(&req),
532            "CancelExportJob" => self.cancel_export_job(res, &req),
533            "CreateTenant" => self.create_tenant(&req),
534            "PutTenantSuppressionAttributes" => self.put_tenant_suppression_attributes(&req),
535            "GetTenant" => self.get_tenant(&req),
536            "ListTenants" => self.list_tenants(&req),
537            "DeleteTenant" => self.delete_tenant(&req),
538            "CreateTenantResourceAssociation" => self.create_tenant_resource_association(&req),
539            "DeleteTenantResourceAssociation" => self.delete_tenant_resource_association(&req),
540            "ListTenantResources" => self.list_tenant_resources(&req),
541            "ListResourceTenants" => self.list_resource_tenants(&req),
542            "GetReputationEntity" => self.get_reputation_entity(res, sub, &req),
543            "ListReputationEntities" => self.list_reputation_entities(&req),
544            "UpdateReputationEntityCustomerManagedStatus" => {
545                self.update_reputation_entity_customer_managed_status(res, sub, &req)
546            }
547            "UpdateReputationEntityPolicy" => self.update_reputation_entity_policy(res, sub, &req),
548            "BatchGetMetricData" => self.batch_get_metric_data(&req),
549            "GetDedicatedIpPool" => self.get_dedicated_ip_pool(res, &req),
550            "GetDeliverabilityDashboardOptions" => self.get_deliverability_dashboard_options(&req),
551            "PutDeliverabilityDashboardOption" => self.put_deliverability_dashboard_option(&req),
552            "CreateDeliverabilityTestReport" => self.create_deliverability_test_report(&req),
553            "GetDeliverabilityTestReport" => self.get_deliverability_test_report(res, &req),
554            "ListDeliverabilityTestReports" => self.list_deliverability_test_reports(&req),
555            "GetBlacklistReports" => self.get_blacklist_reports(&req),
556            "GetDomainDeliverabilityCampaign" => self.get_domain_deliverability_campaign(res, &req),
557            "GetDomainStatisticsReport" => self.get_domain_statistics_report(res, &req),
558            "ListDomainDeliverabilityCampaigns" => {
559                self.list_domain_deliverability_campaigns(res, &req)
560            }
561            "GetEmailAddressInsights" => self.get_email_address_insights(&req),
562            "GetMessageInsights" => self.get_message_insights(res, &req),
563            "ListRecommendations" => self.list_recommendations(&req),
564            _ => Err(AwsServiceError::action_not_implemented("ses", action)),
565        };
566        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
567            self.save_snapshot().await;
568        }
569        result
570    }
571
572    fn supported_actions(&self) -> &[&str] {
573        &[
574            "GetAccount",
575            "CreateEmailIdentity",
576            "ListEmailIdentities",
577            "GetEmailIdentity",
578            "DeleteEmailIdentity",
579            "CreateConfigurationSet",
580            "ListConfigurationSets",
581            "GetConfigurationSet",
582            "DeleteConfigurationSet",
583            "CreateEmailTemplate",
584            "ListEmailTemplates",
585            "GetEmailTemplate",
586            "UpdateEmailTemplate",
587            "DeleteEmailTemplate",
588            "SendEmail",
589            "SendBulkEmail",
590            "TagResource",
591            "UntagResource",
592            "ListTagsForResource",
593            "CreateContactList",
594            "GetContactList",
595            "ListContactLists",
596            "UpdateContactList",
597            "DeleteContactList",
598            "CreateContact",
599            "GetContact",
600            "ListContacts",
601            "UpdateContact",
602            "DeleteContact",
603            "PutSuppressedDestination",
604            "GetSuppressedDestination",
605            "DeleteSuppressedDestination",
606            "ListSuppressedDestinations",
607            "CreateConfigurationSetEventDestination",
608            "GetConfigurationSetEventDestinations",
609            "UpdateConfigurationSetEventDestination",
610            "DeleteConfigurationSetEventDestination",
611            "CreateEmailIdentityPolicy",
612            "GetEmailIdentityPolicies",
613            "UpdateEmailIdentityPolicy",
614            "DeleteEmailIdentityPolicy",
615            "PutEmailIdentityDkimAttributes",
616            "PutEmailIdentityDkimSigningAttributes",
617            "PutEmailIdentityFeedbackAttributes",
618            "PutEmailIdentityMailFromAttributes",
619            "PutEmailIdentityConfigurationSetAttributes",
620            "PutConfigurationSetSendingOptions",
621            "PutConfigurationSetDeliveryOptions",
622            "PutConfigurationSetTrackingOptions",
623            "PutConfigurationSetSuppressionOptions",
624            "PutConfigurationSetReputationOptions",
625            "PutConfigurationSetVdmOptions",
626            "PutConfigurationSetArchivingOptions",
627            "CreateCustomVerificationEmailTemplate",
628            "GetCustomVerificationEmailTemplate",
629            "ListCustomVerificationEmailTemplates",
630            "UpdateCustomVerificationEmailTemplate",
631            "DeleteCustomVerificationEmailTemplate",
632            "SendCustomVerificationEmail",
633            "TestRenderEmailTemplate",
634            "CreateDedicatedIpPool",
635            "ListDedicatedIpPools",
636            "DeleteDedicatedIpPool",
637            "GetDedicatedIp",
638            "GetDedicatedIps",
639            "PutDedicatedIpInPool",
640            "PutDedicatedIpPoolScalingAttributes",
641            "PutDedicatedIpWarmupAttributes",
642            "PutAccountDedicatedIpWarmupAttributes",
643            "CreateMultiRegionEndpoint",
644            "GetMultiRegionEndpoint",
645            "ListMultiRegionEndpoints",
646            "DeleteMultiRegionEndpoint",
647            "PutAccountDetails",
648            "PutAccountSendingAttributes",
649            "PutAccountSuppressionAttributes",
650            "PutAccountVdmAttributes",
651            "CreateImportJob",
652            "GetImportJob",
653            "ListImportJobs",
654            "CreateExportJob",
655            "GetExportJob",
656            "ListExportJobs",
657            "CancelExportJob",
658            "CreateTenant",
659            "PutTenantSuppressionAttributes",
660            "GetTenant",
661            "ListTenants",
662            "DeleteTenant",
663            "CreateTenantResourceAssociation",
664            "DeleteTenantResourceAssociation",
665            "ListTenantResources",
666            "ListResourceTenants",
667            "GetReputationEntity",
668            "ListReputationEntities",
669            "UpdateReputationEntityCustomerManagedStatus",
670            "UpdateReputationEntityPolicy",
671            "BatchGetMetricData",
672            "GetDedicatedIpPool",
673            "GetDeliverabilityDashboardOptions",
674            "PutDeliverabilityDashboardOption",
675            "CreateDeliverabilityTestReport",
676            "GetDeliverabilityTestReport",
677            "ListDeliverabilityTestReports",
678            "GetBlacklistReports",
679            "GetDomainDeliverabilityCampaign",
680            "GetDomainStatisticsReport",
681            "ListDomainDeliverabilityCampaigns",
682            "GetEmailAddressInsights",
683            "GetMessageInsights",
684            "ListRecommendations",
685            // NOTE: SES v1 receipt rule/filter actions are implemented (see v1.rs)
686            // but excluded from the conformance audit because there is no SES v1
687            // Smithy model (only sesv2.json exists) to generate checksums from.
688        ]
689    }
690}
691
692mod helpers;
693pub(crate) use helpers::*;
694
695#[cfg(test)]
696mod tests;