Skip to main content

alien_core/resources/
email.rs

1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
3use crate::ResourceType;
4use alien_error::AlienError;
5use bon::Builder;
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8use std::collections::BTreeMap;
9use std::fmt::Debug;
10
11/// Email infrastructure for sending and receiving mail on customer-owned
12/// domains. On AWS this is backed by SES: a shared configuration set, optional
13/// inbound/event wiring, and one email identity (Easy DKIM) per seed domain.
14///
15/// # Infrastructure vs runtime data
16///
17/// This resource owns the email *infrastructure* and the capability to use it,
18/// not the domain lifecycle. Deployment manages the configuration set, the
19/// event topology, the inbound receipt topology, and any seed identities
20/// listed in `domains`. Email identities created at runtime through the
21/// `email/manage-identities` grant are application data: they are not tracked
22/// by the deployment, are not removed when the stack is deleted, and their
23/// lifecycle — including deletion — belongs to the application.
24///
25/// The operator (or the application, for runtime-created identities) owns DNS:
26/// the per-domain DKIM CNAME records surfaced in [`EmailOutputs`] must be
27/// created before SES verifies a domain and allows sending from it.
28///
29/// # Inbound mail (AWS)
30///
31/// When `inbound` is set, a SES receipt rule set is provisioned that writes
32/// raw incoming mail into the linked Storage bucket. The receipt rule is a
33/// catch-all (no recipient filter), so mail for identities verified at runtime
34/// lands in the bucket without any infrastructure change. Two caveats apply:
35///
36/// * SES allows only **one active receipt rule set per AWS account**, and
37///   CloudFormation has no resource that activates a rule set. Activating the
38///   provisioned rule set is a documented post-deploy step:
39///   `aws ses set-active-receipt-rule-set --rule-set-name <ruleSetName>`.
40/// * SES email receiving is only available in a subset of AWS regions (see the
41///   SES endpoints documentation). Deploying `inbound` to an unsupported
42///   region fails at the CloudFormation layer.
43///
44/// # Update semantics
45///
46/// `domains` is append-friendly: adding a domain provisions a new identity,
47/// removing a domain deletes its identity (and its DKIM verification state).
48/// The list may be empty. `inbound` and `events` may be added, removed, or
49/// repointed; removing them tears down the corresponding receipt rule set /
50/// event destination wiring.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54#[builder(start_fn = new)]
55pub struct Email {
56    /// Identifier for the email resource. Must contain only alphanumeric
57    /// characters, hyphens, and underscores ([A-Za-z0-9-_]). Maximum 64 characters.
58    #[builder(start_fn)]
59    pub id: String,
60
61    /// Seed mail domains provisioned at deploy time (one SES identity each).
62    /// Useful for day-0 bootstrap and products with a static domain set.
63    /// May be empty (the default): products that create and verify domains
64    /// dynamically should manage identities at runtime through the
65    /// `email/manage-identities` grant instead of listing them here.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    #[builder(default)]
68    pub domains: Vec<String>,
69
70    /// Optional inbound-mail configuration: raw incoming mail (for any
71    /// identity the account receives mail for — the receipt rule is a
72    /// catch-all) is written to the linked Storage bucket.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub inbound: Option<EmailInbound>,
75
76    /// Optional sending-event configuration: send / delivery / bounce /
77    /// complaint / delivery-delay / reject events are delivered to the
78    /// linked Queue.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub events: Option<EmailEvents>,
81}
82
83/// Inbound-mail configuration for an [`Email`] resource.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
86#[serde(rename_all = "camelCase", deny_unknown_fields)]
87pub struct EmailInbound {
88    /// The Storage resource that receives raw incoming mail objects.
89    pub storage: ResourceRef,
90}
91
92/// Sending-event configuration for an [`Email`] resource.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct EmailEvents {
97    /// The Queue resource that receives sending events.
98    pub queue: ResourceRef,
99}
100
101impl Email {
102    /// The resource type identifier for Email.
103    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("email");
104
105    /// Returns the email resource's unique identifier.
106    pub fn id(&self) -> &str {
107        &self.id
108    }
109}
110
111/// A single DKIM CNAME record the operator must create in DNS.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
114#[serde(rename_all = "camelCase")]
115pub struct EmailDkimToken {
116    /// CNAME record host name.
117    pub name: String,
118    /// CNAME record value.
119    pub value: String,
120}
121
122/// Per-domain DNS records the operator must create.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125#[serde(rename_all = "camelCase")]
126pub struct EmailDomainOutputs {
127    /// Easy-DKIM CNAME tokens (three per domain). The domain is verified once
128    /// these records exist in its DNS configuration.
129    pub dkim_tokens: Vec<EmailDkimToken>,
130}
131
132/// Outputs generated by a successfully provisioned Email resource.
133///
134/// Domain verification status cannot be known at provisioning time — SES
135/// verifies a domain asynchronously once its DKIM records exist in DNS — so
136/// the outputs carry the records the operator must create rather than a
137/// verification result.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
140#[serde(rename_all = "camelCase")]
141pub struct EmailOutputs {
142    /// DNS records per mail domain.
143    pub domains: BTreeMap<String, EmailDomainOutputs>,
144    /// The provisioned configuration set name (used when sending).
145    pub configuration_set: String,
146    /// The inbound receipt rule set name, when `inbound` is configured.
147    /// Activating it is a manual post-deploy step — CloudFormation cannot
148    /// activate a receipt rule set, and only one can be active per account.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub rule_set_name: Option<String>,
151}
152
153impl ResourceOutputsDefinition for EmailOutputs {
154    fn get_resource_type(&self) -> ResourceType {
155        Email::RESOURCE_TYPE.clone()
156    }
157
158    fn as_any(&self) -> &dyn Any {
159        self
160    }
161
162    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
163        Box::new(self.clone())
164    }
165
166    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
167        other.as_any().downcast_ref::<EmailOutputs>() == Some(self)
168    }
169
170    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
171        serde_json::to_value(self)
172    }
173}
174
175impl ResourceDefinition for Email {
176    fn get_resource_type(&self) -> ResourceType {
177        Self::RESOURCE_TYPE
178    }
179
180    fn id(&self) -> &str {
181        &self.id
182    }
183
184    fn get_dependencies(&self) -> Vec<ResourceRef> {
185        let mut dependencies = Vec::new();
186        if let Some(inbound) = &self.inbound {
187            dependencies.push(inbound.storage.clone());
188        }
189        if let Some(events) = &self.events {
190            dependencies.push(events.queue.clone());
191        }
192        dependencies
193    }
194
195    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
196        let new_email = new_config.as_any().downcast_ref::<Email>().ok_or_else(|| {
197            AlienError::new(ErrorData::UnexpectedResourceType {
198                resource_id: self.id.clone(),
199                expected: Self::RESOURCE_TYPE,
200                actual: new_config.get_resource_type(),
201            })
202        })?;
203
204        if self.id != new_email.id {
205            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
206                resource_id: self.id.clone(),
207                reason: "the 'id' field is immutable".to_string(),
208            }));
209        }
210
211        // Seed domains are append-friendly: adding provisions a new identity
212        // and removing deletes one (including its DKIM verification state).
213        // An empty list is valid — runtime-created identities are managed
214        // outside the deployment.
215
216        Ok(())
217    }
218
219    fn as_any(&self) -> &dyn Any {
220        self
221    }
222
223    fn as_any_mut(&mut self) -> &mut dyn Any {
224        self
225    }
226
227    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
228        Box::new(self.clone())
229    }
230
231    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
232        other.as_any().downcast_ref::<Email>() == Some(self)
233    }
234
235    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
236        serde_json::to_value(self)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::resources::{Queue, Storage};
244
245    fn email_with_links() -> Email {
246        let storage = Storage::new("mailbox".to_string()).build();
247        let queue = Queue::new("mail-events".to_string()).build();
248        Email::new("mailer".to_string())
249            .domains(vec!["mail.example.com".to_string()])
250            .inbound(EmailInbound {
251                storage: ResourceRef::from(&storage),
252            })
253            .events(EmailEvents {
254                queue: ResourceRef::from(&queue),
255            })
256            .build()
257    }
258
259    #[test]
260    fn builder_produces_expected_config() {
261        let email = email_with_links();
262        assert_eq!(email.id, "mailer");
263        assert_eq!(email.domains, vec!["mail.example.com"]);
264        assert_eq!(
265            email.inbound.as_ref().expect("inbound").storage.id,
266            "mailbox"
267        );
268        assert_eq!(
269            email.events.as_ref().expect("events").queue.id,
270            "mail-events"
271        );
272    }
273
274    #[test]
275    fn resource_type_is_email() {
276        assert_eq!(Email::RESOURCE_TYPE.as_ref(), "email");
277    }
278
279    #[test]
280    fn dependencies_include_inbound_storage_and_events_queue() {
281        let email = email_with_links();
282        let dependencies = email.get_dependencies();
283        assert_eq!(dependencies.len(), 2);
284        assert_eq!(dependencies[0].resource_type, Storage::RESOURCE_TYPE);
285        assert_eq!(dependencies[0].id, "mailbox");
286        assert_eq!(dependencies[1].resource_type, Queue::RESOURCE_TYPE);
287        assert_eq!(dependencies[1].id, "mail-events");
288    }
289
290    #[test]
291    fn dependencies_are_empty_without_links() {
292        let email = Email::new("mailer".to_string())
293            .domains(vec!["mail.example.com".to_string()])
294            .build();
295        assert!(email.get_dependencies().is_empty());
296    }
297
298    #[test]
299    fn validate_update_rejects_id_change() {
300        let original = Email::new("mailer".to_string())
301            .domains(vec!["mail.example.com".to_string()])
302            .build();
303        let renamed = Email::new("other".to_string())
304            .domains(vec!["mail.example.com".to_string()])
305            .build();
306
307        let error = original
308            .validate_update(&renamed)
309            .expect_err("changing the id must be rejected");
310        assert!(error.to_string().contains("'id' field is immutable"));
311    }
312
313    #[test]
314    fn validate_update_allows_adding_and_removing_domains() {
315        let original = Email::new("mailer".to_string())
316            .domains(vec![
317                "mail.example.com".to_string(),
318                "mail.example.org".to_string(),
319            ])
320            .build();
321        let appended = Email::new("mailer".to_string())
322            .domains(vec![
323                "mail.example.com".to_string(),
324                "mail.example.org".to_string(),
325                "mail.example.net".to_string(),
326            ])
327            .build();
328        let removed = Email::new("mailer".to_string())
329            .domains(vec!["mail.example.com".to_string()])
330            .build();
331
332        original
333            .validate_update(&appended)
334            .expect("adding a domain must be allowed");
335        original
336            .validate_update(&removed)
337            .expect("removing a domain must be allowed");
338    }
339
340    #[test]
341    fn validate_update_allows_removing_all_seed_domains() {
342        let original = Email::new("mailer".to_string())
343            .domains(vec!["mail.example.com".to_string()])
344            .build();
345        let emptied = Email::new("mailer".to_string()).build();
346
347        original
348            .validate_update(&emptied)
349            .expect("removing all seed domains must be allowed");
350    }
351
352    #[test]
353    fn builder_defaults_to_no_seed_domains() {
354        let email = Email::new("mailer".to_string()).build();
355        assert!(email.domains.is_empty());
356        assert!(email.inbound.is_none());
357        assert!(email.events.is_none());
358        assert!(email.get_dependencies().is_empty());
359    }
360
361    #[test]
362    fn empty_domains_are_omitted_from_serialization_and_roundtrip() {
363        let email = Email::new("mailer".to_string()).build();
364        let json = serde_json::to_value(&email).expect("email should serialize");
365        assert_eq!(json, serde_json::json!({ "id": "mailer" }));
366
367        let roundtrip: Email = serde_json::from_value(json).expect("email should deserialize");
368        assert_eq!(email, roundtrip);
369    }
370
371    #[test]
372    fn validate_update_allows_link_changes() {
373        let original = email_with_links();
374        let unlinked = Email::new("mailer".to_string())
375            .domains(vec!["mail.example.com".to_string()])
376            .build();
377
378        original
379            .validate_update(&unlinked)
380            .expect("removing inbound/events must be allowed");
381        unlinked
382            .validate_update(&original)
383            .expect("adding inbound/events must be allowed");
384    }
385
386    #[test]
387    fn serializes_with_camel_case_and_roundtrips() {
388        let email = email_with_links();
389        let json = serde_json::to_value(&email).expect("email should serialize");
390        assert_eq!(json["domains"][0], "mail.example.com");
391        assert_eq!(json["inbound"]["storage"]["id"], "mailbox");
392        assert_eq!(json["inbound"]["storage"]["type"], "storage");
393        assert_eq!(json["events"]["queue"]["id"], "mail-events");
394
395        let roundtrip: Email = serde_json::from_value(json).expect("email should deserialize");
396        assert_eq!(email, roundtrip);
397    }
398
399    #[test]
400    fn outputs_roundtrip() {
401        let outputs = EmailOutputs {
402            domains: BTreeMap::from([(
403                "mail.example.com".to_string(),
404                EmailDomainOutputs {
405                    dkim_tokens: vec![EmailDkimToken {
406                        name: "token._domainkey.mail.example.com".to_string(),
407                        value: "token.dkim.amazonses.com".to_string(),
408                    }],
409                },
410            )]),
411            configuration_set: "stack-mailer".to_string(),
412            rule_set_name: Some("stack-mailer".to_string()),
413        };
414        let json = serde_json::to_string(&outputs).expect("outputs should serialize");
415        let deserialized: EmailOutputs =
416            serde_json::from_str(&json).expect("outputs should deserialize");
417        assert_eq!(outputs, deserialized);
418    }
419}