Skip to main content

qualia_client_core/
domains.rs

1//! **Domains & mail addresses** — the foundation of the domain + semantic-mail/address stack
2//! (`docs/plans/social-network-plan.md` §0.5). A domain/subdomain acts as an **agent** (QDP — Timothy's
3//! `draft-webcivics-QDP`: a domain publishes an RDF agent profile at `/.well-known/QDP`). A person runs
4//! several **context-domains** (personal/work/projects), each with its own front-door DID(s); subdomains
5//! serve families/children. A domain may be **single-owner** or (deferred placeholder) **group-owned via an
6//! M:N agreement** — modelled now so group domains slot in later without a refactor.
7//!
8//! Addresses on a domain are **rule-bearing mailboxes** — **purpose inboxes** (`frontdoor@`/`junkmail@`/
9//! `mygov@`/`newsletters@`) or **per-relationship** (`bob@alice.example`), plus optional deliberate
10//! **catchall@** for fail-closed wild-card intake (quarantine, not open relay). This module owns the data
11//! model, presets, `resolve_delivery`, and `onboard_purpose_inboxes`. Rules evaluation is
12//! [`crate::mail_rules`]; SMTP/IMAP is [`crate::mail_transport`]; QDP front-door forms are
13//! [`crate::front_door`] / `api::front_door_forms`.
14
15use std::collections::BTreeMap;
16use std::fs;
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use crate::state::app_meta_dir;
22
23/// QDP agent type (`draft-webcivics-QDP` §1) — what kind of agent a domain represents.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum AgentType {
26    NaturalPerson,
27    Organization,
28    AiAgent,
29    HumanitarianService,
30    ContentProvider,
31    /// A group/collective (project, cooperative, household) — see `DomainOwner::Group`.
32    Group,
33}
34
35/// Who owns/controls a domain. `Personal` today; **`Group` is the deferred placeholder** (governed by an
36/// M:N agreement) — present so the model does not hard-assume single-owner domains.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum DomainOwner {
39    /// A single person — `did` controls the domain.
40    Personal { did: String },
41    /// PLACEHOLDER (deferred): group/agreement-owned; `agreement_ref` points to the (future) M:N agreement
42    /// that defines membership/roles/permissions. Not yet implemented — just not precluded.
43    Group { agreement_ref: String },
44}
45
46/// A domain (or subdomain) acting as an agent.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Domain {
49    /// The name, e.g. `personal.me`, `kid.family.me`, `project-x.coop`.
50    pub name: String,
51    pub agent_type: AgentType,
52    pub owner: DomainOwner,
53    /// Front-door DID for this domain (the QDP agent id).
54    pub front_door_did: String,
55    /// Additional DIDs scoped to this context (pairwise per relationship, etc.).
56    #[serde(default)]
57    pub dids: Vec<String>,
58    /// Parent domain, for subdomains (families/children).
59    #[serde(default)]
60    pub parent: Option<String>,
61    /// Human label for the context ("Personal", "Work", "Project X").
62    #[serde(default)]
63    pub label: String,
64    pub created_at: u64,
65}
66
67/// The kind of a mail address on a domain.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum AddressKind {
70    /// A purpose inbox: `frontdoor` / `junkmail` / `mygov` / `newsletters` / …
71    Purpose,
72    /// A per-relationship (pairwise) address bound to one relationship.
73    Relationship,
74}
75
76/// Rules governing an address (a mailbox) — procedural + semantic.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub struct MailRules {
79    /// Quarantine incoming by default (the `junkmail@` burner).
80    #[serde(default)]
81    pub quarantine: bool,
82    /// Only accept mail from a verified/known sender (DID-signed / an established relationship).
83    #[serde(default)]
84    pub require_verified_sender: bool,
85    /// Priority hint (0 = normal; higher = more important, e.g. `mygov@`).
86    #[serde(default)]
87    pub priority: i8,
88    /// Retention in days (0 = keep indefinitely).
89    #[serde(default)]
90    pub retention_days: u32,
91    /// Notify on receipt.
92    #[serde(default)]
93    pub notify: bool,
94    /// Optional **semantic** routing/handling rule — an agreement / credential / values-credential id the
95    /// mail client evaluates (the hook into the rights model).
96    #[serde(default)]
97    pub semantic_route: Option<String>,
98}
99
100/// A mail address = a rule-bearing mailbox on a domain.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct MailAddress {
103    /// The full address, `local@domain`.
104    pub address: String,
105    pub local_part: String,
106    pub domain: String,
107    pub kind: AddressKind,
108    /// For a `Relationship` address: the peer/relationship DID it is bound to.
109    #[serde(default)]
110    pub relationship_did: Option<String>,
111    /// Optional PGP public key (armored) for this address.
112    #[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/// A common purpose-inbox preset (a sensible name + rules a user can accept or tweak).
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct PurposePreset {
127    pub local: String,
128    pub label: String,
129    pub rules: MailRules,
130}
131
132/// The built-in purpose inboxes. `frontdoor@` is public-but-**governed**; `junkmail@` is the burner;
133/// `mygov@` is verified-sender-only + high-priority + long retention.
134pub 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
181/// A local-part is a lowercase RFC-ish token: alphanumeric + `.` `-` `_`, 1..=64 chars, no leading/trailing
182/// separator. (Deliberately stricter than RFC 5321 for legibility + safety.)
183pub 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/// Build a `Domain` value (does not persist).
196#[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
222/// Build a purpose-inbox address (does not persist).
223pub 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
246/// Build a per-relationship (pairwise) address bound to a relationship DID (does not persist).
247pub 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 relationship address defaults to verified-sender-only (it's for one known peer).
257    a.rules.require_verified_sender = true;
258    Ok(a)
259}
260
261/// Resolve a full address (case-insensitive) against a set of addresses.
262pub 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/// How a delivery address was matched.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub enum ResolutionVia {
273    /// Exact full-address match (`bob@alice.example`).
274    Exact,
275    /// Domain catch-all (`*@domain` / `catchall@domain`) for deliberately open intake.
276    Catchall,
277    /// Unknown local part with no catch-all — fail closed.
278    Unsolicited,
279}
280
281/// Outcome of semantic delivery resolution for an inbound `to` address.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum DeliveryResolution<'a> {
284    /// Deliver under this mailbox (apply its rules).
285    Deliver {
286        address: &'a MailAddress,
287        via: ResolutionVia,
288    },
289    /// Reject — no surface for strangers when fail-closed.
290    Reject { reason: String },
291}
292
293/// Parse `local@domain` (lowercase). Returns `None` if not a dotted domain form.
294pub 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
303/// Resolve inbound delivery for `to_address` against minted mailboxes.
304///
305/// Order (structural anti-spam):
306/// 1. Exact enabled address match.
307/// 2. Domain catch-all: `*@domain` or `catchall@domain` if enabled (deliberate public intake).
308/// 3. Otherwise **reject** — no open wildcard to every local part (strangers have no surface).
309///
310/// Disabled exact matches reject with "address disabled". Unknown domain rejects.
311pub 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    // Exact match first.
322    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    // Deliberate catch-all only (not silent open relay).
338    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    // Fail closed: unsolicited local parts have no mailbox.
351    let _ = ResolutionVia::Unsolicited;
352    DeliveryResolution::Reject {
353        reason: format!("no such address ({local}@{domain}) — unsolicited"),
354    }
355}
356
357/// Mint every built-in purpose preset for `domain` that is not already present.
358/// Returns the list of newly minted full addresses (empty if already onboarded).
359pub 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    // Optional deliberate catch-all for public wild-card intake (quarantine by default).
383    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        // DSL tokens force quarantine+notify; catchall:public_intake is audit trail for rights engines.
398        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
406// --- Thin persistence (additive JSON under app_meta_dir; mirrors directory.rs) ---
407
408fn 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
433/// Persist a domain (upsert by name).
434pub 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
449/// Persist an address (upsert by full address). Errors if the domain is unknown.
450pub 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
460/// Enable/disable an address (the surgical per-relationship revoke).
461pub 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
471/// Summary counts for the UI.
472pub 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        // A subdomain (child under a household).
595        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        // Simulate minting as local_part "*" (resolve accepts either).
664        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}