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 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 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 fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Option<String>, Option<String>)> {
162 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 if raw_segs.iter().any(|s| s.is_empty()) {
182 return None;
183 }
184 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 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 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 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 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 ]
648 }
649}
650
651mod helpers;
652pub(crate) use helpers::*;
653
654#[cfg(test)]
655mod tests;