1use std::collections::HashMap;
8
9use dashmap::DashMap;
10use rustack_ses_model::types::{
11 BehaviorOnMXFailure, CustomMailFromStatus, IdentityDkimAttributes,
12 IdentityMailFromDomainAttributes, IdentityNotificationAttributes, IdentityType,
13 IdentityVerificationAttributes, NotificationType, VerificationStatus,
14};
15
16#[derive(Debug, Clone)]
18pub struct IdentityRecord {
19 pub identity: String,
21 pub identity_type: IdentityType,
23 pub verification_status: VerificationStatus,
25 pub verification_token: Option<String>,
27 pub dkim_enabled: bool,
29 pub dkim_tokens: Vec<String>,
31 pub notification_topics: HashMap<String, Option<String>>,
33 pub feedback_forwarding_enabled: bool,
35 pub mail_from_domain: Option<String>,
37 pub behavior_on_mx_failure: BehaviorOnMXFailure,
39 pub policies: HashMap<String, String>,
41}
42
43#[derive(Debug)]
49pub struct IdentityStore {
50 identities: DashMap<String, IdentityRecord>,
52}
53
54impl Default for IdentityStore {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl IdentityStore {
61 #[must_use]
63 pub fn new() -> Self {
64 Self {
65 identities: DashMap::new(),
66 }
67 }
68
69 #[must_use]
71 pub fn verify_email(&self, email: &str) -> IdentityRecord {
72 let record = IdentityRecord {
73 identity: email.to_owned(),
74 identity_type: IdentityType::EmailAddress,
75 verification_status: VerificationStatus::Success,
76 verification_token: None,
77 dkim_enabled: false,
78 dkim_tokens: Vec::new(),
79 notification_topics: HashMap::new(),
80 feedback_forwarding_enabled: true,
81 mail_from_domain: None,
82 behavior_on_mx_failure: BehaviorOnMXFailure::UseDefaultValue,
83 policies: HashMap::new(),
84 };
85 self.identities.insert(email.to_owned(), record.clone());
86 record
87 }
88
89 #[must_use]
91 pub fn verify_domain(&self, domain: &str) -> (IdentityRecord, String) {
92 let token = uuid::Uuid::new_v4().to_string();
93 let dkim_tokens = vec![
94 format!("{:x}", md5_stub(domain, 1)),
95 format!("{:x}", md5_stub(domain, 2)),
96 format!("{:x}", md5_stub(domain, 3)),
97 ];
98 let record = IdentityRecord {
99 identity: domain.to_owned(),
100 identity_type: IdentityType::Domain,
101 verification_status: VerificationStatus::Success,
102 verification_token: Some(token.clone()),
103 dkim_enabled: false,
104 dkim_tokens,
105 notification_topics: HashMap::new(),
106 feedback_forwarding_enabled: true,
107 mail_from_domain: None,
108 behavior_on_mx_failure: BehaviorOnMXFailure::UseDefaultValue,
109 policies: HashMap::new(),
110 };
111 self.identities.insert(domain.to_owned(), record.clone());
112 (record, token)
113 }
114
115 #[must_use]
117 pub fn is_verified(&self, email: &str) -> bool {
118 if let Some(record) = self.identities.get(email) {
120 return matches!(record.verification_status, VerificationStatus::Success);
121 }
122 if let Some(domain) = email.split('@').nth(1) {
124 if let Some(record) = self.identities.get(domain) {
125 return matches!(record.verification_status, VerificationStatus::Success);
126 }
127 }
128 false
129 }
130
131 #[must_use]
133 pub fn list(&self, identity_type: Option<&IdentityType>) -> Vec<String> {
134 self.identities
135 .iter()
136 .filter(|entry| identity_type.is_none_or(|t| entry.identity_type == *t))
137 .map(|entry| entry.identity.clone())
138 .collect()
139 }
140
141 pub fn delete(&self, identity: &str) {
143 self.identities.remove(identity);
144 }
145
146 #[must_use]
148 pub fn get(&self, identity: &str) -> Option<IdentityRecord> {
149 self.identities.get(identity).map(|r| r.value().clone())
150 }
151
152 #[must_use]
154 pub fn get_verification_attributes(
155 &self,
156 identities: &[String],
157 ) -> HashMap<String, IdentityVerificationAttributes> {
158 let mut result = HashMap::new();
159 for identity in identities {
160 let attrs = if let Some(record) = self.identities.get(identity) {
161 IdentityVerificationAttributes {
162 verification_status: record.verification_status.clone(),
163 verification_token: record.verification_token.clone(),
164 }
165 } else {
166 IdentityVerificationAttributes {
168 verification_status: VerificationStatus::Success,
169 verification_token: if identity.contains('@') {
170 None
171 } else {
172 Some(uuid::Uuid::new_v4().to_string())
173 },
174 }
175 };
176 result.insert(identity.clone(), attrs);
177 }
178 result
179 }
180
181 #[must_use]
183 pub fn get_notification_attributes(
184 &self,
185 identities: &[String],
186 ) -> HashMap<String, IdentityNotificationAttributes> {
187 let mut result = HashMap::new();
188 for identity in identities {
189 let attrs = if let Some(record) = self.identities.get(identity) {
190 let bounce_topic = record
191 .notification_topics
192 .get(NotificationType::Bounce.as_str())
193 .and_then(Clone::clone)
194 .unwrap_or_default();
195 let complaint_topic = record
196 .notification_topics
197 .get(NotificationType::Complaint.as_str())
198 .and_then(Clone::clone)
199 .unwrap_or_default();
200 let delivery_topic = record
201 .notification_topics
202 .get(NotificationType::Delivery.as_str())
203 .and_then(Clone::clone)
204 .unwrap_or_default();
205 IdentityNotificationAttributes {
206 bounce_topic,
207 complaint_topic,
208 delivery_topic,
209 forwarding_enabled: record.feedback_forwarding_enabled,
210 headers_in_bounce_notifications_enabled: Some(false),
211 headers_in_complaint_notifications_enabled: Some(false),
212 headers_in_delivery_notifications_enabled: Some(false),
213 }
214 } else {
215 IdentityNotificationAttributes {
216 bounce_topic: String::new(),
217 complaint_topic: String::new(),
218 delivery_topic: String::new(),
219 forwarding_enabled: true,
220 headers_in_bounce_notifications_enabled: Some(false),
221 headers_in_complaint_notifications_enabled: Some(false),
222 headers_in_delivery_notifications_enabled: Some(false),
223 }
224 };
225 result.insert(identity.clone(), attrs);
226 }
227 result
228 }
229
230 pub fn set_notification_topic(
232 &self,
233 identity: &str,
234 notification_type: &NotificationType,
235 sns_topic: Option<String>,
236 ) {
237 if let Some(mut record) = self.identities.get_mut(identity) {
238 record
239 .notification_topics
240 .insert(notification_type.as_str().to_owned(), sns_topic);
241 }
242 }
243
244 pub fn set_feedback_forwarding_enabled(&self, identity: &str, enabled: bool) {
246 if let Some(mut record) = self.identities.get_mut(identity) {
247 record.feedback_forwarding_enabled = enabled;
248 }
249 }
250
251 #[must_use]
253 pub fn get_dkim_attributes(
254 &self,
255 identities: &[String],
256 ) -> HashMap<String, IdentityDkimAttributes> {
257 let mut result = HashMap::new();
258 for identity in identities {
259 let attrs = if let Some(record) = self.identities.get(identity) {
260 IdentityDkimAttributes {
261 dkim_enabled: record.dkim_enabled,
262 dkim_tokens: record.dkim_tokens.clone(),
263 dkim_verification_status: VerificationStatus::Success,
264 }
265 } else {
266 IdentityDkimAttributes {
267 dkim_enabled: false,
268 dkim_tokens: Vec::new(),
269 dkim_verification_status: VerificationStatus::NotStarted,
270 }
271 };
272 result.insert(identity.clone(), attrs);
273 }
274 result
275 }
276
277 #[must_use]
279 pub fn verify_domain_dkim(&self, domain: &str) -> Vec<String> {
280 if let Some(mut record) = self.identities.get_mut(domain) {
281 record.dkim_enabled = true;
282 record.dkim_tokens.clone()
283 } else {
284 let (_, _) = self.verify_domain(domain);
286 if let Some(mut record) = self.identities.get_mut(domain) {
287 record.dkim_enabled = true;
288 record.dkim_tokens.clone()
289 } else {
290 Vec::new()
291 }
292 }
293 }
294
295 #[must_use]
297 pub fn get_mail_from_domain_attributes(
298 &self,
299 identities: &[String],
300 ) -> HashMap<String, IdentityMailFromDomainAttributes> {
301 let mut result = HashMap::new();
302 for identity in identities {
303 let attrs = if let Some(record) = self.identities.get(identity) {
304 IdentityMailFromDomainAttributes {
305 mail_from_domain: record.mail_from_domain.clone().unwrap_or_default(),
306 mail_from_domain_status: CustomMailFromStatus::Success,
307 behavior_on_mx_failure: record.behavior_on_mx_failure.clone(),
308 }
309 } else {
310 IdentityMailFromDomainAttributes {
311 mail_from_domain: String::new(),
312 mail_from_domain_status: CustomMailFromStatus::Success,
313 behavior_on_mx_failure: BehaviorOnMXFailure::UseDefaultValue,
314 }
315 };
316 result.insert(identity.clone(), attrs);
317 }
318 result
319 }
320
321 pub fn set_mail_from_domain(
323 &self,
324 identity: &str,
325 mail_from_domain: Option<String>,
326 behavior_on_mx_failure: Option<BehaviorOnMXFailure>,
327 ) {
328 if let Some(mut record) = self.identities.get_mut(identity) {
329 record.mail_from_domain = mail_from_domain;
330 if let Some(behavior) = behavior_on_mx_failure {
331 record.behavior_on_mx_failure = behavior;
332 }
333 }
334 }
335
336 #[must_use]
338 pub fn get_policies(&self, identity: &str, policy_names: &[String]) -> HashMap<String, String> {
339 let mut result = HashMap::new();
340 if let Some(record) = self.identities.get(identity) {
341 for name in policy_names {
342 if let Some(policy) = record.policies.get(name) {
343 result.insert(name.clone(), policy.clone());
344 }
345 }
346 }
347 result
348 }
349
350 pub fn put_policy(&self, identity: &str, policy_name: &str, policy: &str) {
352 if let Some(mut record) = self.identities.get_mut(identity) {
353 record
354 .policies
355 .insert(policy_name.to_owned(), policy.to_owned());
356 }
357 }
358
359 pub fn delete_policy(&self, identity: &str, policy_name: &str) {
361 if let Some(mut record) = self.identities.get_mut(identity) {
362 record.policies.remove(policy_name);
363 }
364 }
365
366 #[must_use]
368 pub fn list_policy_names(&self, identity: &str) -> Vec<String> {
369 self.identities
370 .get(identity)
371 .map(|record| record.policies.keys().cloned().collect())
372 .unwrap_or_default()
373 }
374}
375
376fn md5_stub(domain: &str, index: u32) -> u64 {
378 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
379 for byte in domain.bytes() {
380 hash ^= u64::from(byte);
381 hash = hash.wrapping_mul(0x0100_0000_01b3);
382 }
383 hash ^= u64::from(index);
384 hash = hash.wrapping_mul(0x0100_0000_01b3);
385 hash
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_should_verify_email() {
394 let store = IdentityStore::new();
395 let record = store.verify_email("test@example.com");
396 assert_eq!(record.identity, "test@example.com");
397 assert_eq!(record.identity_type, IdentityType::EmailAddress);
398 assert_eq!(record.verification_status, VerificationStatus::Success);
399 assert!(record.verification_token.is_none());
400 }
401
402 #[test]
403 fn test_should_verify_domain() {
404 let store = IdentityStore::new();
405 let (record, token) = store.verify_domain("example.com");
406 assert_eq!(record.identity, "example.com");
407 assert_eq!(record.identity_type, IdentityType::Domain);
408 assert_eq!(record.verification_status, VerificationStatus::Success);
409 assert!(!token.is_empty());
410 }
411
412 #[test]
413 fn test_should_check_direct_email_verification() {
414 let store = IdentityStore::new();
415 let _ = store.verify_email("test@example.com");
416 assert!(store.is_verified("test@example.com"));
417 assert!(!store.is_verified("other@example.com"));
418 }
419
420 #[test]
421 fn test_should_check_domain_fallback_verification() {
422 let store = IdentityStore::new();
423 let _ = store.verify_domain("example.com");
424 assert!(store.is_verified("any@example.com"));
425 assert!(!store.is_verified("any@other.com"));
426 }
427
428 #[test]
429 fn test_should_list_all_identities() {
430 let store = IdentityStore::new();
431 let _ = store.verify_email("test@example.com");
432 let _ = store.verify_domain("example.com");
433 let all = store.list(None);
434 assert_eq!(all.len(), 2);
435 }
436
437 #[test]
438 fn test_should_list_by_type() {
439 let store = IdentityStore::new();
440 let _ = store.verify_email("test@example.com");
441 let _ = store.verify_domain("example.com");
442 let emails = store.list(Some(&IdentityType::EmailAddress));
443 assert_eq!(emails.len(), 1);
444 assert_eq!(emails[0], "test@example.com");
445 let domains = store.list(Some(&IdentityType::Domain));
446 assert_eq!(domains.len(), 1);
447 assert_eq!(domains[0], "example.com");
448 }
449
450 #[test]
451 fn test_should_delete_identity() {
452 let store = IdentityStore::new();
453 let _ = store.verify_email("test@example.com");
454 assert!(store.is_verified("test@example.com"));
455 store.delete("test@example.com");
456 assert!(!store.is_verified("test@example.com"));
457 }
458
459 #[test]
460 fn test_should_get_verification_attributes() {
461 let store = IdentityStore::new();
462 let _ = store.verify_email("test@example.com");
463 let _ = store.verify_domain("example.com");
464 let attrs = store.get_verification_attributes(&[
465 "test@example.com".to_owned(),
466 "example.com".to_owned(),
467 "unknown@other.com".to_owned(),
468 ]);
469 assert_eq!(attrs.len(), 3);
470 assert_eq!(
471 attrs["test@example.com"].verification_status,
472 VerificationStatus::Success
473 );
474 assert_eq!(
475 attrs["example.com"].verification_status,
476 VerificationStatus::Success
477 );
478 assert_eq!(
480 attrs["unknown@other.com"].verification_status,
481 VerificationStatus::Success
482 );
483 }
484
485 #[test]
486 fn test_should_set_and_get_notification_topics() {
487 let store = IdentityStore::new();
488 let _ = store.verify_email("test@example.com");
489 store.set_notification_topic(
490 "test@example.com",
491 &NotificationType::Bounce,
492 Some("arn:aws:sns:us-east-1:000:bounce-topic".to_owned()),
493 );
494 let attrs = store.get_notification_attributes(&["test@example.com".to_owned()]);
495 assert_eq!(
496 attrs["test@example.com"].bounce_topic,
497 "arn:aws:sns:us-east-1:000:bounce-topic"
498 );
499 }
500
501 #[test]
502 fn test_should_manage_policies() {
503 let store = IdentityStore::new();
504 let _ = store.verify_email("test@example.com");
505 store.put_policy(
506 "test@example.com",
507 "my-policy",
508 r#"{"Version":"2012-10-17"}"#,
509 );
510 let names = store.list_policy_names("test@example.com");
511 assert_eq!(names, vec!["my-policy"]);
512 let policies = store.get_policies("test@example.com", &["my-policy".to_owned()]);
513 assert_eq!(policies["my-policy"], r#"{"Version":"2012-10-17"}"#);
514 store.delete_policy("test@example.com", "my-policy");
515 let names = store.list_policy_names("test@example.com");
516 assert!(names.is_empty());
517 }
518}