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