1use std::collections::BTreeMap;
16use std::fs;
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use crate::state::app_meta_dir;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum AgentType {
26 NaturalPerson,
27 Organization,
28 AiAgent,
29 HumanitarianService,
30 ContentProvider,
31 Group,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum DomainOwner {
39 Personal { did: String },
41 Group { agreement_ref: String },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Domain {
49 pub name: String,
51 pub agent_type: AgentType,
52 pub owner: DomainOwner,
53 pub front_door_did: String,
55 #[serde(default)]
57 pub dids: Vec<String>,
58 #[serde(default)]
60 pub parent: Option<String>,
61 #[serde(default)]
63 pub label: String,
64 pub created_at: u64,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum AddressKind {
70 Purpose,
72 Relationship,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub struct MailRules {
79 #[serde(default)]
81 pub quarantine: bool,
82 #[serde(default)]
84 pub require_verified_sender: bool,
85 #[serde(default)]
87 pub priority: i8,
88 #[serde(default)]
90 pub retention_days: u32,
91 #[serde(default)]
93 pub notify: bool,
94 #[serde(default)]
97 pub semantic_route: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct MailAddress {
103 pub address: String,
105 pub local_part: String,
106 pub domain: String,
107 pub kind: AddressKind,
108 #[serde(default)]
110 pub relationship_did: Option<String>,
111 #[serde(default)]
113 pub pgp_pubkey: Option<String>,
114 pub rules: MailRules,
115 #[serde(default = "default_true")]
116 pub enabled: bool,
117 pub created_at: u64,
118}
119
120fn default_true() -> bool {
121 true
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct PurposePreset {
127 pub local: String,
128 pub label: String,
129 pub rules: MailRules,
130}
131
132pub fn purpose_presets() -> Vec<PurposePreset> {
135 vec![
136 PurposePreset {
137 local: "frontdoor".into(),
138 label: "Front door (public intake)".into(),
139 rules: MailRules {
140 notify: true,
141 semantic_route: Some("notify purpose:frontdoor".into()),
142 ..Default::default()
143 },
144 },
145 PurposePreset {
146 local: "junkmail".into(),
147 label: "Junk (untrusted sign-ups)".into(),
148 rules: MailRules {
149 quarantine: true,
150 notify: false,
151 retention_days: 30,
152 semantic_route: Some("quarantine silent purpose:junkmail".into()),
153 ..Default::default()
154 },
155 },
156 PurposePreset {
157 local: "mygov".into(),
158 label: "Government / official".into(),
159 rules: MailRules {
160 require_verified_sender: true,
161 priority: 5,
162 retention_days: 3650,
163 notify: true,
164 semantic_route: Some("require_verified priority:5 notify purpose:mygov".into()),
165 ..Default::default()
166 },
167 },
168 PurposePreset {
169 local: "newsletters".into(),
170 label: "Newsletters".into(),
171 rules: MailRules {
172 notify: false,
173 retention_days: 90,
174 semantic_route: Some("silent purpose:newsletters".into()),
175 ..Default::default()
176 },
177 },
178 ]
179}
180
181pub fn is_valid_local_part(local: &str) -> bool {
184 let l = local;
185 if l.is_empty() || l.len() > 64 {
186 return false;
187 }
188 if l.starts_with(['.', '-', '_']) || l.ends_with(['.', '-', '_']) {
189 return false;
190 }
191 l.chars()
192 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_'))
193}
194
195#[allow(clippy::too_many_arguments)]
197pub fn make_domain(
198 name: &str,
199 agent_type: AgentType,
200 owner: DomainOwner,
201 front_door_did: &str,
202 label: &str,
203 parent: Option<String>,
204 now: u64,
205) -> Result<Domain, String> {
206 let name = name.trim().to_lowercase();
207 if name.is_empty() || !name.contains('.') {
208 return Err("a domain must be a dotted name (e.g. personal.me)".into());
209 }
210 Ok(Domain {
211 name,
212 agent_type,
213 owner,
214 front_door_did: front_door_did.to_string(),
215 dids: vec![],
216 parent,
217 label: label.to_string(),
218 created_at: now,
219 })
220}
221
222pub fn make_purpose_address(
224 domain: &str,
225 local: &str,
226 rules: MailRules,
227 now: u64,
228) -> Result<MailAddress, String> {
229 let local = local.trim().to_lowercase();
230 if !is_valid_local_part(&local) {
231 return Err(format!("invalid local part '{local}'"));
232 }
233 Ok(MailAddress {
234 address: format!("{local}@{domain}"),
235 local_part: local,
236 domain: domain.to_string(),
237 kind: AddressKind::Purpose,
238 relationship_did: None,
239 pgp_pubkey: None,
240 rules,
241 enabled: true,
242 created_at: now,
243 })
244}
245
246pub fn make_relationship_address(
248 domain: &str,
249 local: &str,
250 relationship_did: &str,
251 now: u64,
252) -> Result<MailAddress, String> {
253 let mut a = make_purpose_address(domain, local, MailRules::default(), now)?;
254 a.kind = AddressKind::Relationship;
255 a.relationship_did = Some(relationship_did.to_string());
256 a.rules.require_verified_sender = true;
258 Ok(a)
259}
260
261pub fn resolve<'a>(addresses: &'a [MailAddress], address: &str) -> Option<&'a MailAddress> {
263 let want = address.trim().to_lowercase();
264 addresses
265 .iter()
266 .find(|a| a.address.eq_ignore_ascii_case(&want))
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub enum ResolutionVia {
273 Exact,
275 Catchall,
277 Unsolicited,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum DeliveryResolution<'a> {
284 Deliver {
286 address: &'a MailAddress,
287 via: ResolutionVia,
288 },
289 Reject { reason: String },
291}
292
293pub fn split_address(address: &str) -> Option<(String, String)> {
295 let a = address.trim().to_lowercase();
296 let (local, domain) = a.split_once('@')?;
297 if local.is_empty() || domain.is_empty() || !domain.contains('.') {
298 return None;
299 }
300 Some((local.to_string(), domain.to_string()))
301}
302
303pub fn resolve_delivery<'a>(
312 addresses: &'a [MailAddress],
313 to_address: &str,
314) -> DeliveryResolution<'a> {
315 let Some((local, domain)) = split_address(to_address) else {
316 return DeliveryResolution::Reject {
317 reason: "malformed address".into(),
318 };
319 };
320
321 if let Some(a) = addresses
323 .iter()
324 .find(|a| a.address.eq_ignore_ascii_case(&format!("{local}@{domain}")))
325 {
326 if !a.enabled {
327 return DeliveryResolution::Reject {
328 reason: "address disabled".into(),
329 };
330 }
331 return DeliveryResolution::Deliver {
332 address: a,
333 via: ResolutionVia::Exact,
334 };
335 }
336
337 let catchall = addresses.iter().find(|a| {
339 a.domain.eq_ignore_ascii_case(&domain)
340 && a.enabled
341 && (a.local_part == "*" || a.local_part == "catchall")
342 });
343 if let Some(a) = catchall {
344 return DeliveryResolution::Deliver {
345 address: a,
346 via: ResolutionVia::Catchall,
347 };
348 }
349
350 let _ = ResolutionVia::Unsolicited;
352 DeliveryResolution::Reject {
353 reason: format!("no such address ({local}@{domain}) — unsolicited"),
354 }
355}
356
357pub fn onboard_purpose_inboxes(domain: &str) -> Result<Vec<String>, String> {
360 let domain = domain.trim().to_lowercase();
361 if !list_domains().iter().any(|d| d.name == domain) {
362 return Err(format!("unknown domain '{domain}' — register it first"));
363 }
364 let existing = list_addresses(Some(&domain));
365 let now = std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .map(|d| d.as_secs())
368 .unwrap_or(0);
369 let mut minted = Vec::new();
370 for preset in purpose_presets() {
371 let full = format!("{}@{}", preset.local, domain);
372 if existing
373 .iter()
374 .any(|a| a.address.eq_ignore_ascii_case(&full))
375 {
376 continue;
377 }
378 let a = make_purpose_address(&domain, &preset.local, preset.rules.clone(), now)?;
379 upsert_address(a)?;
380 minted.push(full);
381 }
382 let catch_full = format!("catchall@{domain}");
384 if !existing
385 .iter()
386 .any(|a| a.address.eq_ignore_ascii_case(&catch_full))
387 && !list_addresses(Some(&domain))
388 .iter()
389 .any(|a| a.address.eq_ignore_ascii_case(&catch_full))
390 {
391 let mut rules = MailRules {
392 quarantine: true,
393 notify: true,
394 retention_days: 30,
395 ..Default::default()
396 };
397 rules.semantic_route = Some("quarantine notify catchall:public_intake".into());
399 let a = make_purpose_address(&domain, "catchall", rules, now)?;
400 upsert_address(a)?;
401 minted.push(catch_full);
402 }
403 Ok(minted)
404}
405
406fn domains_path() -> PathBuf {
409 app_meta_dir().join("mail_domains.json")
410}
411fn addresses_path() -> PathBuf {
412 app_meta_dir().join("mail_addresses.json")
413}
414
415fn load_json<T: for<'de> Deserialize<'de> + Default>(path: PathBuf) -> T {
416 fs::read_to_string(path)
417 .ok()
418 .and_then(|t| serde_json::from_str(&t).ok())
419 .unwrap_or_default()
420}
421fn save_json<T: Serialize>(path: PathBuf, value: &T) -> Result<(), String> {
422 if let Some(p) = path.parent() {
423 fs::create_dir_all(p).map_err(|e| e.to_string())?;
424 }
425 let text = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
426 fs::write(path, text).map_err(|e| e.to_string())
427}
428
429pub fn list_domains() -> Vec<Domain> {
430 load_json(domains_path())
431}
432
433pub fn upsert_domain(domain: Domain) -> Result<(), String> {
435 let mut all = list_domains();
436 all.retain(|d| d.name != domain.name);
437 all.push(domain);
438 save_json(domains_path(), &all)
439}
440
441pub fn list_addresses(domain: Option<&str>) -> Vec<MailAddress> {
442 let all: Vec<MailAddress> = load_json(addresses_path());
443 match domain {
444 Some(d) => all.into_iter().filter(|a| a.domain == d).collect(),
445 None => all,
446 }
447}
448
449pub fn upsert_address(address: MailAddress) -> Result<(), String> {
451 if !list_domains().iter().any(|d| d.name == address.domain) {
452 return Err(format!("unknown domain '{}'", address.domain));
453 }
454 let mut all: Vec<MailAddress> = load_json(addresses_path());
455 all.retain(|a| !a.address.eq_ignore_ascii_case(&address.address));
456 all.push(address);
457 save_json(addresses_path(), &all)
458}
459
460pub fn set_address_enabled(address: &str, enabled: bool) -> Result<(), String> {
462 let mut all: Vec<MailAddress> = load_json(addresses_path());
463 let a = all
464 .iter_mut()
465 .find(|a| a.address.eq_ignore_ascii_case(address))
466 .ok_or_else(|| format!("unknown address '{address}'"))?;
467 a.enabled = enabled;
468 save_json(addresses_path(), &all)
469}
470
471pub fn address_counts_by_domain() -> BTreeMap<String, usize> {
473 let mut m = BTreeMap::new();
474 for a in list_addresses(None) {
475 *m.entry(a.domain).or_insert(0) += 1;
476 }
477 m
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn local_part_validation() {
486 assert!(is_valid_local_part("frontdoor"));
487 assert!(is_valid_local_part("bob.smith"));
488 assert!(is_valid_local_part("my-gov_2"));
489 assert!(!is_valid_local_part(""));
490 assert!(!is_valid_local_part(".bad"));
491 assert!(!is_valid_local_part("bad."));
492 assert!(!is_valid_local_part("Has Space"));
493 assert!(!is_valid_local_part("UPPER"));
494 assert!(!is_valid_local_part("bad@char"));
495 }
496
497 #[test]
498 fn presets_carry_the_right_rules() {
499 let p = purpose_presets();
500 let junk = p.iter().find(|x| x.local == "junkmail").unwrap();
501 assert!(junk.rules.quarantine && !junk.rules.notify);
502 let gov = p.iter().find(|x| x.local == "mygov").unwrap();
503 assert!(gov.rules.require_verified_sender && gov.rules.priority > 0);
504 let front = p.iter().find(|x| x.local == "frontdoor").unwrap();
505 assert!(
506 !front.rules.quarantine,
507 "front door is public but governed, not quarantined"
508 );
509 }
510
511 #[test]
512 fn purpose_address_is_built_correctly() {
513 let a =
514 make_purpose_address("personal.me", "FrontDoor", MailRules::default(), 100).unwrap();
515 assert_eq!(a.address, "frontdoor@personal.me");
516 assert_eq!(a.kind, AddressKind::Purpose);
517 assert!(a.relationship_did.is_none());
518 assert!(
519 make_purpose_address("personal.me", "bad space", MailRules::default(), 100).is_err()
520 );
521 }
522
523 #[test]
524 fn relationship_address_binds_a_did_and_defaults_to_verified() {
525 let a = make_relationship_address("alice.example", "bob", "did:qualia:bob", 100).unwrap();
526 assert_eq!(a.address, "bob@alice.example");
527 assert_eq!(a.kind, AddressKind::Relationship);
528 assert_eq!(a.relationship_did.as_deref(), Some("did:qualia:bob"));
529 assert!(a.rules.require_verified_sender);
530 }
531
532 #[test]
533 fn resolve_delivery_exact_and_fail_closed() {
534 let exact = make_purpose_address("alice.example", "bob", MailRules::default(), 1).unwrap();
535 let catch = make_purpose_address(
536 "alice.example",
537 "catchall",
538 MailRules {
539 quarantine: true,
540 ..Default::default()
541 },
542 1,
543 )
544 .unwrap();
545 let addrs = vec![exact, catch];
546 match resolve_delivery(&addrs, "bob@alice.example") {
547 DeliveryResolution::Deliver { via, .. } => assert_eq!(via, ResolutionVia::Exact),
548 _ => panic!("expected exact"),
549 }
550 match resolve_delivery(&addrs, "stranger@alice.example") {
551 DeliveryResolution::Deliver { via, address } => {
552 assert_eq!(via, ResolutionVia::Catchall);
553 assert_eq!(address.local_part, "catchall");
554 }
555 _ => panic!("expected catchall"),
556 }
557 match resolve_delivery(&addrs[..1], "nobody@alice.example") {
558 DeliveryResolution::Reject { reason } => assert!(reason.contains("unsolicited")),
559 _ => panic!("expected reject without catchall"),
560 }
561 }
562
563 #[test]
564 fn group_ownership_is_modelled_not_precluded() {
565 let personal = make_domain(
566 "personal.me",
567 AgentType::NaturalPerson,
568 DomainOwner::Personal {
569 did: "did:qualia:me".into(),
570 },
571 "did:qualia:me",
572 "Personal",
573 None,
574 1,
575 )
576 .unwrap();
577 assert!(matches!(personal.owner, DomainOwner::Personal { .. }));
578 let group = make_domain(
579 "project-x.coop",
580 AgentType::Group,
581 DomainOwner::Group {
582 agreement_ref: "agr:project-x".into(),
583 },
584 "did:qualia:project-x",
585 "Project X",
586 None,
587 1,
588 )
589 .unwrap();
590 assert!(
591 matches!(group.owner, DomainOwner::Group { .. }),
592 "group domains slot in without a refactor"
593 );
594 let kid = make_domain(
596 "kid.family.me",
597 AgentType::NaturalPerson,
598 DomainOwner::Personal {
599 did: "did:qualia:kid".into(),
600 },
601 "did:qualia:kid",
602 "Kid",
603 Some("family.me".into()),
604 1,
605 )
606 .unwrap();
607 assert_eq!(kid.parent.as_deref(), Some("family.me"));
608 }
609
610 #[test]
611 fn make_domain_requires_a_dotted_name() {
612 assert!(make_domain(
613 "nodots",
614 AgentType::NaturalPerson,
615 DomainOwner::Personal { did: "d".into() },
616 "d",
617 "",
618 None,
619 1
620 )
621 .is_err());
622 }
623
624 #[test]
625 fn resolve_is_case_insensitive() {
626 let addrs = vec![
627 make_purpose_address("personal.me", "frontdoor", MailRules::default(), 1).unwrap(),
628 make_relationship_address("personal.me", "bob", "did:x", 1).unwrap(),
629 ];
630 assert!(resolve(&addrs, "FrontDoor@Personal.ME").is_some());
631 assert_eq!(
632 resolve(&addrs, "bob@personal.me").unwrap().kind,
633 AddressKind::Relationship
634 );
635 assert!(resolve(&addrs, "nope@personal.me").is_none());
636 }
637
638 #[test]
639 fn split_address_requires_local_at_dotted_domain() {
640 assert_eq!(
641 split_address(" Bob@Alice.Example "),
642 Some(("bob".into(), "alice.example".into()))
643 );
644 assert!(split_address("nodomain").is_none());
645 assert!(split_address("@only.domain").is_none());
646 assert!(split_address("local@nodots").is_none());
647 }
648
649 #[test]
650 fn resolve_delivery_rejects_disabled_exact() {
651 let mut a = make_purpose_address("alice.example", "bob", MailRules::default(), 1).unwrap();
652 a.enabled = false;
653 match resolve_delivery(&[a], "bob@alice.example") {
654 DeliveryResolution::Reject { reason } => assert!(reason.contains("disabled")),
655 _ => panic!("disabled exact must reject"),
656 }
657 }
658
659 #[test]
660 fn resolve_delivery_star_catchall_local() {
661 let mut star =
662 make_purpose_address("alice.example", "catchall", MailRules::default(), 1).unwrap();
663 star.local_part = "*".into();
665 star.address = "*@alice.example".into();
666 match resolve_delivery(&[star], "anyone@alice.example") {
667 DeliveryResolution::Deliver { via, address } => {
668 assert_eq!(via, ResolutionVia::Catchall);
669 assert_eq!(address.local_part, "*");
670 }
671 _ => panic!("expected star catchall"),
672 }
673 }
674
675 #[test]
676 fn purpose_presets_carry_semantic_routes() {
677 for p in purpose_presets() {
678 assert!(
679 p.rules.semantic_route.is_some(),
680 "preset {} should tag semantic_route for audit/routing",
681 p.local
682 );
683 }
684 }
685}