Skip to main content

rustack_ses_core/
identity.rs

1//! Identity store for verified email addresses and domains.
2//!
3//! In local development mode, all identities are auto-verified on creation.
4//! The store supports email and domain identity types, with domain fallback
5//! verification (emails are verified if their domain is verified).
6
7use 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/// A single identity record (email address or domain).
17#[derive(Debug, Clone)]
18pub struct IdentityRecord {
19    /// The identity string (email address or domain name).
20    pub identity: String,
21    /// Type of identity.
22    pub identity_type: IdentityType,
23    /// Verification status (always `Success` in local dev).
24    pub verification_status: VerificationStatus,
25    /// Verification token (for domain identities).
26    pub verification_token: Option<String>,
27    /// DKIM enabled flag.
28    pub dkim_enabled: bool,
29    /// DKIM tokens (stub tokens for domain identities).
30    pub dkim_tokens: Vec<String>,
31    /// Notification topic ARNs keyed by notification type.
32    pub notification_topics: HashMap<String, Option<String>>,
33    /// Feedback forwarding enabled.
34    pub feedback_forwarding_enabled: bool,
35    /// Mail-from domain.
36    pub mail_from_domain: Option<String>,
37    /// Behavior on MX failure.
38    pub behavior_on_mx_failure: BehaviorOnMXFailure,
39    /// Sending authorization policies keyed by policy name.
40    pub policies: HashMap<String, String>,
41}
42
43/// Store for verified email addresses and domains.
44///
45/// In local development mode, all identities are auto-verified on creation.
46/// In strict mode (configurable), identities must be explicitly verified first
47/// before they can be used as a source address in `SendEmail`.
48#[derive(Debug)]
49pub struct IdentityStore {
50    /// All identities keyed by identity string (email address or domain).
51    identities: DashMap<String, IdentityRecord>,
52}
53
54impl Default for IdentityStore {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl IdentityStore {
61    /// Create a new empty identity store.
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            identities: DashMap::new(),
66        }
67    }
68
69    /// Add an email identity. Auto-verifies in local dev mode.
70    #[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    /// Add a domain identity. Auto-verifies in local dev mode.
90    #[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    /// Check if an email address is verified (either directly or via domain).
116    #[must_use]
117    pub fn is_verified(&self, email: &str) -> bool {
118        // Direct email match
119        if let Some(record) = self.identities.get(email) {
120            return matches!(record.verification_status, VerificationStatus::Success);
121        }
122        // Domain match: extract domain from email and check
123        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    /// List all identity strings, optionally filtered by type.
132    #[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    /// Delete an identity by its string.
142    pub fn delete(&self, identity: &str) {
143        self.identities.remove(identity);
144    }
145
146    /// Get a reference to an identity record.
147    #[must_use]
148    pub fn get(&self, identity: &str) -> Option<IdentityRecord> {
149        self.identities.get(identity).map(|r| r.value().clone())
150    }
151
152    /// Get verification attributes for a list of identities.
153    #[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                // Unknown identities still return Success in local dev
167                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    /// Get notification attributes for a list of identities.
182    #[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    /// Set the notification topic for an identity.
231    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    /// Set feedback forwarding enabled for an identity.
245    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    /// Get DKIM attributes for a list of identities.
252    #[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    /// Verify domain DKIM and return DKIM tokens.
278    #[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            // Auto-create domain identity if not exists
285            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    /// Get mail-from domain attributes for a list of identities.
296    #[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    /// Set the mail-from domain for an identity.
322    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    /// Get policies for an identity.
337    #[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    /// Put (create or update) a policy for an identity.
351    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    /// Delete a policy from an identity.
360    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    /// List policy names for an identity.
367    #[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
376/// Stub hash function for generating deterministic DKIM-like tokens.
377fn 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        // Unknown identities return Success in local dev
479        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}