use crate::error::{ErrorData, Result};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
use crate::ResourceType;
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::collections::BTreeMap;
use std::fmt::Debug;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct Email {
#[builder(start_fn)]
pub id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub domains: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inbound: Option<EmailInbound>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub events: Option<EmailEvents>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EmailInbound {
pub storage: ResourceRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EmailEvents {
pub queue: ResourceRef,
}
impl Email {
pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("email");
pub fn id(&self) -> &str {
&self.id
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct EmailDkimToken {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct EmailDomainOutputs {
pub dkim_tokens: Vec<EmailDkimToken>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct EmailOutputs {
pub domains: BTreeMap<String, EmailDomainOutputs>,
pub configuration_set: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_set_name: Option<String>,
}
impl ResourceOutputsDefinition for EmailOutputs {
fn get_resource_type(&self) -> ResourceType {
Email::RESOURCE_TYPE.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
Box::new(self.clone())
}
fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
other.as_any().downcast_ref::<EmailOutputs>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
impl ResourceDefinition for Email {
fn get_resource_type(&self) -> ResourceType {
Self::RESOURCE_TYPE
}
fn id(&self) -> &str {
&self.id
}
fn get_dependencies(&self) -> Vec<ResourceRef> {
let mut dependencies = Vec::new();
if let Some(inbound) = &self.inbound {
dependencies.push(inbound.storage.clone());
}
if let Some(events) = &self.events {
dependencies.push(events.queue.clone());
}
dependencies
}
fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
let new_email = new_config.as_any().downcast_ref::<Email>().ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: self.id.clone(),
expected: Self::RESOURCE_TYPE,
actual: new_config.get_resource_type(),
})
})?;
if self.id != new_email.id {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'id' field is immutable".to_string(),
}));
}
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceDefinition> {
Box::new(self.clone())
}
fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
other.as_any().downcast_ref::<Email>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resources::{Queue, Storage};
fn email_with_links() -> Email {
let storage = Storage::new("mailbox".to_string()).build();
let queue = Queue::new("mail-events".to_string()).build();
Email::new("mailer".to_string())
.domains(vec!["mail.example.com".to_string()])
.inbound(EmailInbound {
storage: ResourceRef::from(&storage),
})
.events(EmailEvents {
queue: ResourceRef::from(&queue),
})
.build()
}
#[test]
fn builder_produces_expected_config() {
let email = email_with_links();
assert_eq!(email.id, "mailer");
assert_eq!(email.domains, vec!["mail.example.com"]);
assert_eq!(
email.inbound.as_ref().expect("inbound").storage.id,
"mailbox"
);
assert_eq!(
email.events.as_ref().expect("events").queue.id,
"mail-events"
);
}
#[test]
fn resource_type_is_email() {
assert_eq!(Email::RESOURCE_TYPE.as_ref(), "email");
}
#[test]
fn dependencies_include_inbound_storage_and_events_queue() {
let email = email_with_links();
let dependencies = email.get_dependencies();
assert_eq!(dependencies.len(), 2);
assert_eq!(dependencies[0].resource_type, Storage::RESOURCE_TYPE);
assert_eq!(dependencies[0].id, "mailbox");
assert_eq!(dependencies[1].resource_type, Queue::RESOURCE_TYPE);
assert_eq!(dependencies[1].id, "mail-events");
}
#[test]
fn dependencies_are_empty_without_links() {
let email = Email::new("mailer".to_string())
.domains(vec!["mail.example.com".to_string()])
.build();
assert!(email.get_dependencies().is_empty());
}
#[test]
fn validate_update_rejects_id_change() {
let original = Email::new("mailer".to_string())
.domains(vec!["mail.example.com".to_string()])
.build();
let renamed = Email::new("other".to_string())
.domains(vec!["mail.example.com".to_string()])
.build();
let error = original
.validate_update(&renamed)
.expect_err("changing the id must be rejected");
assert!(error.to_string().contains("'id' field is immutable"));
}
#[test]
fn validate_update_allows_adding_and_removing_domains() {
let original = Email::new("mailer".to_string())
.domains(vec![
"mail.example.com".to_string(),
"mail.example.org".to_string(),
])
.build();
let appended = Email::new("mailer".to_string())
.domains(vec![
"mail.example.com".to_string(),
"mail.example.org".to_string(),
"mail.example.net".to_string(),
])
.build();
let removed = Email::new("mailer".to_string())
.domains(vec!["mail.example.com".to_string()])
.build();
original
.validate_update(&appended)
.expect("adding a domain must be allowed");
original
.validate_update(&removed)
.expect("removing a domain must be allowed");
}
#[test]
fn validate_update_allows_removing_all_seed_domains() {
let original = Email::new("mailer".to_string())
.domains(vec!["mail.example.com".to_string()])
.build();
let emptied = Email::new("mailer".to_string()).build();
original
.validate_update(&emptied)
.expect("removing all seed domains must be allowed");
}
#[test]
fn builder_defaults_to_no_seed_domains() {
let email = Email::new("mailer".to_string()).build();
assert!(email.domains.is_empty());
assert!(email.inbound.is_none());
assert!(email.events.is_none());
assert!(email.get_dependencies().is_empty());
}
#[test]
fn empty_domains_are_omitted_from_serialization_and_roundtrip() {
let email = Email::new("mailer".to_string()).build();
let json = serde_json::to_value(&email).expect("email should serialize");
assert_eq!(json, serde_json::json!({ "id": "mailer" }));
let roundtrip: Email = serde_json::from_value(json).expect("email should deserialize");
assert_eq!(email, roundtrip);
}
#[test]
fn validate_update_allows_link_changes() {
let original = email_with_links();
let unlinked = Email::new("mailer".to_string())
.domains(vec!["mail.example.com".to_string()])
.build();
original
.validate_update(&unlinked)
.expect("removing inbound/events must be allowed");
unlinked
.validate_update(&original)
.expect("adding inbound/events must be allowed");
}
#[test]
fn serializes_with_camel_case_and_roundtrips() {
let email = email_with_links();
let json = serde_json::to_value(&email).expect("email should serialize");
assert_eq!(json["domains"][0], "mail.example.com");
assert_eq!(json["inbound"]["storage"]["id"], "mailbox");
assert_eq!(json["inbound"]["storage"]["type"], "storage");
assert_eq!(json["events"]["queue"]["id"], "mail-events");
let roundtrip: Email = serde_json::from_value(json).expect("email should deserialize");
assert_eq!(email, roundtrip);
}
#[test]
fn outputs_roundtrip() {
let outputs = EmailOutputs {
domains: BTreeMap::from([(
"mail.example.com".to_string(),
EmailDomainOutputs {
dkim_tokens: vec![EmailDkimToken {
name: "token._domainkey.mail.example.com".to_string(),
value: "token.dkim.amazonses.com".to_string(),
}],
},
)]),
configuration_set: "stack-mailer".to_string(),
rule_set_name: Some("stack-mailer".to_string()),
};
let json = serde_json::to_string(&outputs).expect("outputs should serialize");
let deserialized: EmailOutputs =
serde_json::from_str(&json).expect("outputs should deserialize");
assert_eq!(outputs, deserialized);
}
}