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