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)]
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 #[builder(start_fn)]
60 pub id: String,
61
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 #[builder(default)]
69 pub domains: Vec<String>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub inbound: Option<EmailInbound>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub events: Option<EmailEvents>,
84}
85
86#[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 pub storage: ResourceRef,
93}
94
95#[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 pub queue: ResourceRef,
103}
104
105impl Email {
106 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("email");
108
109 pub fn id(&self) -> &str {
111 &self.id
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
118#[serde(rename_all = "camelCase")]
119pub struct EmailDkimToken {
120 pub name: String,
122 pub value: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
129#[serde(rename_all = "camelCase")]
130pub struct EmailDomainOutputs {
131 pub dkim_tokens: Vec<EmailDkimToken>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
144#[serde(rename_all = "camelCase")]
145pub struct EmailOutputs {
146 pub domains: BTreeMap<String, EmailDomainOutputs>,
148 pub configuration_set: String,
150 #[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 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}