mod account;
mod configuration_sets;
mod contact_lists;
mod identities;
mod misc;
mod sending;
mod suppression;
mod templates;
use async_trait::async_trait;
use http::{Method, StatusCode};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
use fakecloud_persistence::SnapshotStore;
use crate::fanout::SesDeliveryContext;
use crate::state::{
EventDestination, SesSnapshot, SharedSesState, Topic, TopicPreference,
SES_SNAPSHOT_SCHEMA_VERSION,
};
pub struct SesV2Service {
state: SharedSesState,
delivery_ctx: Option<SesDeliveryContext>,
snapshot_store: Option<Arc<dyn SnapshotStore>>,
snapshot_lock: Arc<AsyncMutex<()>>,
}
impl SesV2Service {
pub fn new(state: SharedSesState) -> Self {
Self {
state,
delivery_ctx: None,
snapshot_store: None,
snapshot_lock: Arc::new(AsyncMutex::new(())),
}
}
pub fn with_delivery(mut self, ctx: SesDeliveryContext) -> Self {
self.delivery_ctx = Some(ctx);
self
}
pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
self.snapshot_store = Some(store);
self
}
async fn save_snapshot(&self) {
let Some(store) = self.snapshot_store.clone() else {
return;
};
let _guard = self.snapshot_lock.lock().await;
let snapshot = SesSnapshot {
schema_version: SES_SNAPSHOT_SCHEMA_VERSION,
accounts: Some(self.state.read().clone()),
state: None,
};
let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let bytes = serde_json::to_vec(&snapshot)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
store.save(&bytes)
})
.await;
match join {
Ok(Ok(())) => {}
Ok(Err(err)) => tracing::error!(%err, "failed to write ses snapshot"),
Err(err) => tracing::error!(%err, "ses snapshot task panicked"),
}
}
fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Option<String>, Option<String>)> {
let segs = &req.path_segments;
if segs.len() < 3 || segs[0] != "v2" || segs[1] != "email" {
return None;
}
let method = &req.method;
let resource = segs.get(3).map(|s| decode_segment(s));
let collection = segs[2].as_str();
match collection {
"account" => resolve_account_action(method, segs),
"identities" => resolve_identities_action(method, segs, resource),
"configuration-sets" => resolve_configuration_sets_action(method, segs, resource),
"templates" => resolve_templates_action(method, segs, resource),
"contact-lists" => resolve_contact_lists_action(method, segs, resource),
"suppression" => resolve_suppression_action(method, segs),
"tags" if segs.len() == 3 => match *method {
Method::POST => Some(("TagResource", None, None)),
Method::DELETE => Some(("UntagResource", None, None)),
Method::GET => Some(("ListTagsForResource", None, None)),
_ => None,
},
"outbound-emails" if segs.len() == 3 && *method == Method::POST => {
Some(("SendEmail", None, None))
}
"outbound-bulk-emails" if segs.len() == 3 && *method == Method::POST => {
Some(("SendBulkEmail", None, None))
}
"outbound-custom-verification-emails" if segs.len() == 3 && *method == Method::POST => {
Some(("SendCustomVerificationEmail", None, None))
}
"custom-verification-email-templates" => {
resolve_custom_verification_template_action(method, segs, resource)
}
"dedicated-ip-pools" => resolve_dedicated_ip_pools_action(method, segs, resource),
"dedicated-ips" => resolve_dedicated_ips_action(method, segs, resource),
"multi-region-endpoints" => {
resolve_multi_region_endpoints_action(method, segs, resource)
}
"import-jobs" => resolve_import_jobs_action(method, segs, resource),
"export-jobs" => resolve_export_jobs_action(method, segs, resource),
"list-export-jobs" if segs.len() == 3 && *method == Method::POST => {
Some(("ListExportJobs", None, None))
}
"tenants" => resolve_tenants_action(method, segs),
"resources" => resolve_resources_action(method, segs),
"reputation" => resolve_reputation_action(method, segs),
"metrics" if segs.len() == 4 && segs[3] == "batch" && *method == Method::POST => {
Some(("BatchGetMetricData", None, None))
}
_ => None,
}
}
fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
serde_json::from_slice(&req.body).map_err(|_| {
AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"BadRequestException",
"Invalid JSON in request body",
)
})
}
fn json_error(status: StatusCode, code: &str, message: &str) -> AwsResponse {
let body = json!({
"__type": code,
"message": message,
});
AwsResponse::json(status, body.to_string())
}
}
fn decode_segment(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
}
type ResolvedAction = Option<(&'static str, Option<String>, Option<String>)>;
fn resolve_account_action(method: &Method, segs: &[String]) -> ResolvedAction {
match (method, segs.len()) {
(&Method::GET, 3) => Some(("GetAccount", None, None)),
(&Method::POST, 4) if segs[3] == "details" => Some(("PutAccountDetails", None, None)),
(&Method::PUT, 4) if segs[3] == "sending" => {
Some(("PutAccountSendingAttributes", None, None))
}
(&Method::PUT, 4) if segs[3] == "suppression" => {
Some(("PutAccountSuppressionAttributes", None, None))
}
(&Method::PUT, 4) if segs[3] == "vdm" => Some(("PutAccountVdmAttributes", None, None)),
(&Method::PUT, 5) if segs[3] == "dedicated-ips" && segs[4] == "warmup" => {
Some(("PutAccountDedicatedIpWarmupAttributes", None, None))
}
_ => None,
}
}
fn resolve_identities_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateEmailIdentity", None, None)),
(&Method::GET, 3) => Some(("ListEmailIdentities", None, None)),
(&Method::GET, 4) => Some(("GetEmailIdentity", resource, None)),
(&Method::DELETE, 4) => Some(("DeleteEmailIdentity", resource, None)),
(&Method::PUT, 5) if segs[4] == "dkim" => {
Some(("PutEmailIdentityDkimAttributes", resource, None))
}
(&Method::PUT, 5) if segs[4] == "feedback" => {
Some(("PutEmailIdentityFeedbackAttributes", resource, None))
}
(&Method::PUT, 5) if segs[4] == "mail-from" => {
Some(("PutEmailIdentityMailFromAttributes", resource, None))
}
(&Method::PUT, 5) if segs[4] == "configuration-set" => {
Some(("PutEmailIdentityConfigurationSetAttributes", resource, None))
}
(&Method::GET, 5) if segs[4] == "policies" => {
Some(("GetEmailIdentityPolicies", resource, None))
}
(&Method::PUT, 6) if segs[4] == "dkim" && segs[5] == "signing" => {
Some(("PutEmailIdentityDkimSigningAttributes", resource, None))
}
(&Method::POST, 6) if segs[4] == "policies" => Some((
"CreateEmailIdentityPolicy",
resource,
Some(decode_segment(&segs[5])),
)),
(&Method::PUT, 6) if segs[4] == "policies" => Some((
"UpdateEmailIdentityPolicy",
resource,
Some(decode_segment(&segs[5])),
)),
(&Method::DELETE, 6) if segs[4] == "policies" => Some((
"DeleteEmailIdentityPolicy",
resource,
Some(decode_segment(&segs[5])),
)),
_ => None,
}
}
fn resolve_configuration_sets_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateConfigurationSet", None, None)),
(&Method::GET, 3) => Some(("ListConfigurationSets", None, None)),
(&Method::GET, 4) => Some(("GetConfigurationSet", resource, None)),
(&Method::DELETE, 4) => Some(("DeleteConfigurationSet", resource, None)),
(&Method::POST, 5) if segs[4] == "event-destinations" => {
Some(("CreateConfigurationSetEventDestination", resource, None))
}
(&Method::GET, 5) if segs[4] == "event-destinations" => {
Some(("GetConfigurationSetEventDestinations", resource, None))
}
(&Method::PUT, 5) if segs[4] == "sending" => {
Some(("PutConfigurationSetSendingOptions", resource, None))
}
(&Method::PUT, 5) if segs[4] == "delivery-options" => {
Some(("PutConfigurationSetDeliveryOptions", resource, None))
}
(&Method::PUT, 5) if segs[4] == "tracking-options" => {
Some(("PutConfigurationSetTrackingOptions", resource, None))
}
(&Method::PUT, 5) if segs[4] == "suppression-options" => {
Some(("PutConfigurationSetSuppressionOptions", resource, None))
}
(&Method::PUT, 5) if segs[4] == "reputation-options" => {
Some(("PutConfigurationSetReputationOptions", resource, None))
}
(&Method::PUT, 5) if segs[4] == "vdm-options" => {
Some(("PutConfigurationSetVdmOptions", resource, None))
}
(&Method::PUT, 5) if segs[4] == "archiving-options" => {
Some(("PutConfigurationSetArchivingOptions", resource, None))
}
(&Method::PUT, 6) if segs[4] == "event-destinations" => Some((
"UpdateConfigurationSetEventDestination",
resource,
Some(decode_segment(&segs[5])),
)),
(&Method::DELETE, 6) if segs[4] == "event-destinations" => Some((
"DeleteConfigurationSetEventDestination",
resource,
Some(decode_segment(&segs[5])),
)),
_ => None,
}
}
fn resolve_templates_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateEmailTemplate", None, None)),
(&Method::GET, 3) => Some(("ListEmailTemplates", None, None)),
(&Method::GET, 4) => Some(("GetEmailTemplate", resource, None)),
(&Method::PUT, 4) => Some(("UpdateEmailTemplate", resource, None)),
(&Method::DELETE, 4) => Some(("DeleteEmailTemplate", resource, None)),
(&Method::POST, 5) if segs[4] == "render" => {
Some(("TestRenderEmailTemplate", resource, None))
}
_ => None,
}
}
fn resolve_contact_lists_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateContactList", None, None)),
(&Method::GET, 3) => Some(("ListContactLists", None, None)),
(&Method::GET, 4) => Some(("GetContactList", resource, None)),
(&Method::PUT, 4) => Some(("UpdateContactList", resource, None)),
(&Method::DELETE, 4) => Some(("DeleteContactList", resource, None)),
(&Method::POST, 5) if segs[4] == "contacts" => Some(("CreateContact", resource, None)),
(&Method::GET, 5) if segs[4] == "contacts" => Some(("ListContacts", resource, None)),
(&Method::POST, 6) if segs[4] == "contacts" && segs[5] == "list" => {
Some(("ListContacts", resource, None))
}
(&Method::GET, 6) if segs[4] == "contacts" => {
Some(("GetContact", resource, Some(decode_segment(&segs[5]))))
}
(&Method::PUT, 6) if segs[4] == "contacts" => {
Some(("UpdateContact", resource, Some(decode_segment(&segs[5]))))
}
(&Method::DELETE, 6) if segs[4] == "contacts" => {
Some(("DeleteContact", resource, Some(decode_segment(&segs[5]))))
}
_ => None,
}
}
fn resolve_suppression_action(method: &Method, segs: &[String]) -> ResolvedAction {
if segs.get(3).map(|s| s.as_str()) != Some("addresses") {
return None;
}
match (method, segs.len()) {
(&Method::PUT, 4) => Some(("PutSuppressedDestination", None, None)),
(&Method::GET, 4) => Some(("ListSuppressedDestinations", None, None)),
(&Method::GET, 5) => Some((
"GetSuppressedDestination",
Some(decode_segment(&segs[4])),
None,
)),
(&Method::DELETE, 5) => Some((
"DeleteSuppressedDestination",
Some(decode_segment(&segs[4])),
None,
)),
_ => None,
}
}
fn resolve_custom_verification_template_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateCustomVerificationEmailTemplate", None, None)),
(&Method::GET, 3) => Some(("ListCustomVerificationEmailTemplates", None, None)),
(&Method::GET, 4) => Some(("GetCustomVerificationEmailTemplate", resource, None)),
(&Method::PUT, 4) => Some(("UpdateCustomVerificationEmailTemplate", resource, None)),
(&Method::DELETE, 4) => Some(("DeleteCustomVerificationEmailTemplate", resource, None)),
_ => None,
}
}
fn resolve_dedicated_ip_pools_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateDedicatedIpPool", None, None)),
(&Method::GET, 3) => Some(("ListDedicatedIpPools", None, None)),
(&Method::DELETE, 4) => Some(("DeleteDedicatedIpPool", resource, None)),
(&Method::PUT, 5) if segs[4] == "scaling" => {
Some(("PutDedicatedIpPoolScalingAttributes", resource, None))
}
_ => None,
}
}
fn resolve_dedicated_ips_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::GET, 3) => Some(("GetDedicatedIps", None, None)),
(&Method::GET, 4) => Some(("GetDedicatedIp", resource, None)),
(&Method::PUT, 5) if segs[4] == "pool" => Some(("PutDedicatedIpInPool", resource, None)),
(&Method::PUT, 5) if segs[4] == "warmup" => {
Some(("PutDedicatedIpWarmupAttributes", resource, None))
}
_ => None,
}
}
fn resolve_multi_region_endpoints_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateMultiRegionEndpoint", None, None)),
(&Method::GET, 3) => Some(("ListMultiRegionEndpoints", None, None)),
(&Method::GET, 4) => Some(("GetMultiRegionEndpoint", resource, None)),
(&Method::DELETE, 4) => Some(("DeleteMultiRegionEndpoint", resource, None)),
_ => None,
}
}
fn resolve_import_jobs_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateImportJob", None, None)),
(&Method::POST, 4) if segs[3] == "list" => Some(("ListImportJobs", None, None)),
(&Method::GET, 4) => Some(("GetImportJob", resource, None)),
_ => None,
}
}
fn resolve_export_jobs_action(
method: &Method,
segs: &[String],
resource: Option<String>,
) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateExportJob", None, None)),
(&Method::GET, 4) => Some(("GetExportJob", resource, None)),
(&Method::PUT, 5) if segs[4] == "cancel" => Some(("CancelExportJob", resource, None)),
_ => None,
}
}
fn resolve_tenants_action(method: &Method, segs: &[String]) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 3) => Some(("CreateTenant", None, None)),
(&Method::POST, 4) if segs[3] == "list" => Some(("ListTenants", None, None)),
(&Method::POST, 4) if segs[3] == "get" => Some(("GetTenant", None, None)),
(&Method::POST, 4) if segs[3] == "delete" => Some(("DeleteTenant", None, None)),
(&Method::POST, 4) if segs[3] == "resources" => {
Some(("CreateTenantResourceAssociation", None, None))
}
(&Method::POST, 5) if segs[3] == "resources" && segs[4] == "delete" => {
Some(("DeleteTenantResourceAssociation", None, None))
}
(&Method::POST, 5) if segs[3] == "resources" && segs[4] == "list" => {
Some(("ListTenantResources", None, None))
}
_ => None,
}
}
fn resolve_resources_action(method: &Method, segs: &[String]) -> ResolvedAction {
match (method, segs.len()) {
(&Method::POST, 5) if segs[3] == "tenants" && segs[4] == "list" => {
Some(("ListResourceTenants", None, None))
}
_ => None,
}
}
fn resolve_reputation_action(method: &Method, segs: &[String]) -> ResolvedAction {
if segs.get(3).map(|s| s.as_str()) != Some("entities") {
return None;
}
match (method, segs.len()) {
(&Method::POST, 4) => Some(("ListReputationEntities", None, None)),
(&Method::GET, 6) => Some((
"GetReputationEntity",
Some(decode_segment(&segs[4])),
Some(decode_segment(&segs[5])),
)),
(&Method::PUT, 7) if segs[6] == "customer-managed-status" => Some((
"UpdateReputationEntityCustomerManagedStatus",
Some(decode_segment(&segs[4])),
Some(decode_segment(&segs[5])),
)),
(&Method::PUT, 7) if segs[6] == "policy" => Some((
"UpdateReputationEntityPolicy",
Some(decode_segment(&segs[4])),
Some(decode_segment(&segs[5])),
)),
_ => None,
}
}
fn parse_topics(value: &Value) -> Vec<Topic> {
value
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| {
let topic_name = v["TopicName"].as_str()?.to_string();
let display_name = v["DisplayName"].as_str().unwrap_or("").to_string();
let description = v["Description"].as_str().unwrap_or("").to_string();
let default_subscription_status = v["DefaultSubscriptionStatus"]
.as_str()
.unwrap_or("OPT_OUT")
.to_string();
Some(Topic {
topic_name,
display_name,
description,
default_subscription_status,
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_topic_preferences(value: &Value) -> Vec<TopicPreference> {
value
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| {
let topic_name = v["TopicName"].as_str()?.to_string();
let subscription_status = v["SubscriptionStatus"]
.as_str()
.unwrap_or("OPT_OUT")
.to_string();
Some(TopicPreference {
topic_name,
subscription_status,
})
})
.collect()
})
.unwrap_or_default()
}
fn extract_string_array(value: &Value) -> Vec<String> {
value
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn parse_event_destination_definition(name: &str, def: &Value) -> EventDestination {
let enabled = def["Enabled"].as_bool().unwrap_or(false);
let matching_event_types = extract_string_array(&def["MatchingEventTypes"]);
let kinesis_firehose_destination = def
.get("KinesisFirehoseDestination")
.filter(|v| v.is_object())
.cloned();
let cloud_watch_destination = def
.get("CloudWatchDestination")
.filter(|v| v.is_object())
.cloned();
let sns_destination = def.get("SnsDestination").filter(|v| v.is_object()).cloned();
let event_bridge_destination = def
.get("EventBridgeDestination")
.filter(|v| v.is_object())
.cloned();
let pinpoint_destination = def
.get("PinpointDestination")
.filter(|v| v.is_object())
.cloned();
EventDestination {
name: name.to_string(),
enabled,
matching_event_types,
kinesis_firehose_destination,
cloud_watch_destination,
sns_destination,
event_bridge_destination,
pinpoint_destination,
}
}
fn event_destination_to_json(dest: &EventDestination) -> Value {
let mut obj = json!({
"Name": dest.name,
"Enabled": dest.enabled,
"MatchingEventTypes": dest.matching_event_types,
});
if let Some(ref v) = dest.kinesis_firehose_destination {
obj["KinesisFirehoseDestination"] = v.clone();
}
if let Some(ref v) = dest.cloud_watch_destination {
obj["CloudWatchDestination"] = v.clone();
}
if let Some(ref v) = dest.sns_destination {
obj["SnsDestination"] = v.clone();
}
if let Some(ref v) = dest.event_bridge_destination {
obj["EventBridgeDestination"] = v.clone();
}
if let Some(ref v) = dest.pinpoint_destination {
obj["PinpointDestination"] = v.clone();
}
obj
}
fn is_mutating_action(action: &str) -> bool {
const MUTATING_PREFIXES: &[&str] = &[
"Create",
"Update",
"Delete",
"Put",
"Tag",
"Untag",
"Send",
"Cancel",
"Verify",
"Set",
"Clone",
"Reorder",
"BatchUpdate",
];
MUTATING_PREFIXES.iter().any(|p| action.starts_with(p))
}
#[async_trait]
impl fakecloud_core::service::AwsService for SesV2Service {
fn service_name(&self) -> &str {
"ses"
}
async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
if req.is_query_protocol {
let mutates = is_mutating_action(req.action.as_str());
let result = crate::v1::handle_v1_action(&self.state, &req);
if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
self.save_snapshot().await;
}
return result;
}
let (action, resource_name, sub_resource) =
Self::resolve_action(&req).ok_or_else(|| {
AwsServiceError::aws_error(
StatusCode::NOT_FOUND,
"UnknownOperationException",
format!("Unknown operation: {} {}", req.method, req.raw_path),
)
})?;
let res = resource_name.as_deref().unwrap_or("");
let sub = sub_resource.as_deref().unwrap_or("");
let mutates = is_mutating_action(action);
let result = match action {
"GetAccount" => self.get_account(&req),
"CreateEmailIdentity" => self.create_email_identity(&req),
"ListEmailIdentities" => self.list_email_identities(&req),
"GetEmailIdentity" => self.get_email_identity(res, &req),
"DeleteEmailIdentity" => self.delete_email_identity(res, &req),
"CreateConfigurationSet" => self.create_configuration_set(&req),
"ListConfigurationSets" => self.list_configuration_sets(&req),
"GetConfigurationSet" => self.get_configuration_set(res, &req),
"DeleteConfigurationSet" => self.delete_configuration_set(res, &req),
"CreateEmailTemplate" => self.create_email_template(&req),
"ListEmailTemplates" => self.list_email_templates(&req),
"GetEmailTemplate" => self.get_email_template(res, &req),
"UpdateEmailTemplate" => self.update_email_template(res, &req),
"DeleteEmailTemplate" => self.delete_email_template(res, &req),
"SendEmail" => self.send_email(&req),
"SendBulkEmail" => self.send_bulk_email(&req),
"TagResource" => self.tag_resource(&req),
"UntagResource" => self.untag_resource(&req),
"ListTagsForResource" => self.list_tags_for_resource(&req),
"CreateContactList" => self.create_contact_list(&req),
"GetContactList" => self.get_contact_list(res, &req),
"ListContactLists" => self.list_contact_lists(&req),
"UpdateContactList" => self.update_contact_list(res, &req),
"DeleteContactList" => self.delete_contact_list(res, &req),
"CreateContact" => self.create_contact(res, &req),
"GetContact" => self.get_contact(res, sub, &req),
"ListContacts" => self.list_contacts(res, &req),
"UpdateContact" => self.update_contact(res, sub, &req),
"DeleteContact" => self.delete_contact(res, sub, &req),
"PutSuppressedDestination" => self.put_suppressed_destination(&req),
"GetSuppressedDestination" => self.get_suppressed_destination(res, &req),
"DeleteSuppressedDestination" => self.delete_suppressed_destination(res, &req),
"ListSuppressedDestinations" => self.list_suppressed_destinations(&req),
"CreateConfigurationSetEventDestination" => {
self.create_configuration_set_event_destination(res, &req)
}
"GetConfigurationSetEventDestinations" => {
self.get_configuration_set_event_destinations(res, &req)
}
"UpdateConfigurationSetEventDestination" => {
self.update_configuration_set_event_destination(res, sub, &req)
}
"DeleteConfigurationSetEventDestination" => {
self.delete_configuration_set_event_destination(res, sub, &req)
}
"CreateEmailIdentityPolicy" => self.create_email_identity_policy(res, sub, &req),
"GetEmailIdentityPolicies" => self.get_email_identity_policies(res, &req),
"UpdateEmailIdentityPolicy" => self.update_email_identity_policy(res, sub, &req),
"DeleteEmailIdentityPolicy" => self.delete_email_identity_policy(res, sub, &req),
"PutEmailIdentityDkimAttributes" => self.put_email_identity_dkim_attributes(res, &req),
"PutEmailIdentityDkimSigningAttributes" => {
self.put_email_identity_dkim_signing_attributes(res, &req)
}
"PutEmailIdentityFeedbackAttributes" => {
self.put_email_identity_feedback_attributes(res, &req)
}
"PutEmailIdentityMailFromAttributes" => {
self.put_email_identity_mail_from_attributes(res, &req)
}
"PutEmailIdentityConfigurationSetAttributes" => {
self.put_email_identity_configuration_set_attributes(res, &req)
}
"PutConfigurationSetSendingOptions" => {
self.put_configuration_set_sending_options(res, &req)
}
"PutConfigurationSetDeliveryOptions" => {
self.put_configuration_set_delivery_options(res, &req)
}
"PutConfigurationSetTrackingOptions" => {
self.put_configuration_set_tracking_options(res, &req)
}
"PutConfigurationSetSuppressionOptions" => {
self.put_configuration_set_suppression_options(res, &req)
}
"PutConfigurationSetReputationOptions" => {
self.put_configuration_set_reputation_options(res, &req)
}
"PutConfigurationSetVdmOptions" => self.put_configuration_set_vdm_options(res, &req),
"PutConfigurationSetArchivingOptions" => {
self.put_configuration_set_archiving_options(res, &req)
}
"CreateCustomVerificationEmailTemplate" => {
self.create_custom_verification_email_template(&req)
}
"GetCustomVerificationEmailTemplate" => {
self.get_custom_verification_email_template(res, &req)
}
"ListCustomVerificationEmailTemplates" => {
self.list_custom_verification_email_templates(&req)
}
"UpdateCustomVerificationEmailTemplate" => {
self.update_custom_verification_email_template(res, &req)
}
"DeleteCustomVerificationEmailTemplate" => {
self.delete_custom_verification_email_template(res, &req)
}
"SendCustomVerificationEmail" => self.send_custom_verification_email(&req),
"TestRenderEmailTemplate" => self.test_render_email_template(res, &req),
"CreateDedicatedIpPool" => self.create_dedicated_ip_pool(&req),
"ListDedicatedIpPools" => self.list_dedicated_ip_pools(&req),
"DeleteDedicatedIpPool" => self.delete_dedicated_ip_pool(res, &req),
"GetDedicatedIp" => self.get_dedicated_ip(res, &req),
"GetDedicatedIps" => self.get_dedicated_ips(&req),
"PutDedicatedIpInPool" => self.put_dedicated_ip_in_pool(res, &req),
"PutDedicatedIpPoolScalingAttributes" => {
self.put_dedicated_ip_pool_scaling_attributes(res, &req)
}
"PutDedicatedIpWarmupAttributes" => self.put_dedicated_ip_warmup_attributes(res, &req),
"PutAccountDedicatedIpWarmupAttributes" => {
self.put_account_dedicated_ip_warmup_attributes(&req)
}
"CreateMultiRegionEndpoint" => self.create_multi_region_endpoint(&req),
"GetMultiRegionEndpoint" => self.get_multi_region_endpoint(res, &req),
"ListMultiRegionEndpoints" => self.list_multi_region_endpoints(&req),
"DeleteMultiRegionEndpoint" => self.delete_multi_region_endpoint(res, &req),
"PutAccountDetails" => self.put_account_details(&req),
"PutAccountSendingAttributes" => self.put_account_sending_attributes(&req),
"PutAccountSuppressionAttributes" => self.put_account_suppression_attributes(&req),
"PutAccountVdmAttributes" => self.put_account_vdm_attributes(&req),
"CreateImportJob" => self.create_import_job(&req),
"GetImportJob" => self.get_import_job(res, &req),
"ListImportJobs" => self.list_import_jobs(&req),
"CreateExportJob" => self.create_export_job(&req),
"GetExportJob" => self.get_export_job(res, &req),
"ListExportJobs" => self.list_export_jobs(&req),
"CancelExportJob" => self.cancel_export_job(res, &req),
"CreateTenant" => self.create_tenant(&req),
"GetTenant" => self.get_tenant(&req),
"ListTenants" => self.list_tenants(&req),
"DeleteTenant" => self.delete_tenant(&req),
"CreateTenantResourceAssociation" => self.create_tenant_resource_association(&req),
"DeleteTenantResourceAssociation" => self.delete_tenant_resource_association(&req),
"ListTenantResources" => self.list_tenant_resources(&req),
"ListResourceTenants" => self.list_resource_tenants(&req),
"GetReputationEntity" => self.get_reputation_entity(res, sub, &req),
"ListReputationEntities" => self.list_reputation_entities(&req),
"UpdateReputationEntityCustomerManagedStatus" => {
self.update_reputation_entity_customer_managed_status(res, sub, &req)
}
"UpdateReputationEntityPolicy" => self.update_reputation_entity_policy(res, sub, &req),
"BatchGetMetricData" => self.batch_get_metric_data(&req),
_ => Err(AwsServiceError::action_not_implemented("ses", action)),
};
if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
self.save_snapshot().await;
}
result
}
fn supported_actions(&self) -> &[&str] {
&[
"GetAccount",
"CreateEmailIdentity",
"ListEmailIdentities",
"GetEmailIdentity",
"DeleteEmailIdentity",
"CreateConfigurationSet",
"ListConfigurationSets",
"GetConfigurationSet",
"DeleteConfigurationSet",
"CreateEmailTemplate",
"ListEmailTemplates",
"GetEmailTemplate",
"UpdateEmailTemplate",
"DeleteEmailTemplate",
"SendEmail",
"SendBulkEmail",
"TagResource",
"UntagResource",
"ListTagsForResource",
"CreateContactList",
"GetContactList",
"ListContactLists",
"UpdateContactList",
"DeleteContactList",
"CreateContact",
"GetContact",
"ListContacts",
"UpdateContact",
"DeleteContact",
"PutSuppressedDestination",
"GetSuppressedDestination",
"DeleteSuppressedDestination",
"ListSuppressedDestinations",
"CreateConfigurationSetEventDestination",
"GetConfigurationSetEventDestinations",
"UpdateConfigurationSetEventDestination",
"DeleteConfigurationSetEventDestination",
"CreateEmailIdentityPolicy",
"GetEmailIdentityPolicies",
"UpdateEmailIdentityPolicy",
"DeleteEmailIdentityPolicy",
"PutEmailIdentityDkimAttributes",
"PutEmailIdentityDkimSigningAttributes",
"PutEmailIdentityFeedbackAttributes",
"PutEmailIdentityMailFromAttributes",
"PutEmailIdentityConfigurationSetAttributes",
"PutConfigurationSetSendingOptions",
"PutConfigurationSetDeliveryOptions",
"PutConfigurationSetTrackingOptions",
"PutConfigurationSetSuppressionOptions",
"PutConfigurationSetReputationOptions",
"PutConfigurationSetVdmOptions",
"PutConfigurationSetArchivingOptions",
"CreateCustomVerificationEmailTemplate",
"GetCustomVerificationEmailTemplate",
"ListCustomVerificationEmailTemplates",
"UpdateCustomVerificationEmailTemplate",
"DeleteCustomVerificationEmailTemplate",
"SendCustomVerificationEmail",
"TestRenderEmailTemplate",
"CreateDedicatedIpPool",
"ListDedicatedIpPools",
"DeleteDedicatedIpPool",
"GetDedicatedIp",
"GetDedicatedIps",
"PutDedicatedIpInPool",
"PutDedicatedIpPoolScalingAttributes",
"PutDedicatedIpWarmupAttributes",
"PutAccountDedicatedIpWarmupAttributes",
"CreateMultiRegionEndpoint",
"GetMultiRegionEndpoint",
"ListMultiRegionEndpoints",
"DeleteMultiRegionEndpoint",
"PutAccountDetails",
"PutAccountSendingAttributes",
"PutAccountSuppressionAttributes",
"PutAccountVdmAttributes",
"CreateImportJob",
"GetImportJob",
"ListImportJobs",
"CreateExportJob",
"GetExportJob",
"ListExportJobs",
"CancelExportJob",
"CreateTenant",
"GetTenant",
"ListTenants",
"DeleteTenant",
"CreateTenantResourceAssociation",
"DeleteTenantResourceAssociation",
"ListTenantResources",
"ListResourceTenants",
"GetReputationEntity",
"ListReputationEntities",
"UpdateReputationEntityCustomerManagedStatus",
"UpdateReputationEntityPolicy",
"BatchGetMetricData",
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use fakecloud_core::service::AwsService;
use http::{HeaderMap, Method};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
fn make_state() -> SharedSesState {
Arc::new(RwLock::new(
fakecloud_core::multi_account::MultiAccountState::new(
"123456789012",
"us-east-1",
"http://localhost:4569",
),
))
}
fn make_request(method: Method, path: &str, body: &str) -> AwsRequest {
make_request_with_query(method, path, body, "", HashMap::new())
}
fn make_request_with_query(
method: Method,
path: &str,
body: &str,
raw_query: &str,
query_params: HashMap<String, String>,
) -> AwsRequest {
let path_segments: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
AwsRequest {
service: "ses".to_string(),
action: String::new(),
region: "us-east-1".to_string(),
account_id: "123456789012".to_string(),
request_id: "test-request-id".to_string(),
headers: HeaderMap::new(),
query_params,
body: Bytes::from(body.to_string()),
path_segments,
raw_path: path.to_string(),
raw_query: raw_query.to_string(),
method,
is_query_protocol: false,
access_key_id: None,
principal: None,
}
}
#[tokio::test]
async fn test_identity_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["VerifiedForSendingStatus"], true);
assert_eq!(body["IdentityType"], "EMAIL_ADDRESS");
let req = make_request(Method::GET, "/v2/email/identities", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["EmailIdentities"].as_array().unwrap().len(), 1);
let req = make_request(Method::GET, "/v2/email/identities/test%40example.com", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["VerifiedForSendingStatus"], true);
assert_eq!(body["DkimAttributes"]["Status"], "SUCCESS");
let req = make_request(
Method::DELETE,
"/v2/email/identities/test%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/identities/test%40example.com", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_domain_identity() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["IdentityType"], "DOMAIN");
}
#[tokio::test]
async fn test_duplicate_identity() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_template_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{"TemplateName": "welcome", "TemplateContent": {"Subject": "Welcome", "Html": "<h1>Hi</h1>", "Text": "Hi"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/templates/welcome", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TemplateName"], "welcome");
assert_eq!(body["TemplateContent"]["Subject"], "Welcome");
let req = make_request(
Method::PUT,
"/v2/email/templates/welcome",
r#"{"TemplateContent": {"Subject": "Updated Welcome"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/templates/welcome", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TemplateContent"]["Subject"], "Updated Welcome");
let req = make_request(Method::GET, "/v2/email/templates", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TemplatesMetadata"].as_array().unwrap().len(), 1);
let req = make_request(Method::DELETE, "/v2/email/templates/welcome", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/templates/welcome", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_send_email() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/outbound-emails",
r#"{
"FromEmailAddress": "sender@example.com",
"Destination": {
"ToAddresses": ["recipient@example.com"]
},
"Content": {
"Simple": {
"Subject": {"Data": "Test Subject"},
"Body": {
"Text": {"Data": "Hello world"},
"Html": {"Data": "<p>Hello world</p>"}
}
}
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["MessageId"].as_str().is_some());
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert_eq!(s.sent_emails.len(), 1);
assert_eq!(s.sent_emails[0].from, "sender@example.com");
assert_eq!(s.sent_emails[0].to, vec!["recipient@example.com"]);
assert_eq!(s.sent_emails[0].subject.as_deref(), Some("Test Subject"));
}
#[tokio::test]
async fn test_get_account() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["SendingEnabled"], true);
assert!(body["SendQuota"]["Max24HourSend"].as_f64().unwrap() > 0.0);
}
#[tokio::test]
async fn test_configuration_set_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/my-config", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ConfigurationSetName"], "my-config");
let req = make_request(Method::GET, "/v2/email/configuration-sets", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ConfigurationSets"].as_array().unwrap().len(), 1);
let req = make_request(Method::DELETE, "/v2/email/configuration-sets/my-config", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/my-config", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_send_email_raw_content() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/outbound-emails",
r#"{
"FromEmailAddress": "sender@example.com",
"Destination": {
"ToAddresses": ["to@example.com"]
},
"Content": {
"Raw": {
"Data": "From: sender@example.com\r\nTo: to@example.com\r\nSubject: Raw\r\n\r\nBody"
}
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["MessageId"].as_str().is_some());
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert_eq!(s.sent_emails.len(), 1);
assert!(s.sent_emails[0].raw_data.is_some());
assert!(
s.sent_emails[0].subject.is_none(),
"Raw emails should not have parsed subject"
);
}
#[tokio::test]
async fn test_send_email_template_content() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/outbound-emails",
r#"{
"FromEmailAddress": "sender@example.com",
"Destination": {
"ToAddresses": ["to@example.com"]
},
"Content": {
"Template": {
"TemplateName": "welcome",
"TemplateData": "{\"name\": \"Alice\"}"
}
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert_eq!(s.sent_emails.len(), 1);
assert_eq!(s.sent_emails[0].template_name.as_deref(), Some("welcome"));
assert_eq!(
s.sent_emails[0].template_data.as_deref(),
Some("{\"name\": \"Alice\"}")
);
}
#[tokio::test]
async fn test_send_email_missing_content() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/outbound-emails",
r#"{"FromEmailAddress": "sender@example.com", "Destination": {"ToAddresses": ["to@example.com"]}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_send_email_with_cc_and_bcc() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/outbound-emails",
r#"{
"FromEmailAddress": "sender@example.com",
"Destination": {
"ToAddresses": ["to@example.com"],
"CcAddresses": ["cc@example.com"],
"BccAddresses": ["bcc@example.com"]
},
"Content": {
"Simple": {
"Subject": {"Data": "Test"},
"Body": {"Text": {"Data": "Hello"}}
}
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert_eq!(s.sent_emails[0].cc, vec!["cc@example.com"]);
assert_eq!(s.sent_emails[0].bcc, vec!["bcc@example.com"]);
}
#[tokio::test]
async fn test_send_bulk_email() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/outbound-bulk-emails",
r#"{
"FromEmailAddress": "sender@example.com",
"DefaultContent": {
"Template": {
"TemplateName": "bulk-template",
"TemplateData": "{\"default\": true}"
}
},
"BulkEmailEntries": [
{"Destination": {"ToAddresses": ["a@example.com"]}},
{"Destination": {"ToAddresses": ["b@example.com"]}}
]
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let results = body["BulkEmailEntryResults"].as_array().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["Status"], "SUCCESS");
assert_eq!(results[1]["Status"], "SUCCESS");
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert_eq!(s.sent_emails.len(), 2);
assert_eq!(s.sent_emails[0].to, vec!["a@example.com"]);
assert_eq!(s.sent_emails[1].to, vec!["b@example.com"]);
}
#[tokio::test]
async fn test_send_bulk_email_empty_entries() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/outbound-bulk-emails",
r#"{"FromEmailAddress": "s@example.com", "BulkEmailEntries": []}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_delete_nonexistent_identity() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::DELETE,
"/v2/email/identities/nobody%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_duplicate_configuration_set() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "dup-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "dup-config"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_duplicate_template() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{"TemplateName": "dup-tmpl", "TemplateContent": {}}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{"TemplateName": "dup-tmpl", "TemplateContent": {}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_delete_nonexistent_template() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::DELETE, "/v2/email/templates/nope", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_delete_nonexistent_configuration_set() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::DELETE, "/v2/email/configuration-sets/nope", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_unknown_route() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/unknown-resource", "");
let result = svc.handle(req).await;
assert!(result.is_err(), "Unknown route should return error");
}
#[tokio::test]
async fn test_update_nonexistent_template() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/templates/nonexistent",
r#"{"TemplateContent": {"Subject": "Updated"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_invalid_json_body() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::POST, "/v2/email/identities", "not valid json {{{");
let result = svc.handle(req).await;
assert!(result.is_err(), "Invalid JSON body should return error");
}
#[tokio::test]
async fn test_create_identity_missing_name() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::POST, "/v2/email/identities", r#"{}"#);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_contact_list_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{
"ContactListName": "my-list",
"Description": "Test list",
"Topics": [
{
"TopicName": "newsletters",
"DisplayName": "Newsletters",
"Description": "Weekly newsletters",
"DefaultSubscriptionStatus": "OPT_IN"
}
]
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/contact-lists/my-list", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ContactListName"], "my-list");
assert_eq!(body["Description"], "Test list");
assert_eq!(body["Topics"][0]["TopicName"], "newsletters");
assert_eq!(body["Topics"][0]["DefaultSubscriptionStatus"], "OPT_IN");
assert!(body["CreatedTimestamp"].as_f64().is_some());
assert!(body["LastUpdatedTimestamp"].as_f64().is_some());
let req = make_request(Method::GET, "/v2/email/contact-lists", "{}");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ContactLists"].as_array().unwrap().len(), 1);
assert_eq!(body["ContactLists"][0]["ContactListName"], "my-list");
let req = make_request(
Method::PUT,
"/v2/email/contact-lists/my-list",
r#"{
"Description": "Updated description",
"Topics": [
{
"TopicName": "newsletters",
"DisplayName": "Updated Newsletters",
"Description": "Updated desc",
"DefaultSubscriptionStatus": "OPT_OUT"
},
{
"TopicName": "promotions",
"DisplayName": "Promotions",
"Description": "Promo emails",
"DefaultSubscriptionStatus": "OPT_OUT"
}
]
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/contact-lists/my-list", "{}");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Description"], "Updated description");
assert_eq!(body["Topics"].as_array().unwrap().len(), 2);
let req = make_request(Method::DELETE, "/v2/email/contact-lists/my-list", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/contact-lists/my-list", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_duplicate_contact_list() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "dup-list"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "dup-list"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_contact_list_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/contact-lists/nonexistent", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_contact_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{
"ContactListName": "my-list",
"Topics": [
{
"TopicName": "newsletters",
"DisplayName": "Newsletters",
"Description": "Weekly newsletters",
"DefaultSubscriptionStatus": "OPT_OUT"
}
]
}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/contact-lists/my-list/contacts",
r#"{
"EmailAddress": "user@example.com",
"TopicPreferences": [
{"TopicName": "newsletters", "SubscriptionStatus": "OPT_IN"}
],
"UnsubscribeAll": false
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/contact-lists/my-list/contacts/user%40example.com",
"{}",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["EmailAddress"], "user@example.com");
assert_eq!(body["ContactListName"], "my-list");
assert_eq!(body["UnsubscribeAll"], false);
assert_eq!(body["TopicPreferences"][0]["TopicName"], "newsletters");
assert_eq!(body["TopicPreferences"][0]["SubscriptionStatus"], "OPT_IN");
assert_eq!(
body["TopicDefaultPreferences"][0]["SubscriptionStatus"],
"OPT_OUT"
);
assert!(body["CreatedTimestamp"].as_f64().is_some());
let req = make_request(
Method::GET,
"/v2/email/contact-lists/my-list/contacts",
"{}",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Contacts"].as_array().unwrap().len(), 1);
assert_eq!(body["Contacts"][0]["EmailAddress"], "user@example.com");
let req = make_request(
Method::PUT,
"/v2/email/contact-lists/my-list/contacts/user%40example.com",
r#"{
"TopicPreferences": [
{"TopicName": "newsletters", "SubscriptionStatus": "OPT_OUT"}
],
"UnsubscribeAll": true
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/contact-lists/my-list/contacts/user%40example.com",
"{}",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["UnsubscribeAll"], true);
assert_eq!(body["TopicPreferences"][0]["SubscriptionStatus"], "OPT_OUT");
let req = make_request(
Method::DELETE,
"/v2/email/contact-lists/my-list/contacts/user%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/contact-lists/my-list/contacts/user%40example.com",
"{}",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_duplicate_contact() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "my-list"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/contact-lists/my-list/contacts",
r#"{"EmailAddress": "dup@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/contact-lists/my-list/contacts",
r#"{"EmailAddress": "dup@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_contact_in_nonexistent_list() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists/no-such-list/contacts",
r#"{"EmailAddress": "user@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_nonexistent_contact() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "my-list"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::GET,
"/v2/email/contact-lists/my-list/contacts/nobody%40example.com",
"{}",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_delete_contact_list_cascades_contacts() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "my-list"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/contact-lists/my-list/contacts",
r#"{"EmailAddress": "user@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(Method::DELETE, "/v2/email/contact-lists/my-list", "");
svc.handle(req).await.unwrap();
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert!(!s.contacts.contains_key("my-list"));
}
#[tokio::test]
async fn test_tag_resource() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/tags",
r#"{"ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/test@example.com", "Tags": [{"Key": "env", "Value": "prod"}, {"Key": "team", "Value": "backend"}]}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let mut qp = HashMap::new();
qp.insert(
"ResourceArn".to_string(),
"arn:aws:ses:us-east-1:123456789012:identity/test@example.com".to_string(),
);
let req = make_request_with_query(
Method::GET,
"/v2/email/tags",
"",
"ResourceArn=arn%3Aaws%3Ases%3Aus-east-1%3A123456789012%3Aidentity%2Ftest%40example.com",
qp,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let tags = body["Tags"].as_array().unwrap();
assert_eq!(tags.len(), 2);
}
#[tokio::test]
async fn test_untag_resource() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let arn = "arn:aws:ses:us-east-1:123456789012:identity/test@example.com";
let req = make_request(
Method::POST,
"/v2/email/tags",
&format!(
r#"{{"ResourceArn": "{arn}", "Tags": [{{"Key": "env", "Value": "prod"}}, {{"Key": "team", "Value": "backend"}}]}}"#
),
);
svc.handle(req).await.unwrap();
let mut qp = HashMap::new();
qp.insert("ResourceArn".to_string(), arn.to_string());
qp.insert("TagKeys".to_string(), "env".to_string());
let raw_query = format!("ResourceArn={}&TagKeys=env", urlencoded(arn));
let req = make_request_with_query(Method::DELETE, "/v2/email/tags", "", &raw_query, qp);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let _mas_r = state.read();
let s = _mas_r.default_ref();
let tags = s.tags.get(arn).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags.get("team").unwrap(), "backend");
}
#[tokio::test]
async fn test_tag_nonexistent_resource() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/tags",
r#"{"ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/nope", "Tags": [{"Key": "k", "Value": "v"}]}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_delete_identity_removes_tags() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let arn = "arn:aws:ses:us-east-1:123456789012:identity/test@example.com";
let req = make_request(
Method::POST,
"/v2/email/tags",
&format!(r#"{{"ResourceArn": "{arn}", "Tags": [{{"Key": "k", "Value": "v"}}]}}"#),
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::DELETE,
"/v2/email/identities/test%40example.com",
"",
);
svc.handle(req).await.unwrap();
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert!(!s.tags.contains_key(arn));
}
#[tokio::test]
async fn test_delete_config_set_removes_tags() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
svc.handle(req).await.unwrap();
let arn = "arn:aws:ses:us-east-1:123456789012:configuration-set/my-config";
let req = make_request(
Method::POST,
"/v2/email/tags",
&format!(r#"{{"ResourceArn": "{arn}", "Tags": [{{"Key": "k", "Value": "v"}}]}}"#),
);
svc.handle(req).await.unwrap();
let req = make_request(Method::DELETE, "/v2/email/configuration-sets/my-config", "");
svc.handle(req).await.unwrap();
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert!(!s.tags.contains_key(arn));
}
#[tokio::test]
async fn test_delete_contact_list_removes_tags() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "my-list"}"#,
);
svc.handle(req).await.unwrap();
let arn = "arn:aws:ses:us-east-1:123456789012:contact-list/my-list";
let req = make_request(
Method::POST,
"/v2/email/tags",
&format!(r#"{{"ResourceArn": "{arn}", "Tags": [{{"Key": "k", "Value": "v"}}]}}"#),
);
svc.handle(req).await.unwrap();
let req = make_request(Method::DELETE, "/v2/email/contact-lists/my-list", "");
svc.handle(req).await.unwrap();
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert!(!s.tags.contains_key(arn));
}
fn urlencoded(s: &str) -> String {
form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
#[tokio::test]
async fn test_suppressed_destination_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/suppression/addresses",
r#"{"EmailAddress": "bounce@example.com", "Reason": "BOUNCE"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/suppression/addresses/bounce%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["SuppressedDestination"]["EmailAddress"],
"bounce@example.com"
);
assert_eq!(body["SuppressedDestination"]["Reason"], "BOUNCE");
assert!(body["SuppressedDestination"]["LastUpdateTime"]
.as_f64()
.is_some());
let req = make_request(Method::GET, "/v2/email/suppression/addresses", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["SuppressedDestinationSummaries"]
.as_array()
.unwrap()
.len(),
1
);
let req = make_request(
Method::DELETE,
"/v2/email/suppression/addresses/bounce%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/suppression/addresses/bounce%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_suppressed_destination_complaint() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/suppression/addresses",
r#"{"EmailAddress": "complaint@example.com", "Reason": "COMPLAINT"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/suppression/addresses/complaint%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["SuppressedDestination"]["Reason"], "COMPLAINT");
}
#[tokio::test]
async fn test_suppressed_destination_invalid_reason() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/suppression/addresses",
r#"{"EmailAddress": "bad@example.com", "Reason": "INVALID"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_suppressed_destination_upsert() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/suppression/addresses",
r#"{"EmailAddress": "user@example.com", "Reason": "BOUNCE"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/suppression/addresses",
r#"{"EmailAddress": "user@example.com", "Reason": "COMPLAINT"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::GET,
"/v2/email/suppression/addresses/user%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["SuppressedDestination"]["Reason"], "COMPLAINT");
}
#[tokio::test]
async fn test_delete_nonexistent_suppressed_destination() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::DELETE,
"/v2/email/suppression/addresses/nobody%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_event_destination_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/configuration-sets/my-config/event-destinations",
r#"{
"EventDestinationName": "my-dest",
"EventDestination": {
"Enabled": true,
"MatchingEventTypes": ["SEND", "BOUNCE"],
"SnsDestination": {"TopicArn": "arn:aws:sns:us-east-1:123456789012:my-topic"}
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/configuration-sets/my-config/event-destinations",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let dests = body["EventDestinations"].as_array().unwrap();
assert_eq!(dests.len(), 1);
assert_eq!(dests[0]["Name"], "my-dest");
assert_eq!(dests[0]["Enabled"], true);
assert_eq!(dests[0]["MatchingEventTypes"], json!(["SEND", "BOUNCE"]));
assert_eq!(
dests[0]["SnsDestination"]["TopicArn"],
"arn:aws:sns:us-east-1:123456789012:my-topic"
);
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/my-config/event-destinations/my-dest",
r#"{
"EventDestination": {
"Enabled": false,
"MatchingEventTypes": ["DELIVERY"],
"SnsDestination": {"TopicArn": "arn:aws:sns:us-east-1:123456789012:updated-topic"}
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/configuration-sets/my-config/event-destinations",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let dests = body["EventDestinations"].as_array().unwrap();
assert_eq!(dests[0]["Enabled"], false);
assert_eq!(dests[0]["MatchingEventTypes"], json!(["DELIVERY"]));
let req = make_request(
Method::DELETE,
"/v2/email/configuration-sets/my-config/event-destinations/my-dest",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/configuration-sets/my-config/event-destinations",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["EventDestinations"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_event_destination_config_set_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets/nonexistent/event-destinations",
r#"{
"EventDestinationName": "dest",
"EventDestination": {"Enabled": true, "MatchingEventTypes": ["SEND"]}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_event_destination_duplicate() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
svc.handle(req).await.unwrap();
let body = r#"{
"EventDestinationName": "dup-dest",
"EventDestination": {"Enabled": true, "MatchingEventTypes": ["SEND"]}
}"#;
let req = make_request(
Method::POST,
"/v2/email/configuration-sets/my-config/event-destinations",
body,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/configuration-sets/my-config/event-destinations",
body,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_update_nonexistent_event_destination() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/my-config/event-destinations/nonexistent",
r#"{"EventDestination": {"Enabled": true, "MatchingEventTypes": ["SEND"]}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_delete_nonexistent_event_destination() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::DELETE,
"/v2/email/configuration-sets/my-config/event-destinations/nonexistent",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_event_destinations_cleaned_on_config_set_delete() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "my-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/configuration-sets/my-config/event-destinations",
r#"{
"EventDestinationName": "dest1",
"EventDestination": {"Enabled": true, "MatchingEventTypes": ["SEND"]}
}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(Method::DELETE, "/v2/email/configuration-sets/my-config", "");
svc.handle(req).await.unwrap();
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert!(!s.event_destinations.contains_key("my-config"));
}
#[tokio::test]
async fn test_identity_policy_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let policy_doc = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}"#;
let req = make_request(
Method::POST,
"/v2/email/identities/test%40example.com/policies/my-policy",
&format!(
r#"{{"Policy": {}}}"#,
serde_json::to_string(policy_doc).unwrap()
),
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/identities/test%40example.com/policies",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["Policies"]["my-policy"].is_string());
assert_eq!(body["Policies"]["my-policy"].as_str().unwrap(), policy_doc);
let updated_doc = r#"{"Version":"2012-10-17","Statement":[]}"#;
let req = make_request(
Method::PUT,
"/v2/email/identities/test%40example.com/policies/my-policy",
&format!(
r#"{{"Policy": {}}}"#,
serde_json::to_string(updated_doc).unwrap()
),
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/identities/test%40example.com/policies",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Policies"]["my-policy"].as_str().unwrap(), updated_doc);
let req = make_request(
Method::DELETE,
"/v2/email/identities/test%40example.com/policies/my-policy",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/identities/test%40example.com/policies",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["Policies"].as_object().unwrap().is_empty());
}
#[tokio::test]
async fn test_identity_policy_identity_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities/nonexistent%40example.com/policies/my-policy",
r#"{"Policy": "{}"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_identity_policy_duplicate() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/identities/test%40example.com/policies/my-policy",
r#"{"Policy": "{}"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/identities/test%40example.com/policies/my-policy",
r#"{"Policy": "{}"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_update_nonexistent_policy() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/identities/test%40example.com/policies/nonexistent",
r#"{"Policy": "{}"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_delete_nonexistent_policy() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::DELETE,
"/v2/email/identities/test%40example.com/policies/nonexistent",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_policies_cleaned_on_identity_delete() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/identities/test%40example.com/policies/my-policy",
r#"{"Policy": "{}"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::DELETE,
"/v2/email/identities/test%40example.com",
"",
);
svc.handle(req).await.unwrap();
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert!(!s.identity_policies.contains_key("test@example.com"));
}
#[tokio::test]
async fn test_put_email_identity_dkim_attributes() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/identities/example.com/dkim",
r#"{"SigningEnabled": false}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/identities/example.com", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DkimAttributes"]["SigningEnabled"], false);
}
#[tokio::test]
async fn test_put_email_identity_dkim_attributes_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/identities/nonexistent.com/dkim",
r#"{"SigningEnabled": false}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_put_email_identity_dkim_signing_attributes() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/identities/example.com/dkim/signing",
r#"{"SigningAttributesOrigin": "EXTERNAL", "SigningAttributes": {"DomainSigningPrivateKey": "key123", "DomainSigningSelector": "sel1"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DkimStatus"], "SUCCESS");
assert!(!body["DkimTokens"].as_array().unwrap().is_empty());
let req = make_request(Method::GET, "/v2/email/identities/example.com", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["DkimAttributes"]["SigningAttributesOrigin"],
"EXTERNAL"
);
}
#[tokio::test]
async fn test_put_email_identity_feedback_attributes() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "test@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/identities/test%40example.com/feedback",
r#"{"EmailForwardingEnabled": false}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/identities/test%40example.com", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["FeedbackForwardingStatus"], false);
}
#[tokio::test]
async fn test_put_email_identity_mail_from_attributes() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/identities/example.com/mail-from",
r#"{"MailFromDomain": "mail.example.com", "BehaviorOnMxFailure": "REJECT_MESSAGE"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/identities/example.com", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["MailFromAttributes"]["MailFromDomain"],
"mail.example.com"
);
assert_eq!(
body["MailFromAttributes"]["BehaviorOnMxFailure"],
"REJECT_MESSAGE"
);
}
#[tokio::test]
async fn test_put_email_identity_configuration_set_attributes() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/identities/example.com/configuration-set",
r#"{"ConfigurationSetName": "my-config"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/identities/example.com", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ConfigurationSetName"], "my-config");
}
#[tokio::test]
async fn test_put_configuration_set_sending_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/sending",
r#"{"SendingEnabled": false}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["SendingOptions"]["SendingEnabled"], false);
}
#[tokio::test]
async fn test_put_configuration_set_sending_options_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/nonexistent/sending",
r#"{"SendingEnabled": false}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_put_configuration_set_delivery_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/delivery-options",
r#"{"TlsPolicy": "REQUIRE", "SendingPoolName": "my-pool"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DeliveryOptions"]["TlsPolicy"], "REQUIRE");
assert_eq!(body["DeliveryOptions"]["SendingPoolName"], "my-pool");
}
#[tokio::test]
async fn test_put_configuration_set_tracking_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/tracking-options",
r#"{"CustomRedirectDomain": "track.example.com", "HttpsPolicy": "REQUIRE"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["TrackingOptions"]["CustomRedirectDomain"],
"track.example.com"
);
assert_eq!(body["TrackingOptions"]["HttpsPolicy"], "REQUIRE");
}
#[tokio::test]
async fn test_put_configuration_set_suppression_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/suppression-options",
r#"{"SuppressedReasons": ["BOUNCE", "COMPLAINT"]}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let reasons = body["SuppressionOptions"]["SuppressedReasons"]
.as_array()
.unwrap();
assert_eq!(reasons.len(), 2);
}
#[tokio::test]
async fn test_put_configuration_set_reputation_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/reputation-options",
r#"{"ReputationMetricsEnabled": true}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ReputationOptions"]["ReputationMetricsEnabled"], true);
}
#[tokio::test]
async fn test_put_configuration_set_vdm_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/vdm-options",
r#"{"DashboardOptions": {"EngagementMetrics": "ENABLED"}, "GuardianOptions": {"OptimizedSharedDelivery": "ENABLED"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["VdmOptions"]["DashboardOptions"]["EngagementMetrics"],
"ENABLED"
);
}
#[tokio::test]
async fn test_put_configuration_set_archiving_options() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName": "test-config"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/configuration-sets/test-config/archiving-options",
r#"{"ArchiveArn": "arn:aws:ses:us-east-1:123456789012:mailmanager-archive/my-archive"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/configuration-sets/test-config", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["ArchivingOptions"]["ArchiveArn"]
.as_str()
.unwrap()
.contains("my-archive"));
}
#[tokio::test]
async fn test_custom_verification_email_template_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/custom-verification-email-templates",
r#"{
"TemplateName": "my-verification",
"FromEmailAddress": "noreply@example.com",
"TemplateSubject": "Verify your email",
"TemplateContent": "<h1>Please verify</h1>",
"SuccessRedirectionURL": "https://example.com/success",
"FailureRedirectionURL": "https://example.com/failure"
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/custom-verification-email-templates/my-verification",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TemplateName"], "my-verification");
assert_eq!(body["FromEmailAddress"], "noreply@example.com");
assert_eq!(body["TemplateSubject"], "Verify your email");
assert_eq!(body["TemplateContent"], "<h1>Please verify</h1>");
assert_eq!(body["SuccessRedirectionURL"], "https://example.com/success");
assert_eq!(body["FailureRedirectionURL"], "https://example.com/failure");
let req = make_request(
Method::GET,
"/v2/email/custom-verification-email-templates",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["CustomVerificationEmailTemplates"]
.as_array()
.unwrap()
.len(),
1
);
let req = make_request(
Method::PUT,
"/v2/email/custom-verification-email-templates/my-verification",
r#"{"TemplateSubject": "Updated subject"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/custom-verification-email-templates/my-verification",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TemplateSubject"], "Updated subject");
let req = make_request(
Method::DELETE,
"/v2/email/custom-verification-email-templates/my-verification",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/custom-verification-email-templates/my-verification",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_duplicate_custom_verification_template() {
let state = make_state();
let svc = SesV2Service::new(state);
let body = r#"{
"TemplateName": "dup-tmpl",
"FromEmailAddress": "a@b.com",
"TemplateSubject": "s",
"TemplateContent": "c",
"SuccessRedirectionURL": "https://ok",
"FailureRedirectionURL": "https://fail"
}"#;
let req = make_request(
Method::POST,
"/v2/email/custom-verification-email-templates",
body,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/custom-verification-email-templates",
body,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_send_custom_verification_email() {
let state = make_state();
let svc = SesV2Service::new(state.clone());
let req = make_request(
Method::POST,
"/v2/email/custom-verification-email-templates",
r#"{
"TemplateName": "verify",
"FromEmailAddress": "a@b.com",
"TemplateSubject": "Verify",
"TemplateContent": "content",
"SuccessRedirectionURL": "https://ok",
"FailureRedirectionURL": "https://fail"
}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/outbound-custom-verification-emails",
r#"{"EmailAddress": "user@example.com", "TemplateName": "verify"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["MessageId"].as_str().is_some());
let _mas_r = state.read();
let s = _mas_r.default_ref();
assert_eq!(s.sent_emails.len(), 1);
assert_eq!(s.sent_emails[0].to, vec!["user@example.com"]);
}
#[tokio::test]
async fn test_send_custom_verification_email_template_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/outbound-custom-verification-emails",
r#"{"EmailAddress": "user@example.com", "TemplateName": "nonexistent"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_render_email_template() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{
"TemplateName": "greet",
"TemplateContent": {
"Subject": "Hello {{name}}",
"Html": "<h1>Welcome, {{name}}!</h1><p>Your code is {{code}}.</p>",
"Text": "Welcome, {{name}}! Your code is {{code}}."
}
}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/templates/greet/render",
r#"{"TemplateData": "{\"name\": \"Alice\", \"code\": \"1234\"}"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let rendered = body["RenderedTemplate"].as_str().unwrap();
assert!(rendered.contains("Subject: Hello Alice"));
assert!(rendered.contains("Welcome, Alice!"));
assert!(rendered.contains("Your code is 1234."));
}
#[tokio::test]
async fn test_render_email_template_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/templates/nonexistent/render",
r#"{"TemplateData": "{}"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_render_email_template_missing_data() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{"TemplateName": "t1", "TemplateContent": {"Subject": "Hi"}}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(Method::POST, "/v2/email/templates/t1/render", r#"{}"#);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_dedicated_ip_pool_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/dedicated-ip-pools",
r#"{"PoolName": "my-pool", "ScalingMode": "STANDARD"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/dedicated-ip-pools", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DedicatedIpPools"].as_array().unwrap().len(), 1);
let req = make_request(
Method::POST,
"/v2/email/dedicated-ip-pools",
r#"{"PoolName": "my-pool"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
let req = make_request(Method::DELETE, "/v2/email/dedicated-ip-pools/my-pool", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::DELETE, "/v2/email/dedicated-ip-pools/my-pool", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_managed_pool_generates_ips() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/dedicated-ip-pools",
r#"{"PoolName": "managed-pool", "ScalingMode": "MANAGED"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request_with_query(
Method::GET,
"/v2/email/dedicated-ips",
"",
"PoolName=managed-pool",
{
let mut m = HashMap::new();
m.insert("PoolName".to_string(), "managed-pool".to_string());
m
},
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let ips = body["DedicatedIps"].as_array().unwrap();
assert_eq!(ips.len(), 3);
assert_eq!(ips[0]["WarmupStatus"], "NOT_APPLICABLE");
assert_eq!(ips[0]["WarmupPercentage"], -1);
}
#[tokio::test]
async fn test_dedicated_ip_operations() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/dedicated-ip-pools",
r#"{"PoolName": "pool-a", "ScalingMode": "MANAGED"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/dedicated-ip-pools",
r#"{"PoolName": "pool-b", "ScalingMode": "STANDARD"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(Method::GET, "/v2/email/dedicated-ips/198.51.100.1", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DedicatedIp"]["PoolName"], "pool-a");
let req = make_request(
Method::PUT,
"/v2/email/dedicated-ips/198.51.100.1/pool",
r#"{"DestinationPoolName": "pool-b"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/dedicated-ips/198.51.100.1", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DedicatedIp"]["PoolName"], "pool-b");
let req = make_request(
Method::PUT,
"/v2/email/dedicated-ips/198.51.100.1/warmup",
r#"{"WarmupPercentage": 50}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/dedicated-ips/198.51.100.1", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DedicatedIp"]["WarmupPercentage"], 50);
assert_eq!(body["DedicatedIp"]["WarmupStatus"], "IN_PROGRESS");
let req = make_request(Method::GET, "/v2/email/dedicated-ips/1.2.3.4", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_pool_scaling_attributes() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/dedicated-ip-pools",
r#"{"PoolName": "scalable", "ScalingMode": "STANDARD"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::PUT,
"/v2/email/dedicated-ip-pools/scalable/scaling",
r#"{"ScalingMode": "MANAGED"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::PUT,
"/v2/email/dedicated-ip-pools/scalable/scaling",
r#"{"ScalingMode": "STANDARD"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_account_dedicated_ip_warmup() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/account/dedicated-ips/warmup",
r#"{"AutoWarmupEnabled": true}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["DedicatedIpAutoWarmupEnabled"], true);
}
#[tokio::test]
async fn test_multi_region_endpoint_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/multi-region-endpoints",
r#"{"EndpointName": "global-ep", "Details": {"RoutesDetails": [{"Region": "us-west-2"}]}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Status"], "READY");
assert!(body["EndpointId"].as_str().is_some());
let req = make_request(
Method::GET,
"/v2/email/multi-region-endpoints/global-ep",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["EndpointName"], "global-ep");
assert_eq!(body["Status"], "READY");
let routes = body["Routes"].as_array().unwrap();
assert!(!routes.is_empty());
let req = make_request(Method::GET, "/v2/email/multi-region-endpoints", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["MultiRegionEndpoints"].as_array().unwrap().len(), 1);
let req = make_request(
Method::POST,
"/v2/email/multi-region-endpoints",
r#"{"EndpointName": "global-ep", "Details": {"RoutesDetails": [{"Region": "eu-west-1"}]}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
let req = make_request(
Method::DELETE,
"/v2/email/multi-region-endpoints/global-ep",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Status"], "DELETING");
let req = make_request(
Method::GET,
"/v2/email/multi-region-endpoints/global-ep",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_account_details() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/account/details",
r#"{"MailType": "TRANSACTIONAL", "WebsiteURL": "https://example.com", "UseCaseDescription": "Testing"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Details"]["MailType"], "TRANSACTIONAL");
assert_eq!(body["Details"]["WebsiteURL"], "https://example.com");
assert_eq!(body["Details"]["UseCaseDescription"], "Testing");
}
#[tokio::test]
async fn test_account_sending_attributes() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/account/sending",
r#"{"SendingEnabled": false}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["SendingEnabled"], false);
let req = make_request(
Method::PUT,
"/v2/email/account/sending",
r#"{"SendingEnabled": true}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["SendingEnabled"], true);
}
#[tokio::test]
async fn test_account_suppression_attributes() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/account/suppression",
r#"{"SuppressedReasons": ["BOUNCE", "COMPLAINT"]}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let reasons = body["SuppressionAttributes"]["SuppressedReasons"]
.as_array()
.unwrap();
assert_eq!(reasons.len(), 2);
}
#[tokio::test]
async fn test_account_vdm_attributes() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/account/vdm",
r#"{"VdmAttributes": {"VdmEnabled": "ENABLED", "DashboardAttributes": {"EngagementMetrics": "ENABLED"}}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/account", "");
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["VdmAttributes"]["VdmEnabled"], "ENABLED");
}
#[tokio::test]
async fn test_import_job_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/import-jobs",
r#"{
"ImportDestination": {
"SuppressionListDestination": {"SuppressionListImportAction": "PUT"}
},
"ImportDataSource": {
"S3Url": "s3://bucket/file.csv",
"DataFormat": "CSV"
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let job_id = body["JobId"].as_str().unwrap().to_string();
let req = make_request(
Method::GET,
&format!("/v2/email/import-jobs/{}", job_id),
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["JobId"], job_id);
assert_eq!(body["JobStatus"], "COMPLETED");
let req = make_request(Method::POST, "/v2/email/import-jobs/list", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ImportJobs"].as_array().unwrap().len(), 1);
let req = make_request(Method::GET, "/v2/email/import-jobs/nonexistent", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_export_job_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/export-jobs",
r#"{
"ExportDataSource": {
"MetricsDataSource": {
"Dimensions": {},
"Namespace": "VDM",
"Metrics": []
}
},
"ExportDestination": {
"DataFormat": "CSV",
"S3Url": "s3://bucket/export"
}
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let job_id = body["JobId"].as_str().unwrap().to_string();
let req = make_request(
Method::GET,
&format!("/v2/email/export-jobs/{}", job_id),
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["JobId"], job_id);
assert_eq!(body["JobStatus"], "COMPLETED");
assert_eq!(body["ExportSourceType"], "METRICS_DATA");
let req = make_request(Method::POST, "/v2/email/list-export-jobs", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ExportJobs"].as_array().unwrap().len(), 1);
let req = make_request(
Method::PUT,
&format!("/v2/email/export-jobs/{}/cancel", job_id),
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_tenant_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/tenants",
r#"{"TenantName": "my-tenant"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TenantName"], "my-tenant");
assert!(body["TenantId"].as_str().is_some());
assert_eq!(body["SendingStatus"], "ENABLED");
let req = make_request(
Method::POST,
"/v2/email/tenants/get",
r#"{"TenantName": "my-tenant"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Tenant"]["TenantName"], "my-tenant");
let req = make_request(Method::POST, "/v2/email/tenants/list", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Tenants"].as_array().unwrap().len(), 1);
let req = make_request(
Method::POST,
"/v2/email/tenants/resources",
r#"{"TenantName": "my-tenant", "ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/test@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::POST,
"/v2/email/tenants/resources/list",
r#"{"TenantName": "my-tenant"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["TenantResources"].as_array().unwrap().len(), 1);
let req = make_request(
Method::POST,
"/v2/email/resources/tenants/list",
r#"{"ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/test@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ResourceTenants"].as_array().unwrap().len(), 1);
let req = make_request(
Method::POST,
"/v2/email/tenants/resources/delete",
r#"{"TenantName": "my-tenant", "ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/test@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::POST,
"/v2/email/tenants/resources/list",
r#"{"TenantName": "my-tenant"}"#,
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert!(body["TenantResources"].as_array().unwrap().is_empty());
let req = make_request(
Method::POST,
"/v2/email/tenants/delete",
r#"{"TenantName": "my-tenant"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::POST,
"/v2/email/tenants/get",
r#"{"TenantName": "my-tenant"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_reputation_entity() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::GET,
"/v2/email/reputation/entities/RESOURCE/arn%3Aaws%3Ases%3Aus-east-1%3A123456789012%3Aidentity%2Ftest%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["ReputationEntity"]["SendingStatusAggregate"],
"ENABLED"
);
let req = make_request(
Method::PUT,
"/v2/email/reputation/entities/RESOURCE/arn%3Aaws%3Ases%3Aus-east-1%3A123456789012%3Aidentity%2Ftest%40example.com/customer-managed-status",
r#"{"SendingStatus": "DISABLED"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::PUT,
"/v2/email/reputation/entities/RESOURCE/arn%3Aaws%3Ases%3Aus-east-1%3A123456789012%3Aidentity%2Ftest%40example.com/policy",
r#"{"ReputationEntityPolicy": "arn:aws:ses:us-east-1:123456789012:policy/my-policy"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/reputation/entities/RESOURCE/arn%3Aaws%3Ases%3Aus-east-1%3A123456789012%3Aidentity%2Ftest%40example.com",
"",
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(
body["ReputationEntity"]["CustomerManagedStatus"]["SendingStatus"],
"DISABLED"
);
let req = make_request(Method::POST, "/v2/email/reputation/entities", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ReputationEntities"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn test_batch_get_metric_data() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/metrics/batch",
r#"{
"Queries": [
{
"Id": "q1",
"Namespace": "VDM",
"Metric": "SEND",
"StartDate": "2024-01-01T00:00:00Z",
"EndDate": "2024-01-02T00:00:00Z"
}
]
}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["Results"].as_array().unwrap().len(), 1);
assert_eq!(body["Results"][0]["Id"], "q1");
assert!(body["Errors"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_duplicate_tenant() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/tenants",
r#"{"TenantName": "dup"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/tenants",
r#"{"TenantName": "dup"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn contact_list_duplicate_conflict() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "newsletter"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "newsletter"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn contact_list_get_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/contact-lists/ghost", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn contact_list_delete_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::DELETE, "/v2/email/contact-lists/ghost", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn contact_in_nonexistent_list() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists/ghost/contacts",
r#"{"EmailAddress": "u@x.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn contact_list_lifecycle() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/contact-lists",
r#"{"ContactListName": "news"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(Method::GET, "/v2/email/contact-lists/news", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::POST,
"/v2/email/contact-lists/news/contacts",
r#"{"EmailAddress": "u@x.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::GET, "/v2/email/contact-lists/news/contacts", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/contact-lists/news/contacts/u@x.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::DELETE,
"/v2/email/contact-lists/news/contacts/u@x.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(Method::DELETE, "/v2/email/contact-lists/news", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
}
#[tokio::test]
async fn template_duplicate_conflict() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{"TemplateName":"t1","TemplateContent":{"Subject":"s"}}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/templates",
r#"{"TemplateName":"t1","TemplateContent":{"Subject":"s"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn template_get_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/templates/ghost", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn configuration_set_duplicate_conflict() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName":"cs1"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/configuration-sets",
r#"{"ConfigurationSetName":"cs1"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn configuration_set_get_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/configuration-sets/ghost", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn configuration_set_delete_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::DELETE, "/v2/email/configuration-sets/ghost", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_suppressed_destination_and_get() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::PUT,
"/v2/email/suppression/addresses",
r#"{"EmailAddress":"block@x.com","Reason":"BOUNCE"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::GET,
"/v2/email/suppression/addresses/block@x.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::DELETE,
"/v2/email/suppression/addresses/block@x.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
}
#[tokio::test]
async fn get_suppressed_destination_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::GET,
"/v2/email/suppression/addresses/ghost@x.com",
"",
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_import_job_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/import-jobs/nope", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_export_job_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::GET, "/v2/email/export-jobs/nope", "");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_create_import_job_missing_destination() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/import-jobs",
r#"{"ImportDataSource": {"S3Url": "s3://b/k", "DataFormat": "CSV"}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_create_import_job_missing_data_source() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/import-jobs",
r#"{"ImportDestination": {"SuppressionListDestination": {"SuppressionListImportAction": "PUT"}}}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_list_import_jobs_filter_by_suppression_list() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/import-jobs",
r#"{
"ImportDestination": {"SuppressionListDestination": {"SuppressionListImportAction": "PUT"}},
"ImportDataSource": {"S3Url": "s3://b/k", "DataFormat": "CSV"}
}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/import-jobs",
r#"{
"ImportDestination": {"ContactListDestination": {"ContactListName": "x", "ContactListImportAction": "PUT"}},
"ImportDataSource": {"S3Url": "s3://b/k", "DataFormat": "CSV"}
}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/import-jobs/list",
r#"{"ImportDestinationType": "SUPPRESSION_LIST"}"#,
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
assert_eq!(body["ImportJobs"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn test_cancel_export_job_conflict() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/export-jobs",
r#"{
"ExportDataSource": {"MessageInsightsDataSource": {"StartDate": 0, "EndDate": 0}},
"ExportDestination": {"DataFormat": "CSV"}
}"#,
);
let resp = svc.handle(req).await.unwrap();
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
let job_id = body["JobId"].as_str().unwrap().to_string();
let path = format!("/v2/email/export-jobs/{}/cancel", job_id);
let req = make_request(Method::PUT, &path, "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_cancel_export_job_not_found() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(Method::PUT, "/v2/email/export-jobs/ghost/cancel", "{}");
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_tenant_resource_association_crud() {
let state = make_state();
let svc = SesV2Service::new(state);
let req = make_request(
Method::POST,
"/v2/email/tenants",
r#"{"TenantName": "tenant-a"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/identities",
r#"{"EmailIdentity": "tres@example.com"}"#,
);
svc.handle(req).await.unwrap();
let req = make_request(
Method::POST,
"/v2/email/tenants/resources",
r#"{"TenantName": "tenant-a", "ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/tres@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::POST,
"/v2/email/tenants/resources/list",
r#"{"TenantName": "tenant-a"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
let req = make_request(
Method::POST,
"/v2/email/tenants/resources/delete",
r#"{"TenantName": "tenant-a", "ResourceArn": "arn:aws:ses:us-east-1:123456789012:identity/tres@example.com"}"#,
);
let resp = svc.handle(req).await.unwrap();
assert_eq!(resp.status, StatusCode::OK);
}
}