diff --git a/crates/fakecloud-conformance/src/audit.rs b/crates/fakecloud-conformance/src/audit.rs index c2f9b897..3699abda 100644 --- a/crates/fakecloud-conformance/src/audit.rs +++ b/crates/fakecloud-conformance/src/audit.rs @@ -22,7 +22,7 @@ fn service_source_files(project_root: &Path) -> Vec<(String, Vec)> { ("logs", "logs", &["service/mod.rs", "service.rs"]), ("kms", "kms", &["service.rs"]), ("cloudformation", "cloudformation", &["service.rs"]), - ("ses", "ses", &["service.rs"]), + ("ses", "ses", &["service/mod.rs", "service.rs"]), ("cognito-idp", "cognito", &["service/mod.rs", "service.rs"]), ]; diff --git a/crates/fakecloud-ses/src/service/account.rs b/crates/fakecloud-ses/src/service/account.rs new file mode 100644 index 00000000..3e41f483 --- /dev/null +++ b/crates/fakecloud-ses/src/service/account.rs @@ -0,0 +1,173 @@ +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::AccountDetails; + +use super::SesV2Service; + +impl SesV2Service { + pub(super) fn get_account(&self) -> Result { + let state = self.state.read(); + let acct = &state.account_settings; + let production_access = acct + .details + .as_ref() + .and_then(|d| d.production_access_enabled) + .unwrap_or(true); + let mut response = json!({ + "DedicatedIpAutoWarmupEnabled": acct.dedicated_ip_auto_warmup_enabled, + "EnforcementStatus": "HEALTHY", + "ProductionAccessEnabled": production_access, + "SendQuota": { + "Max24HourSend": 50000.0, + "MaxSendRate": 14.0, + "SentLast24Hours": state.sent_emails.iter() + .filter(|e| e.timestamp > Utc::now() - chrono::Duration::hours(24)) + .count() as f64, + }, + "SendingEnabled": acct.sending_enabled, + "SuppressionAttributes": { + "SuppressedReasons": acct.suppressed_reasons, + }, + }); + if let Some(ref details) = acct.details { + let mut d = json!({}); + if let Some(ref mt) = details.mail_type { + d["MailType"] = json!(mt); + } + if let Some(ref url) = details.website_url { + d["WebsiteURL"] = json!(url); + } + if let Some(ref lang) = details.contact_language { + d["ContactLanguage"] = json!(lang); + } + if let Some(ref desc) = details.use_case_description { + d["UseCaseDescription"] = json!(desc); + } + if !details.additional_contact_email_addresses.is_empty() { + d["AdditionalContactEmailAddresses"] = + json!(details.additional_contact_email_addresses); + } + d["ReviewDetails"] = json!({ + "Status": "GRANTED", + "CaseId": "fakecloud-case-001", + }); + response["Details"] = d; + } + if let Some(ref vdm) = acct.vdm_attributes { + response["VdmAttributes"] = vdm.clone(); + } + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn put_account_details( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mail_type = match body["MailType"].as_str() { + Some(m) => m.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "MailType is required", + )); + } + }; + let website_url = match body["WebsiteURL"].as_str() { + Some(u) => u.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "WebsiteURL is required", + )); + } + }; + let contact_language = body["ContactLanguage"].as_str().map(|s| s.to_string()); + let use_case_description = body["UseCaseDescription"].as_str().map(|s| s.to_string()); + let additional = body["AdditionalContactEmailAddresses"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + let production_access = body["ProductionAccessEnabled"].as_bool(); + + let mut state = self.state.write(); + state.account_settings.details = Some(AccountDetails { + mail_type: Some(mail_type), + website_url: Some(website_url), + contact_language, + use_case_description, + additional_contact_email_addresses: additional, + production_access_enabled: production_access, + }); + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_account_sending_attributes( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let enabled = body["SendingEnabled"].as_bool().unwrap_or(false); + self.state.write().account_settings.sending_enabled = enabled; + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_account_suppression_attributes( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let reasons = body["SuppressedReasons"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + self.state.write().account_settings.suppressed_reasons = reasons; + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_account_vdm_attributes( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let vdm = match body.get("VdmAttributes") { + Some(v) => v.clone(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "VdmAttributes is required", + )); + } + }; + self.state.write().account_settings.vdm_attributes = Some(vdm); + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_account_dedicated_ip_warmup_attributes( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let enabled = body["AutoWarmupEnabled"].as_bool().unwrap_or(false); + self.state + .write() + .account_settings + .dedicated_ip_auto_warmup_enabled = enabled; + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } +} diff --git a/crates/fakecloud-ses/src/service/configuration_sets.rs b/crates/fakecloud-ses/src/service/configuration_sets.rs new file mode 100644 index 00000000..4657ddc3 --- /dev/null +++ b/crates/fakecloud-ses/src/service/configuration_sets.rs @@ -0,0 +1,500 @@ +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::ConfigurationSet; + +use super::{ + event_destination_to_json, extract_string_array, parse_event_destination_definition, + SesV2Service, +}; + +impl SesV2Service { + pub(super) fn create_configuration_set( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let name = match body["ConfigurationSetName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ConfigurationSetName is required", + )); + } + }; + + let mut state = self.state.write(); + + if state.configuration_sets.contains_key(&name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Configuration set {} already exists", name), + )); + } + + state.configuration_sets.insert( + name.clone(), + ConfigurationSet { + name, + sending_enabled: true, + tls_policy: "OPTIONAL".to_string(), + sending_pool_name: None, + custom_redirect_domain: None, + https_policy: None, + suppressed_reasons: Vec::new(), + reputation_metrics_enabled: false, + vdm_options: None, + archive_arn: None, + }, + ); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn list_configuration_sets(&self) -> Result { + let state = self.state.read(); + let sets: Vec = state + .configuration_sets + .keys() + .map(|name| json!(name)) + .collect(); + + let response = json!({ + "ConfigurationSets": sets, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_configuration_set(&self, name: &str) -> Result { + let state = self.state.read(); + + let cs = match state.configuration_sets.get(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + let mut delivery_options = json!({ + "TlsPolicy": cs.tls_policy, + }); + if let Some(ref pool) = cs.sending_pool_name { + delivery_options["SendingPoolName"] = json!(pool); + } + + let mut tracking_options = json!({}); + if let Some(ref domain) = cs.custom_redirect_domain { + tracking_options["CustomRedirectDomain"] = json!(domain); + } + if let Some(ref policy) = cs.https_policy { + tracking_options["HttpsPolicy"] = json!(policy); + } + + let mut response = json!({ + "ConfigurationSetName": name, + "DeliveryOptions": delivery_options, + "ReputationOptions": { + "ReputationMetricsEnabled": cs.reputation_metrics_enabled, + }, + "SendingOptions": { + "SendingEnabled": cs.sending_enabled, + }, + "Tags": [], + "TrackingOptions": tracking_options, + }); + + if !cs.suppressed_reasons.is_empty() { + response["SuppressionOptions"] = json!({ + "SuppressedReasons": cs.suppressed_reasons, + }); + } + + if let Some(ref vdm) = cs.vdm_options { + response["VdmOptions"] = vdm.clone(); + } + + if let Some(ref arn) = cs.archive_arn { + response["ArchivingOptions"] = json!({ + "ArchiveArn": arn, + }); + } + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn delete_configuration_set( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let mut state = self.state.write(); + + if state.configuration_sets.remove(name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + + // Remove tags for this configuration set + let arn = format!( + "arn:aws:ses:{}:{}:configuration-set/{}", + req.region, req.account_id, name + ); + state.tags.remove(&arn); + + // Remove event destinations for this configuration set + state.event_destinations.remove(name); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Configuration Set Options --- + + pub(super) fn put_configuration_set_sending_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + if let Some(enabled) = body["SendingEnabled"].as_bool() { + cs.sending_enabled = enabled; + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_configuration_set_delivery_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + if let Some(policy) = body["TlsPolicy"].as_str() { + cs.tls_policy = policy.to_string(); + } + if let Some(pool) = body["SendingPoolName"].as_str() { + cs.sending_pool_name = Some(pool.to_string()); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_configuration_set_tracking_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + if let Some(domain) = body["CustomRedirectDomain"].as_str() { + cs.custom_redirect_domain = Some(domain.to_string()); + } + if let Some(policy) = body["HttpsPolicy"].as_str() { + cs.https_policy = Some(policy.to_string()); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_configuration_set_suppression_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + cs.suppressed_reasons = extract_string_array(&body["SuppressedReasons"]); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_configuration_set_reputation_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + if let Some(enabled) = body["ReputationMetricsEnabled"].as_bool() { + cs.reputation_metrics_enabled = enabled; + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_configuration_set_vdm_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + cs.vdm_options = Some(body); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_configuration_set_archiving_options( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let cs = match state.configuration_sets.get_mut(name) { + Some(cs) => cs, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", name), + )); + } + }; + + cs.archive_arn = body["ArchiveArn"].as_str().map(|s| s.to_string()); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Event Destination operations --- + + pub(super) fn create_configuration_set_event_destination( + &self, + config_set_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let state_read = self.state.read(); + if !state_read.configuration_sets.contains_key(config_set_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", config_set_name), + )); + } + drop(state_read); + + let dest_name = match body["EventDestinationName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "EventDestinationName is required", + )); + } + }; + + let event_dest = parse_event_destination_definition(&dest_name, &body["EventDestination"]); + + let mut state = self.state.write(); + let dests = state + .event_destinations + .entry(config_set_name.to_string()) + .or_default(); + + if dests.iter().any(|d| d.name == dest_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Event destination {} already exists", dest_name), + )); + } + + dests.push(event_dest); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn get_configuration_set_event_destinations( + &self, + config_set_name: &str, + ) -> Result { + let state = self.state.read(); + + if !state.configuration_sets.contains_key(config_set_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", config_set_name), + )); + } + + let dests = state + .event_destinations + .get(config_set_name) + .cloned() + .unwrap_or_default(); + + let dests_json: Vec = dests.iter().map(event_destination_to_json).collect(); + + let response = json!({ + "EventDestinations": dests_json, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_configuration_set_event_destination( + &self, + config_set_name: &str, + dest_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let mut state = self.state.write(); + + if !state.configuration_sets.contains_key(config_set_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", config_set_name), + )); + } + + let dests = state + .event_destinations + .entry(config_set_name.to_string()) + .or_default(); + + let existing = match dests.iter_mut().find(|d| d.name == dest_name) { + Some(d) => d, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Event destination {} does not exist", dest_name), + )); + } + }; + + let updated = parse_event_destination_definition(dest_name, &body["EventDestination"]); + *existing = updated; + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_configuration_set_event_destination( + &self, + config_set_name: &str, + dest_name: &str, + ) -> Result { + let mut state = self.state.write(); + + if !state.configuration_sets.contains_key(config_set_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Configuration set {} does not exist", config_set_name), + )); + } + + let dests = state + .event_destinations + .entry(config_set_name.to_string()) + .or_default(); + + let len_before = dests.len(); + dests.retain(|d| d.name != dest_name); + + if dests.len() == len_before { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Event destination {} does not exist", dest_name), + )); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } +} diff --git a/crates/fakecloud-ses/src/service/contact_lists.rs b/crates/fakecloud-ses/src/service/contact_lists.rs new file mode 100644 index 00000000..116c06bf --- /dev/null +++ b/crates/fakecloud-ses/src/service/contact_lists.rs @@ -0,0 +1,440 @@ +use std::collections::HashMap; + +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::{Contact, ContactList}; + +use super::{parse_topic_preferences, parse_topics, SesV2Service}; + +impl SesV2Service { + pub(super) fn create_contact_list( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let name = match body["ContactListName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ContactListName is required", + )); + } + }; + + let mut state = self.state.write(); + + if state.contact_lists.contains_key(&name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("List with name {} already exists.", name), + )); + } + + let topics = parse_topics(&body["Topics"]); + let description = body["Description"].as_str().map(|s| s.to_string()); + let now = Utc::now(); + + state.contact_lists.insert( + name.clone(), + ContactList { + contact_list_name: name.clone(), + description, + topics, + created_at: now, + last_updated_at: now, + }, + ); + state.contacts.insert(name, HashMap::new()); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn get_contact_list(&self, name: &str) -> Result { + let state = self.state.read(); + let list = match state.contact_lists.get(name) { + Some(l) => l, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", name), + )); + } + }; + + let topics: Vec = list + .topics + .iter() + .map(|t| { + json!({ + "TopicName": t.topic_name, + "DisplayName": t.display_name, + "Description": t.description, + "DefaultSubscriptionStatus": t.default_subscription_status, + }) + }) + .collect(); + + let response = json!({ + "ContactListName": list.contact_list_name, + "Description": list.description, + "Topics": topics, + "CreatedTimestamp": list.created_at.timestamp() as f64, + "LastUpdatedTimestamp": list.last_updated_at.timestamp() as f64, + "Tags": [], + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_contact_lists(&self) -> Result { + let state = self.state.read(); + let lists: Vec = state + .contact_lists + .values() + .map(|l| { + json!({ + "ContactListName": l.contact_list_name, + "LastUpdatedTimestamp": l.last_updated_at.timestamp() as f64, + }) + }) + .collect(); + + let response = json!({ + "ContactLists": lists, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_contact_list( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let list = match state.contact_lists.get_mut(name) { + Some(l) => l, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", name), + )); + } + }; + + if let Some(desc) = body.get("Description") { + list.description = desc.as_str().map(|s| s.to_string()); + } + if body.get("Topics").is_some() { + list.topics = parse_topics(&body["Topics"]); + } + list.last_updated_at = Utc::now(); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_contact_list( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let mut state = self.state.write(); + + if state.contact_lists.remove(name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", name), + )); + } + + // Also delete all contacts in this list + state.contacts.remove(name); + + // Remove tags for this contact list + let arn = format!( + "arn:aws:ses:{}:{}:contact-list/{}", + req.region, req.account_id, name + ); + state.tags.remove(&arn); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Contact operations --- + + pub(super) fn create_contact( + &self, + list_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let email = match body["EmailAddress"].as_str() { + Some(e) => e.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "EmailAddress is required", + )); + } + }; + + let mut state = self.state.write(); + + if !state.contact_lists.contains_key(list_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", list_name), + )); + } + + let contacts = state.contacts.entry(list_name.to_string()).or_default(); + + if contacts.contains_key(&email) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Contact already exists in list {}", list_name), + )); + } + + let topic_preferences = parse_topic_preferences(&body["TopicPreferences"]); + let unsubscribe_all = body["UnsubscribeAll"].as_bool().unwrap_or(false); + let attributes_data = body["AttributesData"].as_str().map(|s| s.to_string()); + let now = Utc::now(); + + contacts.insert( + email.clone(), + Contact { + email_address: email, + topic_preferences, + unsubscribe_all, + attributes_data, + created_at: now, + last_updated_at: now, + }, + ); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn get_contact( + &self, + list_name: &str, + email: &str, + ) -> Result { + let state = self.state.read(); + + if !state.contact_lists.contains_key(list_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", list_name), + )); + } + + let contact = state.contacts.get(list_name).and_then(|m| m.get(email)); + + let contact = match contact { + Some(c) => c, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Contact {} does not exist in list {}", email, list_name), + )); + } + }; + + // Build TopicDefaultPreferences from the contact list's topics + let list = state.contact_lists.get(list_name).unwrap(); + let topic_default_preferences: Vec = list + .topics + .iter() + .map(|t| { + json!({ + "TopicName": t.topic_name, + "SubscriptionStatus": t.default_subscription_status, + }) + }) + .collect(); + + let topic_preferences: Vec = contact + .topic_preferences + .iter() + .map(|tp| { + json!({ + "TopicName": tp.topic_name, + "SubscriptionStatus": tp.subscription_status, + }) + }) + .collect(); + + let mut response = json!({ + "ContactListName": list_name, + "EmailAddress": contact.email_address, + "TopicPreferences": topic_preferences, + "TopicDefaultPreferences": topic_default_preferences, + "UnsubscribeAll": contact.unsubscribe_all, + "CreatedTimestamp": contact.created_at.timestamp() as f64, + "LastUpdatedTimestamp": contact.last_updated_at.timestamp() as f64, + }); + + if let Some(ref attrs) = contact.attributes_data { + response["AttributesData"] = json!(attrs); + } + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_contacts(&self, list_name: &str) -> Result { + let state = self.state.read(); + + if !state.contact_lists.contains_key(list_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", list_name), + )); + } + + let contacts: Vec = state + .contacts + .get(list_name) + .map(|m| { + m.values() + .map(|c| { + let topic_prefs: Vec = c + .topic_preferences + .iter() + .map(|tp| { + json!({ + "TopicName": tp.topic_name, + "SubscriptionStatus": tp.subscription_status, + }) + }) + .collect(); + + // Build TopicDefaultPreferences from the list's topics + let list = state.contact_lists.get(list_name).unwrap(); + let topic_defaults: Vec = list + .topics + .iter() + .map(|t| { + json!({ + "TopicName": t.topic_name, + "SubscriptionStatus": t.default_subscription_status, + }) + }) + .collect(); + + json!({ + "EmailAddress": c.email_address, + "TopicPreferences": topic_prefs, + "TopicDefaultPreferences": topic_defaults, + "UnsubscribeAll": c.unsubscribe_all, + "LastUpdatedTimestamp": c.last_updated_at.timestamp() as f64, + }) + }) + .collect() + }) + .unwrap_or_default(); + + let response = json!({ + "Contacts": contacts, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_contact( + &self, + list_name: &str, + email: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + if !state.contact_lists.contains_key(list_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", list_name), + )); + } + + let contact = state + .contacts + .get_mut(list_name) + .and_then(|m| m.get_mut(email)); + + let contact = match contact { + Some(c) => c, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Contact {} does not exist in list {}", email, list_name), + )); + } + }; + + if body.get("TopicPreferences").is_some() { + contact.topic_preferences = parse_topic_preferences(&body["TopicPreferences"]); + } + if let Some(unsub) = body["UnsubscribeAll"].as_bool() { + contact.unsubscribe_all = unsub; + } + if let Some(attrs) = body.get("AttributesData") { + contact.attributes_data = attrs.as_str().map(|s| s.to_string()); + } + contact.last_updated_at = Utc::now(); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_contact( + &self, + list_name: &str, + email: &str, + ) -> Result { + let mut state = self.state.write(); + + if !state.contact_lists.contains_key(list_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("List with name {} does not exist.", list_name), + )); + } + + let removed = state + .contacts + .get_mut(list_name) + .and_then(|m| m.remove(email)); + + if removed.is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Contact {} does not exist in list {}", email, list_name), + )); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } +} diff --git a/crates/fakecloud-ses/src/service/identities.rs b/crates/fakecloud-ses/src/service/identities.rs new file mode 100644 index 00000000..341e94b5 --- /dev/null +++ b/crates/fakecloud-ses/src/service/identities.rs @@ -0,0 +1,490 @@ +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::EmailIdentity; + +use super::SesV2Service; + +impl SesV2Service { + pub(super) fn create_email_identity( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let identity_name = match body["EmailIdentity"].as_str() { + Some(name) => name.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "EmailIdentity is required", + )); + } + }; + + let mut state = self.state.write(); + + if state.identities.contains_key(&identity_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Identity {} already exists", identity_name), + )); + } + + let identity_type = if identity_name.contains('@') { + "EMAIL_ADDRESS" + } else { + "DOMAIN" + }; + + let identity = EmailIdentity { + identity_name: identity_name.clone(), + identity_type: identity_type.to_string(), + verified: true, + created_at: Utc::now(), + dkim_signing_enabled: true, + dkim_signing_attributes_origin: "AWS_SES".to_string(), + dkim_domain_signing_private_key: None, + dkim_domain_signing_selector: None, + dkim_next_signing_key_length: None, + email_forwarding_enabled: true, + mail_from_domain: None, + mail_from_behavior_on_mx_failure: "USE_DEFAULT_VALUE".to_string(), + configuration_set_name: None, + }; + + state.identities.insert(identity_name, identity); + + let response = json!({ + "IdentityType": identity_type, + "VerifiedForSendingStatus": true, + "DkimAttributes": { + "SigningEnabled": true, + "Status": "SUCCESS", + "Tokens": [ + "token1", + "token2", + "token3", + ], + }, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_email_identities(&self) -> Result { + let state = self.state.read(); + let identities: Vec = state + .identities + .values() + .map(|id| { + json!({ + "IdentityType": id.identity_type, + "IdentityName": id.identity_name, + "SendingEnabled": true, + }) + }) + .collect(); + + let response = json!({ + "EmailIdentities": identities, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_email_identity( + &self, + identity_name: &str, + ) -> Result { + let state = self.state.read(); + let identity = match state.identities.get(identity_name) { + Some(id) => id, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + }; + + let mail_from_domain = identity.mail_from_domain.as_deref().unwrap_or(""); + let mail_from_status = if mail_from_domain.is_empty() { + "FAILED" + } else { + "SUCCESS" + }; + + let mut response = json!({ + "IdentityType": identity.identity_type, + "VerifiedForSendingStatus": true, + "FeedbackForwardingStatus": identity.email_forwarding_enabled, + "DkimAttributes": { + "SigningEnabled": identity.dkim_signing_enabled, + "Status": "SUCCESS", + "SigningAttributesOrigin": identity.dkim_signing_attributes_origin, + "Tokens": [ + "token1", + "token2", + "token3", + ], + }, + "MailFromAttributes": { + "MailFromDomain": mail_from_domain, + "MailFromDomainStatus": mail_from_status, + "BehaviorOnMxFailure": identity.mail_from_behavior_on_mx_failure, + }, + "Tags": [], + }); + + if let Some(ref cs) = identity.configuration_set_name { + response["ConfigurationSetName"] = json!(cs); + } + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn delete_email_identity( + &self, + identity_name: &str, + req: &AwsRequest, + ) -> Result { + let mut state = self.state.write(); + + if state.identities.remove(identity_name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + + // Remove tags for this identity + let arn = format!( + "arn:aws:ses:{}:{}:identity/{}", + req.region, req.account_id, identity_name + ); + state.tags.remove(&arn); + + // Remove policies for this identity + state.identity_policies.remove(identity_name); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Email Identity Policy operations --- + + pub(super) fn create_email_identity_policy( + &self, + identity_name: &str, + policy_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let policy = match body["Policy"].as_str() { + Some(p) => p.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Policy is required", + )); + } + }; + + let mut state = self.state.write(); + + if !state.identities.contains_key(identity_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + + let policies = state + .identity_policies + .entry(identity_name.to_string()) + .or_default(); + + if policies.contains_key(policy_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Policy {} already exists", policy_name), + )); + } + + policies.insert(policy_name.to_string(), policy); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn get_email_identity_policies( + &self, + identity_name: &str, + ) -> Result { + let state = self.state.read(); + + if !state.identities.contains_key(identity_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + + let policies = state + .identity_policies + .get(identity_name) + .cloned() + .unwrap_or_default(); + + let policies_json: Value = policies + .into_iter() + .map(|(k, v)| (k, Value::String(v))) + .collect::>() + .into(); + + let response = json!({ + "Policies": policies_json, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_email_identity_policy( + &self, + identity_name: &str, + policy_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let policy = match body["Policy"].as_str() { + Some(p) => p.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Policy is required", + )); + } + }; + + let mut state = self.state.write(); + + if !state.identities.contains_key(identity_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + + let policies = state + .identity_policies + .entry(identity_name.to_string()) + .or_default(); + + if !policies.contains_key(policy_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Policy {} does not exist", policy_name), + )); + } + + policies.insert(policy_name.to_string(), policy); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_email_identity_policy( + &self, + identity_name: &str, + policy_name: &str, + ) -> Result { + let mut state = self.state.write(); + + if !state.identities.contains_key(identity_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + + let policies = state + .identity_policies + .entry(identity_name.to_string()) + .or_default(); + + if policies.remove(policy_name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Policy {} does not exist", policy_name), + )); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Identity Attribute operations --- + + pub(super) fn put_email_identity_dkim_attributes( + &self, + identity_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let identity = match state.identities.get_mut(identity_name) { + Some(id) => id, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + }; + + if let Some(enabled) = body["SigningEnabled"].as_bool() { + identity.dkim_signing_enabled = enabled; + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_email_identity_dkim_signing_attributes( + &self, + identity_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let identity = match state.identities.get_mut(identity_name) { + Some(id) => id, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + }; + + if let Some(origin) = body["SigningAttributesOrigin"].as_str() { + identity.dkim_signing_attributes_origin = origin.to_string(); + } + + if let Some(attrs) = body.get("SigningAttributes") { + if let Some(key) = attrs["DomainSigningPrivateKey"].as_str() { + identity.dkim_domain_signing_private_key = Some(key.to_string()); + } + if let Some(selector) = attrs["DomainSigningSelector"].as_str() { + identity.dkim_domain_signing_selector = Some(selector.to_string()); + } + if let Some(length) = attrs["NextSigningKeyLength"].as_str() { + identity.dkim_next_signing_key_length = Some(length.to_string()); + } + } + + let response = json!({ + "DkimStatus": "SUCCESS", + "DkimTokens": ["token1", "token2", "token3"], + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn put_email_identity_feedback_attributes( + &self, + identity_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let identity = match state.identities.get_mut(identity_name) { + Some(id) => id, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + }; + + if let Some(enabled) = body["EmailForwardingEnabled"].as_bool() { + identity.email_forwarding_enabled = enabled; + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_email_identity_mail_from_attributes( + &self, + identity_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let identity = match state.identities.get_mut(identity_name) { + Some(id) => id, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + }; + + if let Some(domain) = body["MailFromDomain"].as_str() { + identity.mail_from_domain = Some(domain.to_string()); + } + if let Some(behavior) = body["BehaviorOnMxFailure"].as_str() { + identity.mail_from_behavior_on_mx_failure = behavior.to_string(); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_email_identity_configuration_set_attributes( + &self, + identity_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let identity = match state.identities.get_mut(identity_name) { + Some(id) => id, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Identity {} does not exist", identity_name), + )); + } + }; + + identity.configuration_set_name = + body["ConfigurationSetName"].as_str().map(|s| s.to_string()); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } +} diff --git a/crates/fakecloud-ses/src/service/misc.rs b/crates/fakecloud-ses/src/service/misc.rs new file mode 100644 index 00000000..38e71fe4 --- /dev/null +++ b/crates/fakecloud-ses/src/service/misc.rs @@ -0,0 +1,1622 @@ +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::{ + CustomVerificationEmailTemplate, DedicatedIp, DedicatedIpPool, ExportJob, ImportJob, + MultiRegionEndpoint, ReputationEntityState, SentEmail, Tenant, TenantResourceAssociation, +}; + +use super::SesV2Service; + +impl SesV2Service { + // --- Tag operations --- + + /// Validate that a resource ARN refers to an existing resource. + /// Returns `None` if the resource exists, or `Some(error_response)` if not. + pub(super) fn validate_resource_arn(&self, arn: &str) -> Option { + let state = self.state.read(); + + // Parse ARN: arn:aws:ses:{region}:{account}:{resource-type}/{name} + let parts: Vec<&str> = arn.split(':').collect(); + if parts.len() < 6 { + return Some(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Resource not found: {arn}"), + )); + } + + let resource = parts[5..].join(":"); + let found = if let Some(name) = resource.strip_prefix("identity/") { + state.identities.contains_key(name) + } else if let Some(name) = resource.strip_prefix("configuration-set/") { + state.configuration_sets.contains_key(name) + } else if let Some(name) = resource.strip_prefix("contact-list/") { + state.contact_lists.contains_key(name) + } else { + false + }; + + if found { + None + } else { + Some(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Resource not found: {arn}"), + )) + } + } + + pub(super) fn tag_resource(&self, req: &AwsRequest) -> Result { + let body: Value = Self::parse_body(req)?; + + let arn = match body["ResourceArn"].as_str() { + Some(a) => a.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ResourceArn is required", + )); + } + }; + + let tags_arr = match body["Tags"].as_array() { + Some(arr) => arr, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Tags is required", + )); + } + }; + + if let Some(resp) = self.validate_resource_arn(&arn) { + return Ok(resp); + } + + let mut state = self.state.write(); + let tag_map = state.tags.entry(arn).or_default(); + for tag in tags_arr { + if let (Some(k), Some(v)) = (tag["Key"].as_str(), tag["Value"].as_str()) { + tag_map.insert(k.to_string(), v.to_string()); + } + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn untag_resource(&self, req: &AwsRequest) -> Result { + // ResourceArn and TagKeys come as query params + let arn = match req.query_params.get("ResourceArn") { + Some(a) => a.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ResourceArn is required", + )); + } + }; + + if let Some(resp) = self.validate_resource_arn(&arn) { + return Ok(resp); + } + + // Parse TagKeys from raw query string (supports repeated params) + let tag_keys: Vec = form_urlencoded::parse(req.raw_query.as_bytes()) + .filter(|(k, _)| k == "TagKeys") + .map(|(_, v)| v.into_owned()) + .collect(); + + let mut state = self.state.write(); + if let Some(tag_map) = state.tags.get_mut(&arn) { + for key in &tag_keys { + tag_map.remove(key); + } + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn list_tags_for_resource( + &self, + req: &AwsRequest, + ) -> Result { + let arn = match req.query_params.get("ResourceArn") { + Some(a) => a.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ResourceArn is required", + )); + } + }; + + if let Some(resp) = self.validate_resource_arn(&arn) { + return Ok(resp); + } + + let state = self.state.read(); + let tags = state.tags.get(&arn); + let tags_json = match tags { + Some(t) => fakecloud_core::tags::tags_to_json(t, "Key", "Value"), + None => vec![], + }; + + let response = json!({ + "Tags": tags_json, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + // --- Custom Verification Email Template operations --- + + pub(super) fn create_custom_verification_email_template( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let template_name = match body["TemplateName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TemplateName is required", + )); + } + }; + + let from_email = body["FromEmailAddress"].as_str().unwrap_or("").to_string(); + let subject = body["TemplateSubject"].as_str().unwrap_or("").to_string(); + let content = body["TemplateContent"].as_str().unwrap_or("").to_string(); + let success_url = body["SuccessRedirectionURL"] + .as_str() + .unwrap_or("") + .to_string(); + let failure_url = body["FailureRedirectionURL"] + .as_str() + .unwrap_or("") + .to_string(); + + let mut state = self.state.write(); + + if state + .custom_verification_email_templates + .contains_key(&template_name) + { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!( + "Custom verification email template {} already exists", + template_name + ), + )); + } + + state.custom_verification_email_templates.insert( + template_name.clone(), + CustomVerificationEmailTemplate { + template_name, + from_email_address: from_email, + template_subject: subject, + template_content: content, + success_redirection_url: success_url, + failure_redirection_url: failure_url, + created_at: Utc::now(), + }, + ); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn get_custom_verification_email_template( + &self, + name: &str, + ) -> Result { + let state = self.state.read(); + let tmpl = match state.custom_verification_email_templates.get(name) { + Some(t) => t, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Custom verification email template {} does not exist", name), + )); + } + }; + + let response = json!({ + "TemplateName": tmpl.template_name, + "FromEmailAddress": tmpl.from_email_address, + "TemplateSubject": tmpl.template_subject, + "TemplateContent": tmpl.template_content, + "SuccessRedirectionURL": tmpl.success_redirection_url, + "FailureRedirectionURL": tmpl.failure_redirection_url, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_custom_verification_email_templates( + &self, + req: &AwsRequest, + ) -> Result { + let state = self.state.read(); + + let page_size: usize = req + .query_params + .get("PageSize") + .and_then(|s| s.parse().ok()) + .unwrap_or(20); + + let mut templates: Vec<&CustomVerificationEmailTemplate> = + state.custom_verification_email_templates.values().collect(); + templates.sort_by(|a, b| a.template_name.cmp(&b.template_name)); + + let next_token = req.query_params.get("NextToken"); + let start_idx = if let Some(token) = next_token { + templates + .iter() + .position(|t| t.template_name == *token) + .unwrap_or(0) + } else { + 0 + }; + + let page: Vec = templates + .iter() + .skip(start_idx) + .take(page_size) + .map(|t| { + json!({ + "TemplateName": t.template_name, + "FromEmailAddress": t.from_email_address, + "TemplateSubject": t.template_subject, + "SuccessRedirectionURL": t.success_redirection_url, + "FailureRedirectionURL": t.failure_redirection_url, + }) + }) + .collect(); + + let mut response = json!({ + "CustomVerificationEmailTemplates": page, + }); + + // Set NextToken if there are more results + if start_idx + page_size < templates.len() { + if let Some(next) = templates.get(start_idx + page_size) { + response["NextToken"] = json!(next.template_name); + } + } + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_custom_verification_email_template( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let tmpl = match state.custom_verification_email_templates.get_mut(name) { + Some(t) => t, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Custom verification email template {} does not exist", name), + )); + } + }; + + if let Some(from) = body["FromEmailAddress"].as_str() { + tmpl.from_email_address = from.to_string(); + } + if let Some(subject) = body["TemplateSubject"].as_str() { + tmpl.template_subject = subject.to_string(); + } + if let Some(content) = body["TemplateContent"].as_str() { + tmpl.template_content = content.to_string(); + } + if let Some(url) = body["SuccessRedirectionURL"].as_str() { + tmpl.success_redirection_url = url.to_string(); + } + if let Some(url) = body["FailureRedirectionURL"].as_str() { + tmpl.failure_redirection_url = url.to_string(); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_custom_verification_email_template( + &self, + name: &str, + ) -> Result { + let mut state = self.state.write(); + + if state + .custom_verification_email_templates + .remove(name) + .is_none() + { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Custom verification email template {} does not exist", name), + )); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn send_custom_verification_email( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let email_address = match body["EmailAddress"].as_str() { + Some(e) => e.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "EmailAddress is required", + )); + } + }; + + let template_name = match body["TemplateName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TemplateName is required", + )); + } + }; + + // Verify template exists + { + let state = self.state.read(); + if !state + .custom_verification_email_templates + .contains_key(&template_name) + { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!( + "Custom verification email template {} does not exist", + template_name + ), + )); + } + } + + let message_id = uuid::Uuid::new_v4().to_string(); + + // Store as a sent email for introspection + let sent = SentEmail { + message_id: message_id.clone(), + from: String::new(), + to: vec![email_address], + cc: Vec::new(), + bcc: Vec::new(), + subject: Some(format!("Custom verification: {}", template_name)), + html_body: None, + text_body: None, + raw_data: None, + template_name: Some(template_name), + template_data: None, + timestamp: Utc::now(), + }; + + self.state.write().sent_emails.push(sent); + + let response = json!({ + "MessageId": message_id, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + // ── Dedicated IP Pools ────────────────────────────────────────────── + + pub(super) fn create_dedicated_ip_pool( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let pool_name = match body["PoolName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "PoolName is required", + )); + } + }; + let scaling_mode = body["ScalingMode"] + .as_str() + .unwrap_or("STANDARD") + .to_string(); + + let mut state = self.state.write(); + + if state.dedicated_ip_pools.contains_key(&pool_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Pool {} already exists", pool_name), + )); + } + + // For MANAGED pools, generate some fake IPs + if scaling_mode == "MANAGED" { + let pool_idx = state.dedicated_ip_pools.len() as u8; + for i in 1..=3 { + let ip_addr = format!("198.51.100.{}", pool_idx * 10 + i); + state.dedicated_ips.insert( + ip_addr.clone(), + DedicatedIp { + ip: ip_addr, + warmup_status: "NOT_APPLICABLE".to_string(), + warmup_percentage: -1, + pool_name: pool_name.clone(), + }, + ); + } + } + + state.dedicated_ip_pools.insert( + pool_name.clone(), + DedicatedIpPool { + pool_name, + scaling_mode, + }, + ); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn list_dedicated_ip_pools(&self) -> Result { + let state = self.state.read(); + let pools: Vec<&str> = state + .dedicated_ip_pools + .keys() + .map(|k| k.as_str()) + .collect(); + let response = json!({ "DedicatedIpPools": pools }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn delete_dedicated_ip_pool( + &self, + name: &str, + ) -> Result { + let mut state = self.state.write(); + if state.dedicated_ip_pools.remove(name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Pool {} does not exist", name), + )); + } + // Remove IPs associated with this pool + state.dedicated_ips.retain(|_, ip| ip.pool_name != name); + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_dedicated_ip_pool_scaling_attributes( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let scaling_mode = match body["ScalingMode"].as_str() { + Some(m) => m.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ScalingMode is required", + )); + } + }; + + let mut state = self.state.write(); + let pool = match state.dedicated_ip_pools.get_mut(name) { + Some(p) => p, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Pool {} does not exist", name), + )); + } + }; + + if pool.scaling_mode == "MANAGED" && scaling_mode == "STANDARD" { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Cannot change scaling mode from MANAGED to STANDARD", + )); + } + + let old_mode = pool.scaling_mode.clone(); + pool.scaling_mode = scaling_mode.clone(); + + // If changing from STANDARD to MANAGED, generate IPs + if old_mode == "STANDARD" && scaling_mode == "MANAGED" { + let pool_idx = state.dedicated_ip_pools.len() as u8; + for i in 1..=3u8 { + let ip_addr = format!("198.51.100.{}", pool_idx * 10 + i); + state.dedicated_ips.insert( + ip_addr.clone(), + DedicatedIp { + ip: ip_addr, + warmup_status: "NOT_APPLICABLE".to_string(), + warmup_percentage: -1, + pool_name: name.to_string(), + }, + ); + } + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // ── Dedicated IPs ─────────────────────────────────────────────────── + + pub(super) fn get_dedicated_ip(&self, ip: &str) -> Result { + let state = self.state.read(); + let dip = match state.dedicated_ips.get(ip) { + Some(d) => d, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Dedicated IP {} does not exist", ip), + )); + } + }; + let response = json!({ + "DedicatedIp": { + "Ip": dip.ip, + "WarmupStatus": dip.warmup_status, + "WarmupPercentage": dip.warmup_percentage, + "PoolName": dip.pool_name, + } + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_dedicated_ips( + &self, + req: &AwsRequest, + ) -> Result { + let state = self.state.read(); + let pool_filter = req.query_params.get("PoolName").map(|s| s.as_str()); + let ips: Vec = state + .dedicated_ips + .values() + .filter(|ip| match pool_filter { + Some(pool) => ip.pool_name == pool, + None => true, + }) + .map(|ip| { + json!({ + "Ip": ip.ip, + "WarmupStatus": ip.warmup_status, + "WarmupPercentage": ip.warmup_percentage, + "PoolName": ip.pool_name, + }) + }) + .collect(); + let response = json!({ "DedicatedIps": ips }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn put_dedicated_ip_in_pool( + &self, + ip: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let dest_pool = match body["DestinationPoolName"].as_str() { + Some(p) => p.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "DestinationPoolName is required", + )); + } + }; + + let mut state = self.state.write(); + + if !state.dedicated_ip_pools.contains_key(&dest_pool) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Pool {} does not exist", dest_pool), + )); + } + + let dip = match state.dedicated_ips.get_mut(ip) { + Some(d) => d, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Dedicated IP {} does not exist", ip), + )); + } + }; + dip.pool_name = dest_pool; + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn put_dedicated_ip_warmup_attributes( + &self, + ip: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let warmup_pct = match body["WarmupPercentage"].as_i64() { + Some(p) => p as i32, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "WarmupPercentage is required", + )); + } + }; + + let mut state = self.state.write(); + let dip = match state.dedicated_ips.get_mut(ip) { + Some(d) => d, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Dedicated IP {} does not exist", ip), + )); + } + }; + dip.warmup_percentage = warmup_pct; + dip.warmup_status = if warmup_pct >= 100 { + "DONE".to_string() + } else { + "IN_PROGRESS".to_string() + }; + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // ── Multi-region Endpoints ────────────────────────────────────────── + + pub(super) fn create_multi_region_endpoint( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let endpoint_name = match body["EndpointName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "EndpointName is required", + )); + } + }; + + let mut state = self.state.write(); + if state.multi_region_endpoints.contains_key(&endpoint_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Endpoint {} already exists", endpoint_name), + )); + } + + // Extract regions from Details.RoutesDetails[].Region + let mut regions = Vec::new(); + if let Some(details) = body.get("Details") { + if let Some(routes) = details["RoutesDetails"].as_array() { + for r in routes { + if let Some(region) = r["Region"].as_str() { + regions.push(region.to_string()); + } + } + } + } + // The primary region is always the current region + if !regions.contains(&state.region) { + regions.insert(0, state.region.clone()); + } + + let endpoint_id = format!( + "ses-{}-{}", + state.region, + uuid::Uuid::new_v4().to_string().split('-').next().unwrap() + ); + let now = Utc::now(); + + state.multi_region_endpoints.insert( + endpoint_name.clone(), + MultiRegionEndpoint { + endpoint_name, + endpoint_id: endpoint_id.clone(), + status: "READY".to_string(), + regions, + created_at: now, + last_updated_at: now, + }, + ); + + let response = json!({ + "Status": "READY", + "EndpointId": endpoint_id, + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_multi_region_endpoint( + &self, + name: &str, + ) -> Result { + let state = self.state.read(); + let ep = match state.multi_region_endpoints.get(name) { + Some(e) => e, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Endpoint {} does not exist", name), + )); + } + }; + + let routes: Vec = ep.regions.iter().map(|r| json!({ "Region": r })).collect(); + + let response = json!({ + "EndpointName": ep.endpoint_name, + "EndpointId": ep.endpoint_id, + "Status": ep.status, + "Routes": routes, + "CreatedTimestamp": ep.created_at.timestamp() as f64, + "LastUpdatedTimestamp": ep.last_updated_at.timestamp() as f64, + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_multi_region_endpoints(&self) -> Result { + let state = self.state.read(); + let endpoints: Vec = state + .multi_region_endpoints + .values() + .map(|ep| { + json!({ + "EndpointName": ep.endpoint_name, + "EndpointId": ep.endpoint_id, + "Status": ep.status, + "Regions": ep.regions, + "CreatedTimestamp": ep.created_at.timestamp() as f64, + "LastUpdatedTimestamp": ep.last_updated_at.timestamp() as f64, + }) + }) + .collect(); + let response = json!({ "MultiRegionEndpoints": endpoints }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn delete_multi_region_endpoint( + &self, + name: &str, + ) -> Result { + let mut state = self.state.write(); + if state.multi_region_endpoints.remove(name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Endpoint {} does not exist", name), + )); + } + let response = json!({ "Status": "DELETING" }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + // --- Import Job operations --- + + pub(super) fn create_import_job( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let import_destination = match body.get("ImportDestination") { + Some(v) if v.is_object() => v.clone(), + _ => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ImportDestination is required", + )); + } + }; + + let import_data_source = match body.get("ImportDataSource") { + Some(v) if v.is_object() => v.clone(), + _ => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ImportDataSource is required", + )); + } + }; + + let job_id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now(); + + let job = ImportJob { + job_id: job_id.clone(), + import_destination, + import_data_source, + job_status: "COMPLETED".to_string(), + created_timestamp: now, + completed_timestamp: Some(now), + processed_records_count: 0, + failed_records_count: 0, + }; + + self.state.write().import_jobs.insert(job_id.clone(), job); + + let response = json!({ "JobId": job_id }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_import_job(&self, job_id: &str) -> Result { + let state = self.state.read(); + let job = match state.import_jobs.get(job_id) { + Some(j) => j, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Import job {} does not exist", job_id), + )); + } + }; + + let mut response = json!({ + "JobId": job.job_id, + "ImportDestination": job.import_destination, + "ImportDataSource": job.import_data_source, + "JobStatus": job.job_status, + "CreatedTimestamp": job.created_timestamp.timestamp() as f64, + "ProcessedRecordsCount": job.processed_records_count, + "FailedRecordsCount": job.failed_records_count, + }); + if let Some(ref ts) = job.completed_timestamp { + response["CompletedTimestamp"] = json!(ts.timestamp() as f64); + } + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_import_jobs( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({})); + let filter_type = body["ImportDestinationType"].as_str(); + + let state = self.state.read(); + let jobs: Vec = state + .import_jobs + .values() + .filter(|j| { + if let Some(ft) = filter_type { + // Check if import destination matches + if j.import_destination + .get("SuppressionListDestination") + .is_some() + && ft == "SUPPRESSION_LIST" + { + return true; + } + if j.import_destination.get("ContactListDestination").is_some() + && ft == "CONTACT_LIST" + { + return true; + } + return false; + } + true + }) + .map(|j| { + let mut obj = json!({ + "JobId": j.job_id, + "ImportDestination": j.import_destination, + "JobStatus": j.job_status, + "CreatedTimestamp": j.created_timestamp.timestamp() as f64, + }); + if j.processed_records_count > 0 { + obj["ProcessedRecordsCount"] = json!(j.processed_records_count); + } + if j.failed_records_count > 0 { + obj["FailedRecordsCount"] = json!(j.failed_records_count); + } + obj + }) + .collect(); + + let response = json!({ "ImportJobs": jobs }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + // --- Export Job operations --- + + pub(super) fn create_export_job( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let export_data_source = match body.get("ExportDataSource") { + Some(v) if v.is_object() => v.clone(), + _ => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ExportDataSource is required", + )); + } + }; + + let export_destination = match body.get("ExportDestination") { + Some(v) if v.is_object() => v.clone(), + _ => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ExportDestination is required", + )); + } + }; + + // Determine export source type from the data source + let export_source_type = if export_data_source.get("MetricsDataSource").is_some() { + "METRICS_DATA" + } else { + "MESSAGE_INSIGHTS" + }; + + let job_id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now(); + + let job = ExportJob { + job_id: job_id.clone(), + export_source_type: export_source_type.to_string(), + export_destination, + export_data_source, + job_status: "COMPLETED".to_string(), + created_timestamp: now, + completed_timestamp: Some(now), + }; + + self.state.write().export_jobs.insert(job_id.clone(), job); + + let response = json!({ "JobId": job_id }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_export_job(&self, job_id: &str) -> Result { + let state = self.state.read(); + let job = match state.export_jobs.get(job_id) { + Some(j) => j, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Export job {} does not exist", job_id), + )); + } + }; + + let mut response = json!({ + "JobId": job.job_id, + "ExportSourceType": job.export_source_type, + "JobStatus": job.job_status, + "ExportDestination": job.export_destination, + "ExportDataSource": job.export_data_source, + "CreatedTimestamp": job.created_timestamp.timestamp() as f64, + "Statistics": { + "ProcessedRecordsCount": 0, + "ExportedRecordsCount": 0, + }, + }); + if let Some(ref ts) = job.completed_timestamp { + response["CompletedTimestamp"] = json!(ts.timestamp() as f64); + } + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_export_jobs( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({})); + let filter_status = body["JobStatus"].as_str(); + let filter_type = body["ExportSourceType"].as_str(); + + let state = self.state.read(); + let jobs: Vec = state + .export_jobs + .values() + .filter(|j| { + if let Some(s) = filter_status { + if j.job_status != s { + return false; + } + } + if let Some(t) = filter_type { + if j.export_source_type != t { + return false; + } + } + true + }) + .map(|j| { + let mut obj = json!({ + "JobId": j.job_id, + "ExportSourceType": j.export_source_type, + "JobStatus": j.job_status, + "CreatedTimestamp": j.created_timestamp.timestamp() as f64, + }); + if let Some(ref ts) = j.completed_timestamp { + obj["CompletedTimestamp"] = json!(ts.timestamp() as f64); + } + obj + }) + .collect(); + + let response = json!({ "ExportJobs": jobs }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn cancel_export_job(&self, job_id: &str) -> Result { + let mut state = self.state.write(); + let job = match state.export_jobs.get_mut(job_id) { + Some(j) => j, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Export job {} does not exist", job_id), + )); + } + }; + + if job.job_status == "COMPLETED" || job.job_status == "CANCELLED" { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "ConflictException", + &format!("Export job {} is already {}", job_id, job.job_status), + )); + } + + job.job_status = "CANCELLED".to_string(); + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Tenant operations --- + + pub(super) fn create_tenant(&self, req: &AwsRequest) -> Result { + let body: Value = Self::parse_body(req)?; + let tenant_name = match body["TenantName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TenantName is required", + )); + } + }; + + let mut state = self.state.write(); + + if state.tenants.contains_key(&tenant_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Tenant {} already exists", tenant_name), + )); + } + + let tenant_id = uuid::Uuid::new_v4().to_string(); + let tenant_arn = format!( + "arn:aws:ses:{}:{}:tenant/{}", + req.region, req.account_id, tenant_id + ); + let now = Utc::now(); + + let tags = body + .get("Tags") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let tenant = Tenant { + tenant_name: tenant_name.clone(), + tenant_id: tenant_id.clone(), + tenant_arn: tenant_arn.clone(), + created_timestamp: now, + sending_status: "ENABLED".to_string(), + tags: tags.clone(), + }; + + state.tenants.insert(tenant_name.clone(), tenant); + + let response = json!({ + "TenantName": tenant_name, + "TenantId": tenant_id, + "TenantArn": tenant_arn, + "CreatedTimestamp": now.timestamp() as f64, + "SendingStatus": "ENABLED", + "Tags": tags, + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_tenant(&self, req: &AwsRequest) -> Result { + let body: Value = Self::parse_body(req)?; + let tenant_name = match body["TenantName"].as_str() { + Some(n) => n, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TenantName is required", + )); + } + }; + + let state = self.state.read(); + let tenant = match state.tenants.get(tenant_name) { + Some(t) => t, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Tenant {} does not exist", tenant_name), + )); + } + }; + + let response = json!({ + "Tenant": { + "TenantName": tenant.tenant_name, + "TenantId": tenant.tenant_id, + "TenantArn": tenant.tenant_arn, + "CreatedTimestamp": tenant.created_timestamp.timestamp() as f64, + "SendingStatus": tenant.sending_status, + "Tags": tenant.tags, + } + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_tenants(&self, _req: &AwsRequest) -> Result { + let state = self.state.read(); + let tenants: Vec = state + .tenants + .values() + .map(|t| { + json!({ + "TenantName": t.tenant_name, + "TenantId": t.tenant_id, + "TenantArn": t.tenant_arn, + "CreatedTimestamp": t.created_timestamp.timestamp() as f64, + }) + }) + .collect(); + + let response = json!({ "Tenants": tenants }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn delete_tenant(&self, req: &AwsRequest) -> Result { + let body: Value = Self::parse_body(req)?; + let tenant_name = match body["TenantName"].as_str() { + Some(n) => n, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TenantName is required", + )); + } + }; + + let mut state = self.state.write(); + + if state.tenants.remove(tenant_name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Tenant {} does not exist", tenant_name), + )); + } + + state.tenant_resource_associations.remove(tenant_name); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn create_tenant_resource_association( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let tenant_name = match body["TenantName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TenantName is required", + )); + } + }; + let resource_arn = match body["ResourceArn"].as_str() { + Some(a) => a.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ResourceArn is required", + )); + } + }; + + let mut state = self.state.write(); + + if !state.tenants.contains_key(&tenant_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Tenant {} does not exist", tenant_name), + )); + } + + let assoc = TenantResourceAssociation { + resource_arn, + associated_timestamp: Utc::now(), + }; + + state + .tenant_resource_associations + .entry(tenant_name) + .or_default() + .push(assoc); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_tenant_resource_association( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let tenant_name = match body["TenantName"].as_str() { + Some(n) => n, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TenantName is required", + )); + } + }; + let resource_arn = match body["ResourceArn"].as_str() { + Some(a) => a, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ResourceArn is required", + )); + } + }; + + let mut state = self.state.write(); + + if let Some(assocs) = state.tenant_resource_associations.get_mut(tenant_name) { + let before = assocs.len(); + assocs.retain(|a| a.resource_arn != resource_arn); + if assocs.len() == before { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + "Resource association not found", + )); + } + } else { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + "Resource association not found", + )); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn list_tenant_resources( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let tenant_name = match body["TenantName"].as_str() { + Some(n) => n, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TenantName is required", + )); + } + }; + + let state = self.state.read(); + + if !state.tenants.contains_key(tenant_name) { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Tenant {} does not exist", tenant_name), + )); + } + + let resources: Vec = state + .tenant_resource_associations + .get(tenant_name) + .map(|assocs| { + assocs + .iter() + .map(|a| { + json!({ + "ResourceType": "RESOURCE", + "ResourceArn": a.resource_arn, + }) + }) + .collect() + }) + .unwrap_or_default(); + + let response = json!({ "TenantResources": resources }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_resource_tenants( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let resource_arn = match body["ResourceArn"].as_str() { + Some(a) => a, + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "ResourceArn is required", + )); + } + }; + + let state = self.state.read(); + let mut resource_tenants: Vec = Vec::new(); + + for (tenant_name, assocs) in &state.tenant_resource_associations { + for assoc in assocs { + if assoc.resource_arn == resource_arn { + if let Some(tenant) = state.tenants.get(tenant_name) { + resource_tenants.push(json!({ + "TenantName": tenant.tenant_name, + "TenantId": tenant.tenant_id, + "ResourceArn": assoc.resource_arn, + "AssociatedTimestamp": assoc.associated_timestamp.timestamp() as f64, + })); + } + } + } + } + + let response = json!({ "ResourceTenants": resource_tenants }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + // --- Reputation Entity operations --- + + pub(super) fn get_reputation_entity( + &self, + entity_type: &str, + entity_ref: &str, + ) -> Result { + let key = format!("{}/{}", entity_type, entity_ref); + let state = self.state.read(); + + let entity = match state.reputation_entities.get(&key) { + Some(e) => e, + None => { + // Return a default entity for any reference + let response = json!({ + "ReputationEntity": { + "ReputationEntityReference": entity_ref, + "ReputationEntityType": entity_type, + "SendingStatusAggregate": "ENABLED", + "CustomerManagedStatus": { + "SendingStatus": "ENABLED", + }, + "AwsSesManagedStatus": { + "SendingStatus": "ENABLED", + }, + } + }); + return Ok(AwsResponse::json(StatusCode::OK, response.to_string())); + } + }; + + let response = json!({ + "ReputationEntity": { + "ReputationEntityReference": entity.reputation_entity_reference, + "ReputationEntityType": entity.reputation_entity_type, + "ReputationManagementPolicy": entity.reputation_management_policy, + "SendingStatusAggregate": entity.sending_status_aggregate, + "CustomerManagedStatus": { + "SendingStatus": entity.customer_managed_status, + }, + "AwsSesManagedStatus": { + "SendingStatus": "ENABLED", + }, + } + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn list_reputation_entities( + &self, + _req: &AwsRequest, + ) -> Result { + let state = self.state.read(); + let entities: Vec = state + .reputation_entities + .values() + .map(|e| { + json!({ + "ReputationEntityReference": e.reputation_entity_reference, + "ReputationEntityType": e.reputation_entity_type, + "SendingStatusAggregate": e.sending_status_aggregate, + }) + }) + .collect(); + + let response = json!({ "ReputationEntities": entities }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_reputation_entity_customer_managed_status( + &self, + entity_type: &str, + entity_ref: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let sending_status = body["SendingStatus"] + .as_str() + .unwrap_or("ENABLED") + .to_string(); + + let key = format!("{}/{}", entity_type, entity_ref); + let mut state = self.state.write(); + + let entity = + state + .reputation_entities + .entry(key) + .or_insert_with(|| ReputationEntityState { + reputation_entity_reference: entity_ref.to_string(), + reputation_entity_type: entity_type.to_string(), + reputation_management_policy: None, + customer_managed_status: "ENABLED".to_string(), + sending_status_aggregate: "ENABLED".to_string(), + }); + + entity.customer_managed_status = sending_status; + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn update_reputation_entity_policy( + &self, + entity_type: &str, + entity_ref: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let policy = body["ReputationEntityPolicy"] + .as_str() + .map(|s| s.to_string()); + + let key = format!("{}/{}", entity_type, entity_ref); + let mut state = self.state.write(); + + let entity = + state + .reputation_entities + .entry(key) + .or_insert_with(|| ReputationEntityState { + reputation_entity_reference: entity_ref.to_string(), + reputation_entity_type: entity_type.to_string(), + reputation_management_policy: None, + customer_managed_status: "ENABLED".to_string(), + sending_status_aggregate: "ENABLED".to_string(), + }); + + entity.reputation_management_policy = policy; + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + // --- Metrics --- + + pub(super) fn batch_get_metric_data( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let queries = body["Queries"].as_array().cloned().unwrap_or_default(); + + let results: Vec = queries + .iter() + .filter_map(|q| { + let id = q["Id"].as_str()?; + Some(json!({ + "Id": id, + "Timestamps": [], + "Values": [], + })) + }) + .collect(); + + let response = json!({ + "Results": results, + "Errors": [], + }); + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } +} diff --git a/crates/fakecloud-ses/src/service.rs b/crates/fakecloud-ses/src/service/mod.rs similarity index 55% rename from crates/fakecloud-ses/src/service.rs rename to crates/fakecloud-ses/src/service/mod.rs index 2e1796e0..dd3f2074 100644 --- a/crates/fakecloud-ses/src/service.rs +++ b/crates/fakecloud-ses/src/service/mod.rs @@ -1,18 +1,20 @@ +mod account; +mod configuration_sets; +mod contact_lists; +mod identities; +mod misc; +mod sending; +mod suppression; +mod templates; + use async_trait::async_trait; -use chrono::Utc; use http::{Method, StatusCode}; use serde_json::{json, Value}; -use std::collections::HashMap; use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; use crate::fanout::SesDeliveryContext; -use crate::state::{ - AccountDetails, ConfigurationSet, Contact, ContactList, CustomVerificationEmailTemplate, - DedicatedIp, DedicatedIpPool, EmailIdentity, EmailTemplate, EventDestination, ExportJob, - ImportJob, MultiRegionEndpoint, ReputationEntityState, SentEmail, SharedSesState, - SuppressedDestination, Tenant, TenantResourceAssociation, Topic, TopicPreference, -}; +use crate::state::{EventDestination, SharedSesState, Topic, TopicPreference}; pub struct SesV2Service { state: SharedSesState, @@ -584,3564 +586,6 @@ impl SesV2Service { }); AwsResponse::json(status, body.to_string()) } - - fn get_account(&self) -> Result { - let state = self.state.read(); - let acct = &state.account_settings; - let production_access = acct - .details - .as_ref() - .and_then(|d| d.production_access_enabled) - .unwrap_or(true); - let mut response = json!({ - "DedicatedIpAutoWarmupEnabled": acct.dedicated_ip_auto_warmup_enabled, - "EnforcementStatus": "HEALTHY", - "ProductionAccessEnabled": production_access, - "SendQuota": { - "Max24HourSend": 50000.0, - "MaxSendRate": 14.0, - "SentLast24Hours": state.sent_emails.iter() - .filter(|e| e.timestamp > Utc::now() - chrono::Duration::hours(24)) - .count() as f64, - }, - "SendingEnabled": acct.sending_enabled, - "SuppressionAttributes": { - "SuppressedReasons": acct.suppressed_reasons, - }, - }); - if let Some(ref details) = acct.details { - let mut d = json!({}); - if let Some(ref mt) = details.mail_type { - d["MailType"] = json!(mt); - } - if let Some(ref url) = details.website_url { - d["WebsiteURL"] = json!(url); - } - if let Some(ref lang) = details.contact_language { - d["ContactLanguage"] = json!(lang); - } - if let Some(ref desc) = details.use_case_description { - d["UseCaseDescription"] = json!(desc); - } - if !details.additional_contact_email_addresses.is_empty() { - d["AdditionalContactEmailAddresses"] = - json!(details.additional_contact_email_addresses); - } - d["ReviewDetails"] = json!({ - "Status": "GRANTED", - "CaseId": "fakecloud-case-001", - }); - response["Details"] = d; - } - if let Some(ref vdm) = acct.vdm_attributes { - response["VdmAttributes"] = vdm.clone(); - } - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn create_email_identity(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let identity_name = match body["EmailIdentity"].as_str() { - Some(name) => name.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "EmailIdentity is required", - )); - } - }; - - let mut state = self.state.write(); - - if state.identities.contains_key(&identity_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Identity {} already exists", identity_name), - )); - } - - let identity_type = if identity_name.contains('@') { - "EMAIL_ADDRESS" - } else { - "DOMAIN" - }; - - let identity = EmailIdentity { - identity_name: identity_name.clone(), - identity_type: identity_type.to_string(), - verified: true, - created_at: Utc::now(), - dkim_signing_enabled: true, - dkim_signing_attributes_origin: "AWS_SES".to_string(), - dkim_domain_signing_private_key: None, - dkim_domain_signing_selector: None, - dkim_next_signing_key_length: None, - email_forwarding_enabled: true, - mail_from_domain: None, - mail_from_behavior_on_mx_failure: "USE_DEFAULT_VALUE".to_string(), - configuration_set_name: None, - }; - - state.identities.insert(identity_name, identity); - - let response = json!({ - "IdentityType": identity_type, - "VerifiedForSendingStatus": true, - "DkimAttributes": { - "SigningEnabled": true, - "Status": "SUCCESS", - "Tokens": [ - "token1", - "token2", - "token3", - ], - }, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_email_identities(&self) -> Result { - let state = self.state.read(); - let identities: Vec = state - .identities - .values() - .map(|id| { - json!({ - "IdentityType": id.identity_type, - "IdentityName": id.identity_name, - "SendingEnabled": true, - }) - }) - .collect(); - - let response = json!({ - "EmailIdentities": identities, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_email_identity(&self, identity_name: &str) -> Result { - let state = self.state.read(); - let identity = match state.identities.get(identity_name) { - Some(id) => id, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - }; - - let mail_from_domain = identity.mail_from_domain.as_deref().unwrap_or(""); - let mail_from_status = if mail_from_domain.is_empty() { - "FAILED" - } else { - "SUCCESS" - }; - - let mut response = json!({ - "IdentityType": identity.identity_type, - "VerifiedForSendingStatus": true, - "FeedbackForwardingStatus": identity.email_forwarding_enabled, - "DkimAttributes": { - "SigningEnabled": identity.dkim_signing_enabled, - "Status": "SUCCESS", - "SigningAttributesOrigin": identity.dkim_signing_attributes_origin, - "Tokens": [ - "token1", - "token2", - "token3", - ], - }, - "MailFromAttributes": { - "MailFromDomain": mail_from_domain, - "MailFromDomainStatus": mail_from_status, - "BehaviorOnMxFailure": identity.mail_from_behavior_on_mx_failure, - }, - "Tags": [], - }); - - if let Some(ref cs) = identity.configuration_set_name { - response["ConfigurationSetName"] = json!(cs); - } - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn delete_email_identity( - &self, - identity_name: &str, - req: &AwsRequest, - ) -> Result { - let mut state = self.state.write(); - - if state.identities.remove(identity_name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - - // Remove tags for this identity - let arn = format!( - "arn:aws:ses:{}:{}:identity/{}", - req.region, req.account_id, identity_name - ); - state.tags.remove(&arn); - - // Remove policies for this identity - state.identity_policies.remove(identity_name); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn create_configuration_set(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let name = match body["ConfigurationSetName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ConfigurationSetName is required", - )); - } - }; - - let mut state = self.state.write(); - - if state.configuration_sets.contains_key(&name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Configuration set {} already exists", name), - )); - } - - state.configuration_sets.insert( - name.clone(), - ConfigurationSet { - name, - sending_enabled: true, - tls_policy: "OPTIONAL".to_string(), - sending_pool_name: None, - custom_redirect_domain: None, - https_policy: None, - suppressed_reasons: Vec::new(), - reputation_metrics_enabled: false, - vdm_options: None, - archive_arn: None, - }, - ); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn list_configuration_sets(&self) -> Result { - let state = self.state.read(); - let sets: Vec = state - .configuration_sets - .keys() - .map(|name| json!(name)) - .collect(); - - let response = json!({ - "ConfigurationSets": sets, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_configuration_set(&self, name: &str) -> Result { - let state = self.state.read(); - - let cs = match state.configuration_sets.get(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - let mut delivery_options = json!({ - "TlsPolicy": cs.tls_policy, - }); - if let Some(ref pool) = cs.sending_pool_name { - delivery_options["SendingPoolName"] = json!(pool); - } - - let mut tracking_options = json!({}); - if let Some(ref domain) = cs.custom_redirect_domain { - tracking_options["CustomRedirectDomain"] = json!(domain); - } - if let Some(ref policy) = cs.https_policy { - tracking_options["HttpsPolicy"] = json!(policy); - } - - let mut response = json!({ - "ConfigurationSetName": name, - "DeliveryOptions": delivery_options, - "ReputationOptions": { - "ReputationMetricsEnabled": cs.reputation_metrics_enabled, - }, - "SendingOptions": { - "SendingEnabled": cs.sending_enabled, - }, - "Tags": [], - "TrackingOptions": tracking_options, - }); - - if !cs.suppressed_reasons.is_empty() { - response["SuppressionOptions"] = json!({ - "SuppressedReasons": cs.suppressed_reasons, - }); - } - - if let Some(ref vdm) = cs.vdm_options { - response["VdmOptions"] = vdm.clone(); - } - - if let Some(ref arn) = cs.archive_arn { - response["ArchivingOptions"] = json!({ - "ArchiveArn": arn, - }); - } - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn delete_configuration_set( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let mut state = self.state.write(); - - if state.configuration_sets.remove(name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - - // Remove tags for this configuration set - let arn = format!( - "arn:aws:ses:{}:{}:configuration-set/{}", - req.region, req.account_id, name - ); - state.tags.remove(&arn); - - // Remove event destinations for this configuration set - state.event_destinations.remove(name); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn create_email_template(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let template_name = match body["TemplateName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TemplateName is required", - )); - } - }; - - let mut state = self.state.write(); - - if state.templates.contains_key(&template_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Template {} already exists", template_name), - )); - } - - let template = EmailTemplate { - template_name: template_name.clone(), - subject: body["TemplateContent"]["Subject"] - .as_str() - .map(|s| s.to_string()), - html_body: body["TemplateContent"]["Html"] - .as_str() - .map(|s| s.to_string()), - text_body: body["TemplateContent"]["Text"] - .as_str() - .map(|s| s.to_string()), - created_at: Utc::now(), - }; - - state.templates.insert(template_name, template); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn list_email_templates(&self) -> Result { - let state = self.state.read(); - let templates: Vec = state - .templates - .values() - .map(|t| { - json!({ - "TemplateName": t.template_name, - "CreatedTimestamp": t.created_at.timestamp() as f64, - }) - }) - .collect(); - - let response = json!({ - "TemplatesMetadata": templates, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_email_template(&self, name: &str) -> Result { - let state = self.state.read(); - let template = match state.templates.get(name) { - Some(t) => t, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Template {} does not exist", name), - )); - } - }; - - let response = json!({ - "TemplateName": template.template_name, - "TemplateContent": { - "Subject": template.subject, - "Html": template.html_body, - "Text": template.text_body, - }, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_email_template( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let template = match state.templates.get_mut(name) { - Some(t) => t, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Template {} does not exist", name), - )); - } - }; - - if let Some(subject) = body["TemplateContent"]["Subject"].as_str() { - template.subject = Some(subject.to_string()); - } - if let Some(html) = body["TemplateContent"]["Html"].as_str() { - template.html_body = Some(html.to_string()); - } - if let Some(text) = body["TemplateContent"]["Text"].as_str() { - template.text_body = Some(text.to_string()); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_email_template(&self, name: &str) -> Result { - let mut state = self.state.write(); - - if state.templates.remove(name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Template {} does not exist", name), - )); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn send_email(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - - if !body["Content"].is_object() - || (!body["Content"]["Simple"].is_object() - && !body["Content"]["Raw"].is_object() - && !body["Content"]["Template"].is_object()) - { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Content is required and must contain Simple, Raw, or Template", - )); - } - - let from = body["FromEmailAddress"].as_str().unwrap_or("").to_string(); - - let to = extract_string_array(&body["Destination"]["ToAddresses"]); - let cc = extract_string_array(&body["Destination"]["CcAddresses"]); - let bcc = extract_string_array(&body["Destination"]["BccAddresses"]); - - let config_set_name = body["ConfigurationSetName"].as_str().map(|s| s.to_string()); - - let (subject, html_body, text_body, raw_data, template_name, template_data) = - if body["Content"]["Simple"].is_object() { - let simple = &body["Content"]["Simple"]; - let subject = simple["Subject"]["Data"].as_str().map(|s| s.to_string()); - let html = simple["Body"]["Html"]["Data"] - .as_str() - .map(|s| s.to_string()); - let text = simple["Body"]["Text"]["Data"] - .as_str() - .map(|s| s.to_string()); - (subject, html, text, None, None, None) - } else if body["Content"]["Raw"].is_object() { - let raw = body["Content"]["Raw"]["Data"] - .as_str() - .map(|s| s.to_string()); - (None, None, None, raw, None, None) - } else if body["Content"]["Template"].is_object() { - let tmpl = &body["Content"]["Template"]; - let tmpl_name = tmpl["TemplateName"].as_str().map(|s| s.to_string()); - let tmpl_data = tmpl["TemplateData"].as_str().map(|s| s.to_string()); - (None, None, None, None, tmpl_name, tmpl_data) - } else { - (None, None, None, None, None, None) - }; - - let message_id = uuid::Uuid::new_v4().to_string(); - - let sent = SentEmail { - message_id: message_id.clone(), - from, - to, - cc, - bcc, - subject, - html_body, - text_body, - raw_data, - template_name, - template_data, - timestamp: Utc::now(), - }; - - // Event fanout: check suppression list, generate events, deliver to destinations - if let Some(ref ctx) = self.delivery_ctx { - crate::fanout::process_send_events(ctx, &sent, config_set_name.as_deref()); - } - - self.state.write().sent_emails.push(sent); - - let response = json!({ - "MessageId": message_id, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // --- Contact List operations --- - - fn create_contact_list(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let name = match body["ContactListName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ContactListName is required", - )); - } - }; - - let mut state = self.state.write(); - - if state.contact_lists.contains_key(&name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("List with name {} already exists.", name), - )); - } - - let topics = parse_topics(&body["Topics"]); - let description = body["Description"].as_str().map(|s| s.to_string()); - let now = Utc::now(); - - state.contact_lists.insert( - name.clone(), - ContactList { - contact_list_name: name.clone(), - description, - topics, - created_at: now, - last_updated_at: now, - }, - ); - state.contacts.insert(name, HashMap::new()); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn get_contact_list(&self, name: &str) -> Result { - let state = self.state.read(); - let list = match state.contact_lists.get(name) { - Some(l) => l, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", name), - )); - } - }; - - let topics: Vec = list - .topics - .iter() - .map(|t| { - json!({ - "TopicName": t.topic_name, - "DisplayName": t.display_name, - "Description": t.description, - "DefaultSubscriptionStatus": t.default_subscription_status, - }) - }) - .collect(); - - let response = json!({ - "ContactListName": list.contact_list_name, - "Description": list.description, - "Topics": topics, - "CreatedTimestamp": list.created_at.timestamp() as f64, - "LastUpdatedTimestamp": list.last_updated_at.timestamp() as f64, - "Tags": [], - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_contact_lists(&self) -> Result { - let state = self.state.read(); - let lists: Vec = state - .contact_lists - .values() - .map(|l| { - json!({ - "ContactListName": l.contact_list_name, - "LastUpdatedTimestamp": l.last_updated_at.timestamp() as f64, - }) - }) - .collect(); - - let response = json!({ - "ContactLists": lists, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_contact_list( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let list = match state.contact_lists.get_mut(name) { - Some(l) => l, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", name), - )); - } - }; - - if let Some(desc) = body.get("Description") { - list.description = desc.as_str().map(|s| s.to_string()); - } - if body.get("Topics").is_some() { - list.topics = parse_topics(&body["Topics"]); - } - list.last_updated_at = Utc::now(); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_contact_list( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let mut state = self.state.write(); - - if state.contact_lists.remove(name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", name), - )); - } - - // Also delete all contacts in this list - state.contacts.remove(name); - - // Remove tags for this contact list - let arn = format!( - "arn:aws:ses:{}:{}:contact-list/{}", - req.region, req.account_id, name - ); - state.tags.remove(&arn); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Contact operations --- - - fn create_contact( - &self, - list_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let email = match body["EmailAddress"].as_str() { - Some(e) => e.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "EmailAddress is required", - )); - } - }; - - let mut state = self.state.write(); - - if !state.contact_lists.contains_key(list_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", list_name), - )); - } - - let contacts = state.contacts.entry(list_name.to_string()).or_default(); - - if contacts.contains_key(&email) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Contact already exists in list {}", list_name), - )); - } - - let topic_preferences = parse_topic_preferences(&body["TopicPreferences"]); - let unsubscribe_all = body["UnsubscribeAll"].as_bool().unwrap_or(false); - let attributes_data = body["AttributesData"].as_str().map(|s| s.to_string()); - let now = Utc::now(); - - contacts.insert( - email.clone(), - Contact { - email_address: email, - topic_preferences, - unsubscribe_all, - attributes_data, - created_at: now, - last_updated_at: now, - }, - ); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn get_contact(&self, list_name: &str, email: &str) -> Result { - let state = self.state.read(); - - if !state.contact_lists.contains_key(list_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", list_name), - )); - } - - let contact = state.contacts.get(list_name).and_then(|m| m.get(email)); - - let contact = match contact { - Some(c) => c, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Contact {} does not exist in list {}", email, list_name), - )); - } - }; - - // Build TopicDefaultPreferences from the contact list's topics - let list = state.contact_lists.get(list_name).unwrap(); - let topic_default_preferences: Vec = list - .topics - .iter() - .map(|t| { - json!({ - "TopicName": t.topic_name, - "SubscriptionStatus": t.default_subscription_status, - }) - }) - .collect(); - - let topic_preferences: Vec = contact - .topic_preferences - .iter() - .map(|tp| { - json!({ - "TopicName": tp.topic_name, - "SubscriptionStatus": tp.subscription_status, - }) - }) - .collect(); - - let mut response = json!({ - "ContactListName": list_name, - "EmailAddress": contact.email_address, - "TopicPreferences": topic_preferences, - "TopicDefaultPreferences": topic_default_preferences, - "UnsubscribeAll": contact.unsubscribe_all, - "CreatedTimestamp": contact.created_at.timestamp() as f64, - "LastUpdatedTimestamp": contact.last_updated_at.timestamp() as f64, - }); - - if let Some(ref attrs) = contact.attributes_data { - response["AttributesData"] = json!(attrs); - } - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_contacts(&self, list_name: &str) -> Result { - let state = self.state.read(); - - if !state.contact_lists.contains_key(list_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", list_name), - )); - } - - let contacts: Vec = state - .contacts - .get(list_name) - .map(|m| { - m.values() - .map(|c| { - let topic_prefs: Vec = c - .topic_preferences - .iter() - .map(|tp| { - json!({ - "TopicName": tp.topic_name, - "SubscriptionStatus": tp.subscription_status, - }) - }) - .collect(); - - // Build TopicDefaultPreferences from the list's topics - let list = state.contact_lists.get(list_name).unwrap(); - let topic_defaults: Vec = list - .topics - .iter() - .map(|t| { - json!({ - "TopicName": t.topic_name, - "SubscriptionStatus": t.default_subscription_status, - }) - }) - .collect(); - - json!({ - "EmailAddress": c.email_address, - "TopicPreferences": topic_prefs, - "TopicDefaultPreferences": topic_defaults, - "UnsubscribeAll": c.unsubscribe_all, - "LastUpdatedTimestamp": c.last_updated_at.timestamp() as f64, - }) - }) - .collect() - }) - .unwrap_or_default(); - - let response = json!({ - "Contacts": contacts, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_contact( - &self, - list_name: &str, - email: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - if !state.contact_lists.contains_key(list_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", list_name), - )); - } - - let contact = state - .contacts - .get_mut(list_name) - .and_then(|m| m.get_mut(email)); - - let contact = match contact { - Some(c) => c, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Contact {} does not exist in list {}", email, list_name), - )); - } - }; - - if body.get("TopicPreferences").is_some() { - contact.topic_preferences = parse_topic_preferences(&body["TopicPreferences"]); - } - if let Some(unsub) = body["UnsubscribeAll"].as_bool() { - contact.unsubscribe_all = unsub; - } - if let Some(attrs) = body.get("AttributesData") { - contact.attributes_data = attrs.as_str().map(|s| s.to_string()); - } - contact.last_updated_at = Utc::now(); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_contact(&self, list_name: &str, email: &str) -> Result { - let mut state = self.state.write(); - - if !state.contact_lists.contains_key(list_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("List with name {} does not exist.", list_name), - )); - } - - let removed = state - .contacts - .get_mut(list_name) - .and_then(|m| m.remove(email)); - - if removed.is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Contact {} does not exist in list {}", email, list_name), - )); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Tag operations --- - - /// Validate that a resource ARN refers to an existing resource. - /// Returns `None` if the resource exists, or `Some(error_response)` if not. - fn validate_resource_arn(&self, arn: &str) -> Option { - let state = self.state.read(); - - // Parse ARN: arn:aws:ses:{region}:{account}:{resource-type}/{name} - let parts: Vec<&str> = arn.split(':').collect(); - if parts.len() < 6 { - return Some(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Resource not found: {arn}"), - )); - } - - let resource = parts[5..].join(":"); - let found = if let Some(name) = resource.strip_prefix("identity/") { - state.identities.contains_key(name) - } else if let Some(name) = resource.strip_prefix("configuration-set/") { - state.configuration_sets.contains_key(name) - } else if let Some(name) = resource.strip_prefix("contact-list/") { - state.contact_lists.contains_key(name) - } else { - false - }; - - if found { - None - } else { - Some(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Resource not found: {arn}"), - )) - } - } - - fn tag_resource(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - - let arn = match body["ResourceArn"].as_str() { - Some(a) => a.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ResourceArn is required", - )); - } - }; - - let tags_arr = match body["Tags"].as_array() { - Some(arr) => arr, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Tags is required", - )); - } - }; - - if let Some(resp) = self.validate_resource_arn(&arn) { - return Ok(resp); - } - - let mut state = self.state.write(); - let tag_map = state.tags.entry(arn).or_default(); - for tag in tags_arr { - if let (Some(k), Some(v)) = (tag["Key"].as_str(), tag["Value"].as_str()) { - tag_map.insert(k.to_string(), v.to_string()); - } - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn untag_resource(&self, req: &AwsRequest) -> Result { - // ResourceArn and TagKeys come as query params - let arn = match req.query_params.get("ResourceArn") { - Some(a) => a.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ResourceArn is required", - )); - } - }; - - if let Some(resp) = self.validate_resource_arn(&arn) { - return Ok(resp); - } - - // Parse TagKeys from raw query string (supports repeated params) - let tag_keys: Vec = form_urlencoded::parse(req.raw_query.as_bytes()) - .filter(|(k, _)| k == "TagKeys") - .map(|(_, v)| v.into_owned()) - .collect(); - - let mut state = self.state.write(); - if let Some(tag_map) = state.tags.get_mut(&arn) { - for key in &tag_keys { - tag_map.remove(key); - } - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn list_tags_for_resource(&self, req: &AwsRequest) -> Result { - let arn = match req.query_params.get("ResourceArn") { - Some(a) => a.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ResourceArn is required", - )); - } - }; - - if let Some(resp) = self.validate_resource_arn(&arn) { - return Ok(resp); - } - - let state = self.state.read(); - let tags = state.tags.get(&arn); - let tags_json = match tags { - Some(t) => fakecloud_core::tags::tags_to_json(t, "Key", "Value"), - None => vec![], - }; - - let response = json!({ - "Tags": tags_json, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // --- Suppression List operations --- - - fn put_suppressed_destination(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let email = match body["EmailAddress"].as_str() { - Some(e) => e.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "EmailAddress is required", - )); - } - }; - let reason = match body["Reason"].as_str() { - Some(r) if r == "BOUNCE" || r == "COMPLAINT" => r.to_string(), - Some(_) => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Reason must be BOUNCE or COMPLAINT", - )); - } - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Reason is required", - )); - } - }; - - let mut state = self.state.write(); - state.suppressed_destinations.insert( - email.clone(), - SuppressedDestination { - email_address: email, - reason, - last_update_time: Utc::now(), - }, - ); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn get_suppressed_destination(&self, email: &str) -> Result { - let state = self.state.read(); - let dest = match state.suppressed_destinations.get(email) { - Some(d) => d, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("{} is not on the suppression list", email), - )); - } - }; - - let response = json!({ - "SuppressedDestination": { - "EmailAddress": dest.email_address, - "Reason": dest.reason, - "LastUpdateTime": dest.last_update_time.timestamp() as f64, - } - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn delete_suppressed_destination(&self, email: &str) -> Result { - let mut state = self.state.write(); - if state.suppressed_destinations.remove(email).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("{} is not on the suppression list", email), - )); - } - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn list_suppressed_destinations(&self) -> Result { - let state = self.state.read(); - let summaries: Vec = state - .suppressed_destinations - .values() - .map(|d| { - json!({ - "EmailAddress": d.email_address, - "Reason": d.reason, - "LastUpdateTime": d.last_update_time.timestamp() as f64, - }) - }) - .collect(); - - let response = json!({ - "SuppressedDestinationSummaries": summaries, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // --- Event Destination operations --- - - fn create_configuration_set_event_destination( - &self, - config_set_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let state_read = self.state.read(); - if !state_read.configuration_sets.contains_key(config_set_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", config_set_name), - )); - } - drop(state_read); - - let dest_name = match body["EventDestinationName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "EventDestinationName is required", - )); - } - }; - - let event_dest = parse_event_destination_definition(&dest_name, &body["EventDestination"]); - - let mut state = self.state.write(); - let dests = state - .event_destinations - .entry(config_set_name.to_string()) - .or_default(); - - if dests.iter().any(|d| d.name == dest_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Event destination {} already exists", dest_name), - )); - } - - dests.push(event_dest); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn get_configuration_set_event_destinations( - &self, - config_set_name: &str, - ) -> Result { - let state = self.state.read(); - - if !state.configuration_sets.contains_key(config_set_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", config_set_name), - )); - } - - let dests = state - .event_destinations - .get(config_set_name) - .cloned() - .unwrap_or_default(); - - let dests_json: Vec = dests.iter().map(event_destination_to_json).collect(); - - let response = json!({ - "EventDestinations": dests_json, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_configuration_set_event_destination( - &self, - config_set_name: &str, - dest_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let mut state = self.state.write(); - - if !state.configuration_sets.contains_key(config_set_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", config_set_name), - )); - } - - let dests = state - .event_destinations - .entry(config_set_name.to_string()) - .or_default(); - - let existing = match dests.iter_mut().find(|d| d.name == dest_name) { - Some(d) => d, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Event destination {} does not exist", dest_name), - )); - } - }; - - let updated = parse_event_destination_definition(dest_name, &body["EventDestination"]); - *existing = updated; - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_configuration_set_event_destination( - &self, - config_set_name: &str, - dest_name: &str, - ) -> Result { - let mut state = self.state.write(); - - if !state.configuration_sets.contains_key(config_set_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", config_set_name), - )); - } - - let dests = state - .event_destinations - .entry(config_set_name.to_string()) - .or_default(); - - let len_before = dests.len(); - dests.retain(|d| d.name != dest_name); - - if dests.len() == len_before { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Event destination {} does not exist", dest_name), - )); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Email Identity Policy operations --- - - fn create_email_identity_policy( - &self, - identity_name: &str, - policy_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let policy = match body["Policy"].as_str() { - Some(p) => p.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Policy is required", - )); - } - }; - - let mut state = self.state.write(); - - if !state.identities.contains_key(identity_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - - let policies = state - .identity_policies - .entry(identity_name.to_string()) - .or_default(); - - if policies.contains_key(policy_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Policy {} already exists", policy_name), - )); - } - - policies.insert(policy_name.to_string(), policy); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn get_email_identity_policies( - &self, - identity_name: &str, - ) -> Result { - let state = self.state.read(); - - if !state.identities.contains_key(identity_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - - let policies = state - .identity_policies - .get(identity_name) - .cloned() - .unwrap_or_default(); - - let policies_json: Value = policies - .into_iter() - .map(|(k, v)| (k, Value::String(v))) - .collect::>() - .into(); - - let response = json!({ - "Policies": policies_json, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_email_identity_policy( - &self, - identity_name: &str, - policy_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let policy = match body["Policy"].as_str() { - Some(p) => p.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Policy is required", - )); - } - }; - - let mut state = self.state.write(); - - if !state.identities.contains_key(identity_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - - let policies = state - .identity_policies - .entry(identity_name.to_string()) - .or_default(); - - if !policies.contains_key(policy_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Policy {} does not exist", policy_name), - )); - } - - policies.insert(policy_name.to_string(), policy); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_email_identity_policy( - &self, - identity_name: &str, - policy_name: &str, - ) -> Result { - let mut state = self.state.write(); - - if !state.identities.contains_key(identity_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - - let policies = state - .identity_policies - .entry(identity_name.to_string()) - .or_default(); - - if policies.remove(policy_name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Policy {} does not exist", policy_name), - )); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Identity Attribute operations --- - - fn put_email_identity_dkim_attributes( - &self, - identity_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let identity = match state.identities.get_mut(identity_name) { - Some(id) => id, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - }; - - if let Some(enabled) = body["SigningEnabled"].as_bool() { - identity.dkim_signing_enabled = enabled; - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_email_identity_dkim_signing_attributes( - &self, - identity_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let identity = match state.identities.get_mut(identity_name) { - Some(id) => id, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - }; - - if let Some(origin) = body["SigningAttributesOrigin"].as_str() { - identity.dkim_signing_attributes_origin = origin.to_string(); - } - - if let Some(attrs) = body.get("SigningAttributes") { - if let Some(key) = attrs["DomainSigningPrivateKey"].as_str() { - identity.dkim_domain_signing_private_key = Some(key.to_string()); - } - if let Some(selector) = attrs["DomainSigningSelector"].as_str() { - identity.dkim_domain_signing_selector = Some(selector.to_string()); - } - if let Some(length) = attrs["NextSigningKeyLength"].as_str() { - identity.dkim_next_signing_key_length = Some(length.to_string()); - } - } - - let response = json!({ - "DkimStatus": "SUCCESS", - "DkimTokens": ["token1", "token2", "token3"], - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn put_email_identity_feedback_attributes( - &self, - identity_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let identity = match state.identities.get_mut(identity_name) { - Some(id) => id, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - }; - - if let Some(enabled) = body["EmailForwardingEnabled"].as_bool() { - identity.email_forwarding_enabled = enabled; - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_email_identity_mail_from_attributes( - &self, - identity_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let identity = match state.identities.get_mut(identity_name) { - Some(id) => id, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - }; - - if let Some(domain) = body["MailFromDomain"].as_str() { - identity.mail_from_domain = Some(domain.to_string()); - } - if let Some(behavior) = body["BehaviorOnMxFailure"].as_str() { - identity.mail_from_behavior_on_mx_failure = behavior.to_string(); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_email_identity_configuration_set_attributes( - &self, - identity_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let identity = match state.identities.get_mut(identity_name) { - Some(id) => id, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Identity {} does not exist", identity_name), - )); - } - }; - - identity.configuration_set_name = - body["ConfigurationSetName"].as_str().map(|s| s.to_string()); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Configuration Set Options --- - - fn put_configuration_set_sending_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - if let Some(enabled) = body["SendingEnabled"].as_bool() { - cs.sending_enabled = enabled; - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_configuration_set_delivery_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - if let Some(policy) = body["TlsPolicy"].as_str() { - cs.tls_policy = policy.to_string(); - } - if let Some(pool) = body["SendingPoolName"].as_str() { - cs.sending_pool_name = Some(pool.to_string()); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_configuration_set_tracking_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - if let Some(domain) = body["CustomRedirectDomain"].as_str() { - cs.custom_redirect_domain = Some(domain.to_string()); - } - if let Some(policy) = body["HttpsPolicy"].as_str() { - cs.https_policy = Some(policy.to_string()); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_configuration_set_suppression_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - cs.suppressed_reasons = extract_string_array(&body["SuppressedReasons"]); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_configuration_set_reputation_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - if let Some(enabled) = body["ReputationMetricsEnabled"].as_bool() { - cs.reputation_metrics_enabled = enabled; - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_configuration_set_vdm_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - cs.vdm_options = Some(body); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_configuration_set_archiving_options( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let cs = match state.configuration_sets.get_mut(name) { - Some(cs) => cs, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Configuration set {} does not exist", name), - )); - } - }; - - cs.archive_arn = body["ArchiveArn"].as_str().map(|s| s.to_string()); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Custom Verification Email Template operations --- - - fn create_custom_verification_email_template( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let template_name = match body["TemplateName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TemplateName is required", - )); - } - }; - - let from_email = body["FromEmailAddress"].as_str().unwrap_or("").to_string(); - let subject = body["TemplateSubject"].as_str().unwrap_or("").to_string(); - let content = body["TemplateContent"].as_str().unwrap_or("").to_string(); - let success_url = body["SuccessRedirectionURL"] - .as_str() - .unwrap_or("") - .to_string(); - let failure_url = body["FailureRedirectionURL"] - .as_str() - .unwrap_or("") - .to_string(); - - let mut state = self.state.write(); - - if state - .custom_verification_email_templates - .contains_key(&template_name) - { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!( - "Custom verification email template {} already exists", - template_name - ), - )); - } - - state.custom_verification_email_templates.insert( - template_name.clone(), - CustomVerificationEmailTemplate { - template_name, - from_email_address: from_email, - template_subject: subject, - template_content: content, - success_redirection_url: success_url, - failure_redirection_url: failure_url, - created_at: Utc::now(), - }, - ); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn get_custom_verification_email_template( - &self, - name: &str, - ) -> Result { - let state = self.state.read(); - let tmpl = match state.custom_verification_email_templates.get(name) { - Some(t) => t, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Custom verification email template {} does not exist", name), - )); - } - }; - - let response = json!({ - "TemplateName": tmpl.template_name, - "FromEmailAddress": tmpl.from_email_address, - "TemplateSubject": tmpl.template_subject, - "TemplateContent": tmpl.template_content, - "SuccessRedirectionURL": tmpl.success_redirection_url, - "FailureRedirectionURL": tmpl.failure_redirection_url, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_custom_verification_email_templates( - &self, - req: &AwsRequest, - ) -> Result { - let state = self.state.read(); - - let page_size: usize = req - .query_params - .get("PageSize") - .and_then(|s| s.parse().ok()) - .unwrap_or(20); - - let mut templates: Vec<&CustomVerificationEmailTemplate> = - state.custom_verification_email_templates.values().collect(); - templates.sort_by(|a, b| a.template_name.cmp(&b.template_name)); - - let next_token = req.query_params.get("NextToken"); - let start_idx = if let Some(token) = next_token { - templates - .iter() - .position(|t| t.template_name == *token) - .unwrap_or(0) - } else { - 0 - }; - - let page: Vec = templates - .iter() - .skip(start_idx) - .take(page_size) - .map(|t| { - json!({ - "TemplateName": t.template_name, - "FromEmailAddress": t.from_email_address, - "TemplateSubject": t.template_subject, - "SuccessRedirectionURL": t.success_redirection_url, - "FailureRedirectionURL": t.failure_redirection_url, - }) - }) - .collect(); - - let mut response = json!({ - "CustomVerificationEmailTemplates": page, - }); - - // Set NextToken if there are more results - if start_idx + page_size < templates.len() { - if let Some(next) = templates.get(start_idx + page_size) { - response["NextToken"] = json!(next.template_name); - } - } - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_custom_verification_email_template( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let mut state = self.state.write(); - - let tmpl = match state.custom_verification_email_templates.get_mut(name) { - Some(t) => t, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Custom verification email template {} does not exist", name), - )); - } - }; - - if let Some(from) = body["FromEmailAddress"].as_str() { - tmpl.from_email_address = from.to_string(); - } - if let Some(subject) = body["TemplateSubject"].as_str() { - tmpl.template_subject = subject.to_string(); - } - if let Some(content) = body["TemplateContent"].as_str() { - tmpl.template_content = content.to_string(); - } - if let Some(url) = body["SuccessRedirectionURL"].as_str() { - tmpl.success_redirection_url = url.to_string(); - } - if let Some(url) = body["FailureRedirectionURL"].as_str() { - tmpl.failure_redirection_url = url.to_string(); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_custom_verification_email_template( - &self, - name: &str, - ) -> Result { - let mut state = self.state.write(); - - if state - .custom_verification_email_templates - .remove(name) - .is_none() - { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Custom verification email template {} does not exist", name), - )); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn send_custom_verification_email( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let email_address = match body["EmailAddress"].as_str() { - Some(e) => e.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "EmailAddress is required", - )); - } - }; - - let template_name = match body["TemplateName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TemplateName is required", - )); - } - }; - - // Verify template exists - { - let state = self.state.read(); - if !state - .custom_verification_email_templates - .contains_key(&template_name) - { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!( - "Custom verification email template {} does not exist", - template_name - ), - )); - } - } - - let message_id = uuid::Uuid::new_v4().to_string(); - - // Store as a sent email for introspection - let sent = SentEmail { - message_id: message_id.clone(), - from: String::new(), - to: vec![email_address], - cc: Vec::new(), - bcc: Vec::new(), - subject: Some(format!("Custom verification: {}", template_name)), - html_body: None, - text_body: None, - raw_data: None, - template_name: Some(template_name), - template_data: None, - timestamp: Utc::now(), - }; - - self.state.write().sent_emails.push(sent); - - let response = json!({ - "MessageId": message_id, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // --- TestRenderEmailTemplate --- - - fn test_render_email_template( - &self, - template_name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - - let template_data_str = match body["TemplateData"].as_str() { - Some(d) => d.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TemplateData is required", - )); - } - }; - - let state = self.state.read(); - let template = match state.templates.get(template_name) { - Some(t) => t, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Template {} does not exist", template_name), - )); - } - }; - - // Parse template data JSON - let data: HashMap = - serde_json::from_str(&template_data_str).unwrap_or_default(); - - let substitute = |text: &str| -> String { - let mut result = text.to_string(); - for (key, value) in &data { - let placeholder = format!("{{{{{}}}}}", key); - let replacement = match value { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - result = result.replace(&placeholder, &replacement); - } - result - }; - - let rendered_subject = template - .subject - .as_deref() - .map(&substitute) - .unwrap_or_default(); - let rendered_html = template.html_body.as_deref().map(&substitute); - let rendered_text = template.text_body.as_deref().map(&substitute); - - // Build a simplified MIME message - let mut mime = format!("Subject: {}\r\n", rendered_subject); - mime.push_str("MIME-Version: 1.0\r\n"); - mime.push_str("Content-Type: text/html; charset=UTF-8\r\n"); - mime.push_str("\r\n"); - if let Some(ref html) = rendered_html { - mime.push_str(html); - } else if let Some(ref text) = rendered_text { - mime.push_str(text); - } - - let response = json!({ - "RenderedTemplate": mime, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn send_bulk_email(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - - let from = body["FromEmailAddress"].as_str().unwrap_or("").to_string(); - let config_set_name = body["ConfigurationSetName"].as_str().map(|s| s.to_string()); - - let entries = match body["BulkEmailEntries"].as_array() { - Some(arr) if !arr.is_empty() => arr.clone(), - _ => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "BulkEmailEntries is required and must not be empty", - )); - } - }; - - let mut results = Vec::new(); - - for entry in &entries { - let to = extract_string_array(&entry["Destination"]["ToAddresses"]); - let cc = extract_string_array(&entry["Destination"]["CcAddresses"]); - let bcc = extract_string_array(&entry["Destination"]["BccAddresses"]); - - let message_id = uuid::Uuid::new_v4().to_string(); - - let template_name = body["DefaultContent"]["Template"]["TemplateName"] - .as_str() - .map(|s| s.to_string()); - let template_data = entry["ReplacementEmailContent"]["ReplacementTemplate"] - ["ReplacementTemplateData"] - .as_str() - .or_else(|| body["DefaultContent"]["Template"]["TemplateData"].as_str()) - .map(|s| s.to_string()); - - let sent = SentEmail { - message_id: message_id.clone(), - from: from.clone(), - to, - cc, - bcc, - subject: None, - html_body: None, - text_body: None, - raw_data: None, - template_name, - template_data, - timestamp: Utc::now(), - }; - - // Event fanout for each bulk entry - if let Some(ref ctx) = self.delivery_ctx { - crate::fanout::process_send_events(ctx, &sent, config_set_name.as_deref()); - } - - self.state.write().sent_emails.push(sent); - - results.push(json!({ - "Status": "SUCCESS", - "MessageId": message_id, - })); - } - - let response = json!({ - "BulkEmailEntryResults": results, - }); - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // ── Dedicated IP Pools ────────────────────────────────────────────── - - fn create_dedicated_ip_pool(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let pool_name = match body["PoolName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "PoolName is required", - )); - } - }; - let scaling_mode = body["ScalingMode"] - .as_str() - .unwrap_or("STANDARD") - .to_string(); - - let mut state = self.state.write(); - - if state.dedicated_ip_pools.contains_key(&pool_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Pool {} already exists", pool_name), - )); - } - - // For MANAGED pools, generate some fake IPs - if scaling_mode == "MANAGED" { - let pool_idx = state.dedicated_ip_pools.len() as u8; - for i in 1..=3 { - let ip_addr = format!("198.51.100.{}", pool_idx * 10 + i); - state.dedicated_ips.insert( - ip_addr.clone(), - DedicatedIp { - ip: ip_addr, - warmup_status: "NOT_APPLICABLE".to_string(), - warmup_percentage: -1, - pool_name: pool_name.clone(), - }, - ); - } - } - - state.dedicated_ip_pools.insert( - pool_name.clone(), - DedicatedIpPool { - pool_name, - scaling_mode, - }, - ); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn list_dedicated_ip_pools(&self) -> Result { - let state = self.state.read(); - let pools: Vec<&str> = state - .dedicated_ip_pools - .keys() - .map(|k| k.as_str()) - .collect(); - let response = json!({ "DedicatedIpPools": pools }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn delete_dedicated_ip_pool(&self, name: &str) -> Result { - let mut state = self.state.write(); - if state.dedicated_ip_pools.remove(name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Pool {} does not exist", name), - )); - } - // Remove IPs associated with this pool - state.dedicated_ips.retain(|_, ip| ip.pool_name != name); - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_dedicated_ip_pool_scaling_attributes( - &self, - name: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let scaling_mode = match body["ScalingMode"].as_str() { - Some(m) => m.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ScalingMode is required", - )); - } - }; - - let mut state = self.state.write(); - let pool = match state.dedicated_ip_pools.get_mut(name) { - Some(p) => p, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Pool {} does not exist", name), - )); - } - }; - - if pool.scaling_mode == "MANAGED" && scaling_mode == "STANDARD" { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "Cannot change scaling mode from MANAGED to STANDARD", - )); - } - - let old_mode = pool.scaling_mode.clone(); - pool.scaling_mode = scaling_mode.clone(); - - // If changing from STANDARD to MANAGED, generate IPs - if old_mode == "STANDARD" && scaling_mode == "MANAGED" { - let pool_idx = state.dedicated_ip_pools.len() as u8; - for i in 1..=3u8 { - let ip_addr = format!("198.51.100.{}", pool_idx * 10 + i); - state.dedicated_ips.insert( - ip_addr.clone(), - DedicatedIp { - ip: ip_addr, - warmup_status: "NOT_APPLICABLE".to_string(), - warmup_percentage: -1, - pool_name: name.to_string(), - }, - ); - } - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // ── Dedicated IPs ─────────────────────────────────────────────────── - - fn get_dedicated_ip(&self, ip: &str) -> Result { - let state = self.state.read(); - let dip = match state.dedicated_ips.get(ip) { - Some(d) => d, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Dedicated IP {} does not exist", ip), - )); - } - }; - let response = json!({ - "DedicatedIp": { - "Ip": dip.ip, - "WarmupStatus": dip.warmup_status, - "WarmupPercentage": dip.warmup_percentage, - "PoolName": dip.pool_name, - } - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_dedicated_ips(&self, req: &AwsRequest) -> Result { - let state = self.state.read(); - let pool_filter = req.query_params.get("PoolName").map(|s| s.as_str()); - let ips: Vec = state - .dedicated_ips - .values() - .filter(|ip| match pool_filter { - Some(pool) => ip.pool_name == pool, - None => true, - }) - .map(|ip| { - json!({ - "Ip": ip.ip, - "WarmupStatus": ip.warmup_status, - "WarmupPercentage": ip.warmup_percentage, - "PoolName": ip.pool_name, - }) - }) - .collect(); - let response = json!({ "DedicatedIps": ips }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn put_dedicated_ip_in_pool( - &self, - ip: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let dest_pool = match body["DestinationPoolName"].as_str() { - Some(p) => p.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "DestinationPoolName is required", - )); - } - }; - - let mut state = self.state.write(); - - if !state.dedicated_ip_pools.contains_key(&dest_pool) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Pool {} does not exist", dest_pool), - )); - } - - let dip = match state.dedicated_ips.get_mut(ip) { - Some(d) => d, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Dedicated IP {} does not exist", ip), - )); - } - }; - dip.pool_name = dest_pool; - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_dedicated_ip_warmup_attributes( - &self, - ip: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let warmup_pct = match body["WarmupPercentage"].as_i64() { - Some(p) => p as i32, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "WarmupPercentage is required", - )); - } - }; - - let mut state = self.state.write(); - let dip = match state.dedicated_ips.get_mut(ip) { - Some(d) => d, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Dedicated IP {} does not exist", ip), - )); - } - }; - dip.warmup_percentage = warmup_pct; - dip.warmup_status = if warmup_pct >= 100 { - "DONE".to_string() - } else { - "IN_PROGRESS".to_string() - }; - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_account_dedicated_ip_warmup_attributes( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let enabled = body["AutoWarmupEnabled"].as_bool().unwrap_or(false); - self.state - .write() - .account_settings - .dedicated_ip_auto_warmup_enabled = enabled; - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // ── Multi-region Endpoints ────────────────────────────────────────── - - fn create_multi_region_endpoint( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let endpoint_name = match body["EndpointName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "EndpointName is required", - )); - } - }; - - let mut state = self.state.write(); - if state.multi_region_endpoints.contains_key(&endpoint_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Endpoint {} already exists", endpoint_name), - )); - } - - // Extract regions from Details.RoutesDetails[].Region - let mut regions = Vec::new(); - if let Some(details) = body.get("Details") { - if let Some(routes) = details["RoutesDetails"].as_array() { - for r in routes { - if let Some(region) = r["Region"].as_str() { - regions.push(region.to_string()); - } - } - } - } - // The primary region is always the current region - if !regions.contains(&state.region) { - regions.insert(0, state.region.clone()); - } - - let endpoint_id = format!( - "ses-{}-{}", - state.region, - uuid::Uuid::new_v4().to_string().split('-').next().unwrap() - ); - let now = Utc::now(); - - state.multi_region_endpoints.insert( - endpoint_name.clone(), - MultiRegionEndpoint { - endpoint_name, - endpoint_id: endpoint_id.clone(), - status: "READY".to_string(), - regions, - created_at: now, - last_updated_at: now, - }, - ); - - let response = json!({ - "Status": "READY", - "EndpointId": endpoint_id, - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_multi_region_endpoint(&self, name: &str) -> Result { - let state = self.state.read(); - let ep = match state.multi_region_endpoints.get(name) { - Some(e) => e, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Endpoint {} does not exist", name), - )); - } - }; - - let routes: Vec = ep.regions.iter().map(|r| json!({ "Region": r })).collect(); - - let response = json!({ - "EndpointName": ep.endpoint_name, - "EndpointId": ep.endpoint_id, - "Status": ep.status, - "Routes": routes, - "CreatedTimestamp": ep.created_at.timestamp() as f64, - "LastUpdatedTimestamp": ep.last_updated_at.timestamp() as f64, - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_multi_region_endpoints(&self) -> Result { - let state = self.state.read(); - let endpoints: Vec = state - .multi_region_endpoints - .values() - .map(|ep| { - json!({ - "EndpointName": ep.endpoint_name, - "EndpointId": ep.endpoint_id, - "Status": ep.status, - "Regions": ep.regions, - "CreatedTimestamp": ep.created_at.timestamp() as f64, - "LastUpdatedTimestamp": ep.last_updated_at.timestamp() as f64, - }) - }) - .collect(); - let response = json!({ "MultiRegionEndpoints": endpoints }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn delete_multi_region_endpoint(&self, name: &str) -> Result { - let mut state = self.state.write(); - if state.multi_region_endpoints.remove(name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Endpoint {} does not exist", name), - )); - } - let response = json!({ "Status": "DELETING" }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // ── Account Settings ──────────────────────────────────────────────── - - fn put_account_details(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let mail_type = match body["MailType"].as_str() { - Some(m) => m.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "MailType is required", - )); - } - }; - let website_url = match body["WebsiteURL"].as_str() { - Some(u) => u.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "WebsiteURL is required", - )); - } - }; - let contact_language = body["ContactLanguage"].as_str().map(|s| s.to_string()); - let use_case_description = body["UseCaseDescription"].as_str().map(|s| s.to_string()); - let additional = body["AdditionalContactEmailAddresses"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - let production_access = body["ProductionAccessEnabled"].as_bool(); - - let mut state = self.state.write(); - state.account_settings.details = Some(AccountDetails { - mail_type: Some(mail_type), - website_url: Some(website_url), - contact_language, - use_case_description, - additional_contact_email_addresses: additional, - production_access_enabled: production_access, - }); - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_account_sending_attributes( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let enabled = body["SendingEnabled"].as_bool().unwrap_or(false); - self.state.write().account_settings.sending_enabled = enabled; - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_account_suppression_attributes( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let reasons = body["SuppressedReasons"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - self.state.write().account_settings.suppressed_reasons = reasons; - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn put_account_vdm_attributes(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let vdm = match body.get("VdmAttributes") { - Some(v) => v.clone(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "VdmAttributes is required", - )); - } - }; - self.state.write().account_settings.vdm_attributes = Some(vdm); - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Import Job operations --- - - fn create_import_job(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - - let import_destination = match body.get("ImportDestination") { - Some(v) if v.is_object() => v.clone(), - _ => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ImportDestination is required", - )); - } - }; - - let import_data_source = match body.get("ImportDataSource") { - Some(v) if v.is_object() => v.clone(), - _ => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ImportDataSource is required", - )); - } - }; - - let job_id = uuid::Uuid::new_v4().to_string(); - let now = Utc::now(); - - let job = ImportJob { - job_id: job_id.clone(), - import_destination, - import_data_source, - job_status: "COMPLETED".to_string(), - created_timestamp: now, - completed_timestamp: Some(now), - processed_records_count: 0, - failed_records_count: 0, - }; - - self.state.write().import_jobs.insert(job_id.clone(), job); - - let response = json!({ "JobId": job_id }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_import_job(&self, job_id: &str) -> Result { - let state = self.state.read(); - let job = match state.import_jobs.get(job_id) { - Some(j) => j, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Import job {} does not exist", job_id), - )); - } - }; - - let mut response = json!({ - "JobId": job.job_id, - "ImportDestination": job.import_destination, - "ImportDataSource": job.import_data_source, - "JobStatus": job.job_status, - "CreatedTimestamp": job.created_timestamp.timestamp() as f64, - "ProcessedRecordsCount": job.processed_records_count, - "FailedRecordsCount": job.failed_records_count, - }); - if let Some(ref ts) = job.completed_timestamp { - response["CompletedTimestamp"] = json!(ts.timestamp() as f64); - } - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_import_jobs(&self, req: &AwsRequest) -> Result { - let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({})); - let filter_type = body["ImportDestinationType"].as_str(); - - let state = self.state.read(); - let jobs: Vec = state - .import_jobs - .values() - .filter(|j| { - if let Some(ft) = filter_type { - // Check if import destination matches - if j.import_destination - .get("SuppressionListDestination") - .is_some() - && ft == "SUPPRESSION_LIST" - { - return true; - } - if j.import_destination.get("ContactListDestination").is_some() - && ft == "CONTACT_LIST" - { - return true; - } - return false; - } - true - }) - .map(|j| { - let mut obj = json!({ - "JobId": j.job_id, - "ImportDestination": j.import_destination, - "JobStatus": j.job_status, - "CreatedTimestamp": j.created_timestamp.timestamp() as f64, - }); - if j.processed_records_count > 0 { - obj["ProcessedRecordsCount"] = json!(j.processed_records_count); - } - if j.failed_records_count > 0 { - obj["FailedRecordsCount"] = json!(j.failed_records_count); - } - obj - }) - .collect(); - - let response = json!({ "ImportJobs": jobs }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // --- Export Job operations --- - - fn create_export_job(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - - let export_data_source = match body.get("ExportDataSource") { - Some(v) if v.is_object() => v.clone(), - _ => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ExportDataSource is required", - )); - } - }; - - let export_destination = match body.get("ExportDestination") { - Some(v) if v.is_object() => v.clone(), - _ => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ExportDestination is required", - )); - } - }; - - // Determine export source type from the data source - let export_source_type = if export_data_source.get("MetricsDataSource").is_some() { - "METRICS_DATA" - } else { - "MESSAGE_INSIGHTS" - }; - - let job_id = uuid::Uuid::new_v4().to_string(); - let now = Utc::now(); - - let job = ExportJob { - job_id: job_id.clone(), - export_source_type: export_source_type.to_string(), - export_destination, - export_data_source, - job_status: "COMPLETED".to_string(), - created_timestamp: now, - completed_timestamp: Some(now), - }; - - self.state.write().export_jobs.insert(job_id.clone(), job); - - let response = json!({ "JobId": job_id }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_export_job(&self, job_id: &str) -> Result { - let state = self.state.read(); - let job = match state.export_jobs.get(job_id) { - Some(j) => j, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Export job {} does not exist", job_id), - )); - } - }; - - let mut response = json!({ - "JobId": job.job_id, - "ExportSourceType": job.export_source_type, - "JobStatus": job.job_status, - "ExportDestination": job.export_destination, - "ExportDataSource": job.export_data_source, - "CreatedTimestamp": job.created_timestamp.timestamp() as f64, - "Statistics": { - "ProcessedRecordsCount": 0, - "ExportedRecordsCount": 0, - }, - }); - if let Some(ref ts) = job.completed_timestamp { - response["CompletedTimestamp"] = json!(ts.timestamp() as f64); - } - - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_export_jobs(&self, req: &AwsRequest) -> Result { - let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({})); - let filter_status = body["JobStatus"].as_str(); - let filter_type = body["ExportSourceType"].as_str(); - - let state = self.state.read(); - let jobs: Vec = state - .export_jobs - .values() - .filter(|j| { - if let Some(s) = filter_status { - if j.job_status != s { - return false; - } - } - if let Some(t) = filter_type { - if j.export_source_type != t { - return false; - } - } - true - }) - .map(|j| { - let mut obj = json!({ - "JobId": j.job_id, - "ExportSourceType": j.export_source_type, - "JobStatus": j.job_status, - "CreatedTimestamp": j.created_timestamp.timestamp() as f64, - }); - if let Some(ref ts) = j.completed_timestamp { - obj["CompletedTimestamp"] = json!(ts.timestamp() as f64); - } - obj - }) - .collect(); - - let response = json!({ "ExportJobs": jobs }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn cancel_export_job(&self, job_id: &str) -> Result { - let mut state = self.state.write(); - let job = match state.export_jobs.get_mut(job_id) { - Some(j) => j, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Export job {} does not exist", job_id), - )); - } - }; - - if job.job_status == "COMPLETED" || job.job_status == "CANCELLED" { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "ConflictException", - &format!("Export job {} is already {}", job_id, job.job_status), - )); - } - - job.job_status = "CANCELLED".to_string(); - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Tenant operations --- - - fn create_tenant(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let tenant_name = match body["TenantName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TenantName is required", - )); - } - }; - - let mut state = self.state.write(); - - if state.tenants.contains_key(&tenant_name) { - return Ok(Self::json_error( - StatusCode::CONFLICT, - "AlreadyExistsException", - &format!("Tenant {} already exists", tenant_name), - )); - } - - let tenant_id = uuid::Uuid::new_v4().to_string(); - let tenant_arn = format!( - "arn:aws:ses:{}:{}:tenant/{}", - req.region, req.account_id, tenant_id - ); - let now = Utc::now(); - - let tags = body - .get("Tags") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - let tenant = Tenant { - tenant_name: tenant_name.clone(), - tenant_id: tenant_id.clone(), - tenant_arn: tenant_arn.clone(), - created_timestamp: now, - sending_status: "ENABLED".to_string(), - tags: tags.clone(), - }; - - state.tenants.insert(tenant_name.clone(), tenant); - - let response = json!({ - "TenantName": tenant_name, - "TenantId": tenant_id, - "TenantArn": tenant_arn, - "CreatedTimestamp": now.timestamp() as f64, - "SendingStatus": "ENABLED", - "Tags": tags, - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn get_tenant(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let tenant_name = match body["TenantName"].as_str() { - Some(n) => n, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TenantName is required", - )); - } - }; - - let state = self.state.read(); - let tenant = match state.tenants.get(tenant_name) { - Some(t) => t, - None => { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Tenant {} does not exist", tenant_name), - )); - } - }; - - let response = json!({ - "Tenant": { - "TenantName": tenant.tenant_name, - "TenantId": tenant.tenant_id, - "TenantArn": tenant.tenant_arn, - "CreatedTimestamp": tenant.created_timestamp.timestamp() as f64, - "SendingStatus": tenant.sending_status, - "Tags": tenant.tags, - } - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_tenants(&self, _req: &AwsRequest) -> Result { - let state = self.state.read(); - let tenants: Vec = state - .tenants - .values() - .map(|t| { - json!({ - "TenantName": t.tenant_name, - "TenantId": t.tenant_id, - "TenantArn": t.tenant_arn, - "CreatedTimestamp": t.created_timestamp.timestamp() as f64, - }) - }) - .collect(); - - let response = json!({ "Tenants": tenants }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn delete_tenant(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let tenant_name = match body["TenantName"].as_str() { - Some(n) => n, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TenantName is required", - )); - } - }; - - let mut state = self.state.write(); - - if state.tenants.remove(tenant_name).is_none() { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Tenant {} does not exist", tenant_name), - )); - } - - state.tenant_resource_associations.remove(tenant_name); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn create_tenant_resource_association( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let tenant_name = match body["TenantName"].as_str() { - Some(n) => n.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TenantName is required", - )); - } - }; - let resource_arn = match body["ResourceArn"].as_str() { - Some(a) => a.to_string(), - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ResourceArn is required", - )); - } - }; - - let mut state = self.state.write(); - - if !state.tenants.contains_key(&tenant_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Tenant {} does not exist", tenant_name), - )); - } - - let assoc = TenantResourceAssociation { - resource_arn, - associated_timestamp: Utc::now(), - }; - - state - .tenant_resource_associations - .entry(tenant_name) - .or_default() - .push(assoc); - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn delete_tenant_resource_association( - &self, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let tenant_name = match body["TenantName"].as_str() { - Some(n) => n, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TenantName is required", - )); - } - }; - let resource_arn = match body["ResourceArn"].as_str() { - Some(a) => a, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ResourceArn is required", - )); - } - }; - - let mut state = self.state.write(); - - if let Some(assocs) = state.tenant_resource_associations.get_mut(tenant_name) { - let before = assocs.len(); - assocs.retain(|a| a.resource_arn != resource_arn); - if assocs.len() == before { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - "Resource association not found", - )); - } - } else { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - "Resource association not found", - )); - } - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn list_tenant_resources(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let tenant_name = match body["TenantName"].as_str() { - Some(n) => n, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "TenantName is required", - )); - } - }; - - let state = self.state.read(); - - if !state.tenants.contains_key(tenant_name) { - return Ok(Self::json_error( - StatusCode::NOT_FOUND, - "NotFoundException", - &format!("Tenant {} does not exist", tenant_name), - )); - } - - let resources: Vec = state - .tenant_resource_associations - .get(tenant_name) - .map(|assocs| { - assocs - .iter() - .map(|a| { - json!({ - "ResourceType": "RESOURCE", - "ResourceArn": a.resource_arn, - }) - }) - .collect() - }) - .unwrap_or_default(); - - let response = json!({ "TenantResources": resources }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_resource_tenants(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let resource_arn = match body["ResourceArn"].as_str() { - Some(a) => a, - None => { - return Ok(Self::json_error( - StatusCode::BAD_REQUEST, - "BadRequestException", - "ResourceArn is required", - )); - } - }; - - let state = self.state.read(); - let mut resource_tenants: Vec = Vec::new(); - - for (tenant_name, assocs) in &state.tenant_resource_associations { - for assoc in assocs { - if assoc.resource_arn == resource_arn { - if let Some(tenant) = state.tenants.get(tenant_name) { - resource_tenants.push(json!({ - "TenantName": tenant.tenant_name, - "TenantId": tenant.tenant_id, - "ResourceArn": assoc.resource_arn, - "AssociatedTimestamp": assoc.associated_timestamp.timestamp() as f64, - })); - } - } - } - } - - let response = json!({ "ResourceTenants": resource_tenants }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - // --- Reputation Entity operations --- - - fn get_reputation_entity( - &self, - entity_type: &str, - entity_ref: &str, - ) -> Result { - let key = format!("{}/{}", entity_type, entity_ref); - let state = self.state.read(); - - let entity = match state.reputation_entities.get(&key) { - Some(e) => e, - None => { - // Return a default entity for any reference - let response = json!({ - "ReputationEntity": { - "ReputationEntityReference": entity_ref, - "ReputationEntityType": entity_type, - "SendingStatusAggregate": "ENABLED", - "CustomerManagedStatus": { - "SendingStatus": "ENABLED", - }, - "AwsSesManagedStatus": { - "SendingStatus": "ENABLED", - }, - } - }); - return Ok(AwsResponse::json(StatusCode::OK, response.to_string())); - } - }; - - let response = json!({ - "ReputationEntity": { - "ReputationEntityReference": entity.reputation_entity_reference, - "ReputationEntityType": entity.reputation_entity_type, - "ReputationManagementPolicy": entity.reputation_management_policy, - "SendingStatusAggregate": entity.sending_status_aggregate, - "CustomerManagedStatus": { - "SendingStatus": entity.customer_managed_status, - }, - "AwsSesManagedStatus": { - "SendingStatus": "ENABLED", - }, - } - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn list_reputation_entities(&self, _req: &AwsRequest) -> Result { - let state = self.state.read(); - let entities: Vec = state - .reputation_entities - .values() - .map(|e| { - json!({ - "ReputationEntityReference": e.reputation_entity_reference, - "ReputationEntityType": e.reputation_entity_type, - "SendingStatusAggregate": e.sending_status_aggregate, - }) - }) - .collect(); - - let response = json!({ "ReputationEntities": entities }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } - - fn update_reputation_entity_customer_managed_status( - &self, - entity_type: &str, - entity_ref: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let sending_status = body["SendingStatus"] - .as_str() - .unwrap_or("ENABLED") - .to_string(); - - let key = format!("{}/{}", entity_type, entity_ref); - let mut state = self.state.write(); - - let entity = - state - .reputation_entities - .entry(key) - .or_insert_with(|| ReputationEntityState { - reputation_entity_reference: entity_ref.to_string(), - reputation_entity_type: entity_type.to_string(), - reputation_management_policy: None, - customer_managed_status: "ENABLED".to_string(), - sending_status_aggregate: "ENABLED".to_string(), - }); - - entity.customer_managed_status = sending_status; - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - fn update_reputation_entity_policy( - &self, - entity_type: &str, - entity_ref: &str, - req: &AwsRequest, - ) -> Result { - let body: Value = Self::parse_body(req)?; - let policy = body["ReputationEntityPolicy"] - .as_str() - .map(|s| s.to_string()); - - let key = format!("{}/{}", entity_type, entity_ref); - let mut state = self.state.write(); - - let entity = - state - .reputation_entities - .entry(key) - .or_insert_with(|| ReputationEntityState { - reputation_entity_reference: entity_ref.to_string(), - reputation_entity_type: entity_type.to_string(), - reputation_management_policy: None, - customer_managed_status: "ENABLED".to_string(), - sending_status_aggregate: "ENABLED".to_string(), - }); - - entity.reputation_management_policy = policy; - - Ok(AwsResponse::json(StatusCode::OK, "{}")) - } - - // --- Metrics --- - - fn batch_get_metric_data(&self, req: &AwsRequest) -> Result { - let body: Value = Self::parse_body(req)?; - let queries = body["Queries"].as_array().cloned().unwrap_or_default(); - - let results: Vec = queries - .iter() - .filter_map(|q| { - let id = q["Id"].as_str()?; - Some(json!({ - "Id": id, - "Timestamps": [], - "Values": [], - })) - }) - .collect(); - - let response = json!({ - "Results": results, - "Errors": [], - }); - Ok(AwsResponse::json(StatusCode::OK, response.to_string())) - } } fn parse_topics(value: &Value) -> Vec { diff --git a/crates/fakecloud-ses/src/service/sending.rs b/crates/fakecloud-ses/src/service/sending.rs new file mode 100644 index 00000000..1236fd20 --- /dev/null +++ b/crates/fakecloud-ses/src/service/sending.rs @@ -0,0 +1,160 @@ +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::SentEmail; + +use super::{extract_string_array, SesV2Service}; + +impl SesV2Service { + pub(super) fn send_email(&self, req: &AwsRequest) -> Result { + let body: Value = Self::parse_body(req)?; + + if !body["Content"].is_object() + || (!body["Content"]["Simple"].is_object() + && !body["Content"]["Raw"].is_object() + && !body["Content"]["Template"].is_object()) + { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Content is required and must contain Simple, Raw, or Template", + )); + } + + let from = body["FromEmailAddress"].as_str().unwrap_or("").to_string(); + + let to = extract_string_array(&body["Destination"]["ToAddresses"]); + let cc = extract_string_array(&body["Destination"]["CcAddresses"]); + let bcc = extract_string_array(&body["Destination"]["BccAddresses"]); + + let config_set_name = body["ConfigurationSetName"].as_str().map(|s| s.to_string()); + + let (subject, html_body, text_body, raw_data, template_name, template_data) = + if body["Content"]["Simple"].is_object() { + let simple = &body["Content"]["Simple"]; + let subject = simple["Subject"]["Data"].as_str().map(|s| s.to_string()); + let html = simple["Body"]["Html"]["Data"] + .as_str() + .map(|s| s.to_string()); + let text = simple["Body"]["Text"]["Data"] + .as_str() + .map(|s| s.to_string()); + (subject, html, text, None, None, None) + } else if body["Content"]["Raw"].is_object() { + let raw = body["Content"]["Raw"]["Data"] + .as_str() + .map(|s| s.to_string()); + (None, None, None, raw, None, None) + } else if body["Content"]["Template"].is_object() { + let tmpl = &body["Content"]["Template"]; + let tmpl_name = tmpl["TemplateName"].as_str().map(|s| s.to_string()); + let tmpl_data = tmpl["TemplateData"].as_str().map(|s| s.to_string()); + (None, None, None, None, tmpl_name, tmpl_data) + } else { + (None, None, None, None, None, None) + }; + + let message_id = uuid::Uuid::new_v4().to_string(); + + let sent = SentEmail { + message_id: message_id.clone(), + from, + to, + cc, + bcc, + subject, + html_body, + text_body, + raw_data, + template_name, + template_data, + timestamp: Utc::now(), + }; + + // Event fanout: check suppression list, generate events, deliver to destinations + if let Some(ref ctx) = self.delivery_ctx { + crate::fanout::process_send_events(ctx, &sent, config_set_name.as_deref()); + } + + self.state.write().sent_emails.push(sent); + + let response = json!({ + "MessageId": message_id, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn send_bulk_email(&self, req: &AwsRequest) -> Result { + let body: Value = Self::parse_body(req)?; + + let from = body["FromEmailAddress"].as_str().unwrap_or("").to_string(); + let config_set_name = body["ConfigurationSetName"].as_str().map(|s| s.to_string()); + + let entries = match body["BulkEmailEntries"].as_array() { + Some(arr) if !arr.is_empty() => arr.clone(), + _ => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "BulkEmailEntries is required and must not be empty", + )); + } + }; + + let mut results = Vec::new(); + + for entry in &entries { + let to = extract_string_array(&entry["Destination"]["ToAddresses"]); + let cc = extract_string_array(&entry["Destination"]["CcAddresses"]); + let bcc = extract_string_array(&entry["Destination"]["BccAddresses"]); + + let message_id = uuid::Uuid::new_v4().to_string(); + + let template_name = body["DefaultContent"]["Template"]["TemplateName"] + .as_str() + .map(|s| s.to_string()); + let template_data = entry["ReplacementEmailContent"]["ReplacementTemplate"] + ["ReplacementTemplateData"] + .as_str() + .or_else(|| body["DefaultContent"]["Template"]["TemplateData"].as_str()) + .map(|s| s.to_string()); + + let sent = SentEmail { + message_id: message_id.clone(), + from: from.clone(), + to, + cc, + bcc, + subject: None, + html_body: None, + text_body: None, + raw_data: None, + template_name, + template_data, + timestamp: Utc::now(), + }; + + // Event fanout for each bulk entry + if let Some(ref ctx) = self.delivery_ctx { + crate::fanout::process_send_events(ctx, &sent, config_set_name.as_deref()); + } + + self.state.write().sent_emails.push(sent); + + results.push(json!({ + "Status": "SUCCESS", + "MessageId": message_id, + })); + } + + let response = json!({ + "BulkEmailEntryResults": results, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } +} diff --git a/crates/fakecloud-ses/src/service/suppression.rs b/crates/fakecloud-ses/src/service/suppression.rs new file mode 100644 index 00000000..e6af7b98 --- /dev/null +++ b/crates/fakecloud-ses/src/service/suppression.rs @@ -0,0 +1,120 @@ +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::SuppressedDestination; + +use super::SesV2Service; + +impl SesV2Service { + pub(super) fn put_suppressed_destination( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let email = match body["EmailAddress"].as_str() { + Some(e) => e.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "EmailAddress is required", + )); + } + }; + let reason = match body["Reason"].as_str() { + Some(r) if r == "BOUNCE" || r == "COMPLAINT" => r.to_string(), + Some(_) => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Reason must be BOUNCE or COMPLAINT", + )); + } + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "Reason is required", + )); + } + }; + + let mut state = self.state.write(); + state.suppressed_destinations.insert( + email.clone(), + SuppressedDestination { + email_address: email, + reason, + last_update_time: Utc::now(), + }, + ); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn get_suppressed_destination( + &self, + email: &str, + ) -> Result { + let state = self.state.read(); + let dest = match state.suppressed_destinations.get(email) { + Some(d) => d, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("{} is not on the suppression list", email), + )); + } + }; + + let response = json!({ + "SuppressedDestination": { + "EmailAddress": dest.email_address, + "Reason": dest.reason, + "LastUpdateTime": dest.last_update_time.timestamp() as f64, + } + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn delete_suppressed_destination( + &self, + email: &str, + ) -> Result { + let mut state = self.state.write(); + if state.suppressed_destinations.remove(email).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("{} is not on the suppression list", email), + )); + } + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn list_suppressed_destinations(&self) -> Result { + let state = self.state.read(); + let summaries: Vec = state + .suppressed_destinations + .values() + .map(|d| { + json!({ + "EmailAddress": d.email_address, + "Reason": d.reason, + "LastUpdateTime": d.last_update_time.timestamp() as f64, + }) + }) + .collect(); + + let response = json!({ + "SuppressedDestinationSummaries": summaries, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } +} diff --git a/crates/fakecloud-ses/src/service/templates.rs b/crates/fakecloud-ses/src/service/templates.rs new file mode 100644 index 00000000..931e89ca --- /dev/null +++ b/crates/fakecloud-ses/src/service/templates.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; + +use chrono::Utc; +use http::StatusCode; +use serde_json::{json, Value}; + +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::state::EmailTemplate; + +use super::SesV2Service; + +impl SesV2Service { + pub(super) fn create_email_template( + &self, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let template_name = match body["TemplateName"].as_str() { + Some(n) => n.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TemplateName is required", + )); + } + }; + + let mut state = self.state.write(); + + if state.templates.contains_key(&template_name) { + return Ok(Self::json_error( + StatusCode::CONFLICT, + "AlreadyExistsException", + &format!("Template {} already exists", template_name), + )); + } + + let template = EmailTemplate { + template_name: template_name.clone(), + subject: body["TemplateContent"]["Subject"] + .as_str() + .map(|s| s.to_string()), + html_body: body["TemplateContent"]["Html"] + .as_str() + .map(|s| s.to_string()), + text_body: body["TemplateContent"]["Text"] + .as_str() + .map(|s| s.to_string()), + created_at: Utc::now(), + }; + + state.templates.insert(template_name, template); + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn list_email_templates(&self) -> Result { + let state = self.state.read(); + let templates: Vec = state + .templates + .values() + .map(|t| { + json!({ + "TemplateName": t.template_name, + "CreatedTimestamp": t.created_at.timestamp() as f64, + }) + }) + .collect(); + + let response = json!({ + "TemplatesMetadata": templates, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn get_email_template(&self, name: &str) -> Result { + let state = self.state.read(); + let template = match state.templates.get(name) { + Some(t) => t, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Template {} does not exist", name), + )); + } + }; + + let response = json!({ + "TemplateName": template.template_name, + "TemplateContent": { + "Subject": template.subject, + "Html": template.html_body, + "Text": template.text_body, + }, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } + + pub(super) fn update_email_template( + &self, + name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + let mut state = self.state.write(); + + let template = match state.templates.get_mut(name) { + Some(t) => t, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Template {} does not exist", name), + )); + } + }; + + if let Some(subject) = body["TemplateContent"]["Subject"].as_str() { + template.subject = Some(subject.to_string()); + } + if let Some(html) = body["TemplateContent"]["Html"].as_str() { + template.html_body = Some(html.to_string()); + } + if let Some(text) = body["TemplateContent"]["Text"].as_str() { + template.text_body = Some(text.to_string()); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn delete_email_template(&self, name: &str) -> Result { + let mut state = self.state.write(); + + if state.templates.remove(name).is_none() { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Template {} does not exist", name), + )); + } + + Ok(AwsResponse::json(StatusCode::OK, "{}")) + } + + pub(super) fn test_render_email_template( + &self, + template_name: &str, + req: &AwsRequest, + ) -> Result { + let body: Value = Self::parse_body(req)?; + + let template_data_str = match body["TemplateData"].as_str() { + Some(d) => d.to_string(), + None => { + return Ok(Self::json_error( + StatusCode::BAD_REQUEST, + "BadRequestException", + "TemplateData is required", + )); + } + }; + + let state = self.state.read(); + let template = match state.templates.get(template_name) { + Some(t) => t, + None => { + return Ok(Self::json_error( + StatusCode::NOT_FOUND, + "NotFoundException", + &format!("Template {} does not exist", template_name), + )); + } + }; + + // Parse template data JSON + let data: HashMap = + serde_json::from_str(&template_data_str).unwrap_or_default(); + + let substitute = |text: &str| -> String { + let mut result = text.to_string(); + for (key, value) in &data { + let placeholder = format!("{{{{{}}}}}", key); + let replacement = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + result = result.replace(&placeholder, &replacement); + } + result + }; + + let rendered_subject = template + .subject + .as_deref() + .map(&substitute) + .unwrap_or_default(); + let rendered_html = template.html_body.as_deref().map(&substitute); + let rendered_text = template.text_body.as_deref().map(&substitute); + + // Build a simplified MIME message + let mut mime = format!("Subject: {}\r\n", rendered_subject); + mime.push_str("MIME-Version: 1.0\r\n"); + mime.push_str("Content-Type: text/html; charset=UTF-8\r\n"); + mime.push_str("\r\n"); + if let Some(ref html) = rendered_html { + mime.push_str(html); + } else if let Some(ref text) = rendered_text { + mime.push_str(text); + } + + let response = json!({ + "RenderedTemplate": mime, + }); + + Ok(AwsResponse::json(StatusCode::OK, response.to_string())) + } +}