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