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")]
81 pub events: Option<EmailEvents>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88pub struct EmailInbound {
89 pub storage: ResourceRef,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub struct EmailEvents {
98 pub queue: ResourceRef,
101}
102
103impl Email {
104 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("email");
106
107 pub fn id(&self) -> &str {
109 &self.id
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
116#[serde(rename_all = "camelCase")]
117pub struct EmailDkimToken {
118 pub name: String,
120 pub value: String,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127#[serde(rename_all = "camelCase")]
128pub struct EmailDomainOutputs {
129 pub dkim_tokens: Vec<EmailDkimToken>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
142#[serde(rename_all = "camelCase")]
143pub struct EmailOutputs {
144 pub domains: BTreeMap<String, EmailDomainOutputs>,
146 pub configuration_set: String,
148 #[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 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}