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#[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 #[builder(start_fn)]
58 pub id: String,
59
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 #[builder(default)]
67 pub domains: Vec<String>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub inbound: Option<EmailInbound>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub events: Option<EmailEvents>,
80}
81
82#[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 pub storage: ResourceRef,
89}
90
91#[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 pub queue: ResourceRef,
98}
99
100impl Email {
101 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("email");
103
104 pub fn id(&self) -> &str {
106 &self.id
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
113#[serde(rename_all = "camelCase")]
114pub struct EmailDkimToken {
115 pub name: String,
117 pub value: String,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
124#[serde(rename_all = "camelCase")]
125pub struct EmailDomainOutputs {
126 pub dkim_tokens: Vec<EmailDkimToken>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
139#[serde(rename_all = "camelCase")]
140pub struct EmailOutputs {
141 pub domains: BTreeMap<String, EmailDomainOutputs>,
143 pub configuration_set: String,
145 #[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 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}