[Sprint 1] Config schema + FQDN-keyed mailboxes#239
Conversation
Lands the multi-domain config schema in `config.rs`: - `Config.domains: Vec<String>` replaces `Config.domain: String`. The canonical TOML shape is `domains = ["a.com", "b.com"]`; the legacy `domain = "..."` field is accepted on read for v1 back-compat and normalized to a one-entry vec. Mixed `domain` + `domains` is a load error with the exact wording "specify either 'domain' (singular, legacy) or 'domains' (plural), not both". Entries are lowercased, case-insensitive deduplicated, and RFC 1035 syntax-validated on load. Order is significant: `domains[0]` is the default domain. - `[mailboxes."info@a.com"]` (FQDN-keyed) parses; key must equal `address` (case-insensitive on the domain). Legacy `[mailboxes.info]` (local-part-keyed) keeps its operator-friendly key in the in-memory map and is required to carry an `address` that references a configured domain. The on-disk re-keying to FQDN happens during the later upgrade migration, so single-domain runtime paths (ingest, storage paths, mailbox CRUD) keep working unchanged this sprint. - `MailboxConfig::is_catchall(&self, config: &Config)` matches against `*@<d>` for any `d` in `config.domains`. - `Config.per_domain: HashMap<String, DomainOverride>` parses from `[domain."<name>"]` sub-tables. Each `DomainOverride` carries optional `signature`, `dkim_selector`, `trust`, `trusted_senders`. Dangling sub-tables (key not in `domains`) reject at load. Per-domain trust values validate against the same allowlist as the global trust. - Top-level `dkim_selector` is now `Option<String>`; `Config::default_dkim_selector(&self) -> &str` resolves to `"aimx"` when unset. `Config::default_domain(&self) -> &str` returns `domains[0]`. - 47 unit tests in `config::tests` cover every legal and rejected shape. 6 fixture configs land at `tests/fixtures/config/*.toml` (legacy-v1-single-domain, legacy-v1-with-catchall, canonical-single- domain, canonical-two-domains, canonical-two-domains-with-overrides, mixed-legacy-fqdn) with structural-invariant load tests for each. Notable departure from the source plan: the per-domain sub-table uses the TOML key `domain` (singular), not `domains` (plural). TOML cannot let one key be both an array (`domains = [...]`) and a table (`[domains."b.com"]`); the singular form sidesteps the conflict and mirrors the `aimx domain` / `aimx domains` clap alias pattern. All 1181 unit + 116 integration tests pass. `cargo clippy --all-targets -- -D warnings` and `cargo fmt -- --check` clean.
uzyn
left a comment
There was a problem hiding this comment.
Sprint Goal Assessment
The implementation lands the multi-domain config schema in config.rs cleanly and within the stated "no runtime changes" scope. Config.domains: Vec<String> replaces Config.domain: String, the legacy domain field is accepted on read with the exact required error message for mixed shapes, FQDN-keyed [mailboxes] parse alongside legacy local-part keys, per-domain [domain."<name>"] sub-tables populate Config.per_domain, and the top-level dkim_selector becomes Option<String> with the built-in "aimx" fallback. All 1181 unit + 116 integration tests pass; cargo clippy --all-targets -- -D warnings and cargo fmt -- --check are clean.
Acceptance Criteria Checklist
S1-1 Cut epic/multi-domain integration branch
-
epic/multi-domainexists on origin (cut frommain); PR base isepic/multi-domain— verified. - Pattern established for subsequent sprint PRs in this track.
S1-2 domains: Vec<String> deserializer with legacy domain back-compat
-
Config.domains: Vec<String>exists; oldConfig.domain: Stringremoved. - Legacy
domain = "x.com"parses intodomains = ["x.com"]via the customDeserializeonConfig(routes throughRawConfig→from_raw→normalize_domains_field). - Canonical
domains = ["a.com", "b.com"]parses verbatim. - Mixed
domain+domainsrejects with the exact message"specify either 'domain' (singular, legacy) or 'domains' (plural), not both". - Empty
domains = []rejects ("must contain at least one entry"). - Case-insensitive duplicate detection ("duplicate domain").
- RFC 1035 syntactic invalidity rejects (
a..com,a com,-foo.com, single-label all covered). - All entries lowercased on load.
- Unit tests cover each rule; happy-path two-domain config covered by
load_canonical_domains_vec_parses_verbatim.
S1-3 FQDN-keyed [mailboxes] with legacy local-part back-compat
-
[mailboxes."info@a.com"]parses and storesaddress = "info@a.com". - Legacy
[mailboxes.info]parses;addressvalidated againstdomains. - FQDN key/address mismatch rejects.
- Mailbox
addressreferencing a domain not inconfig.domainsrejects. -
MailboxConfig::is_catchall(&self, config: &Config)matches*@<d>for anydinconfig.domains. - Per-domain catchalls (
*@a.com+*@b.com) parse as distinct mailboxes. - Cross-domain duplicate local-part (
info@a.com+info@b.com) parses cleanly. - Mixed legacy/FQDN keys in the same
[mailboxes]table parse cleanly. - PARTIAL: legacy keys normalized to FQDN keys in the in-memory
Config— the sprint plan's acceptance criterion explicitly says "legacy keys normalized to FQDN keys in the in-memoryConfig," but the implementation preserves the operator-friendly key as-is. This is flagged as Deviation #2 below and judged acceptable on scope grounds; see the disposition there.
S1-4 Per-domain [domain.<d>] sub-table parsing
-
Config.per_domain: HashMap<String, DomainOverride>populated from[domain."<name>"]sub-tables. - All four override fields (
signature,dkim_selector,trust,trusted_senders) parse correctly when present; remainNonewhen absent. - Dangling sub-table (key not in
domains) rejects with the expected message. - Top-level
dkim_selectoris nowOption<String>;Config::default_dkim_selector(&self) -> &strreturns"aimx"when unset. -
validate_hooksstill runs and still rejects legacy hook fields (regression testload_still_rejects_legacy_hook_fields_on_multi_domain_configcovers this). - Per-domain
trustvalues validate againstVALID_TRUST_VALUES(validate_trust_valuesextended). - Unit tests cover empty, partial, full, dangling, and default-selector cases.
- Deviation: TOML key is
domain(singular), notdomains(plural) as the PRD example shows. See Deviation #1 below — judged acceptable; the PRD example is invalid TOML.
S1-5 Fixture snapshot tests covering every legal config shape
- All six fixtures present at
tests/fixtures/config/*.toml. - One structural-invariant test per fixture (
fixture_legacy_v1_single_domain_parses,fixture_legacy_v1_with_catchall_parses,fixture_canonical_single_domain_parses,fixture_canonical_two_domains_parses,fixture_canonical_two_domains_with_overrides_parses,fixture_mixed_legacy_fqdn_parses). - One negative-case test per failure mode (mixed
domain/domains, empty list, case-insensitive duplicate, RFC 1035 invalidity, mismatched key/address, dangling sub-table, mailbox domain not indomains, invalid per-domain trust value). -
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt -- --checkclean.
Disposition on the Three Flagged Deviations
1. TOML key [domain."<name>"] (singular) vs PRD's [domains."<name>"] (plural)
Verdict: APPROVE.
The PRD example is genuinely invalid TOML. I reproduced this directly: domains = ["a.com"] followed by [domains."b.com"] produces TomlError { message: "invalid table header — dotted key 'domain' attempted to extend non-table type (string)" } (or the analogous "extend non-table type (array)" for the plural). A TOML key cannot simultaneously be a leaf value/array and a parent table. The PRD author specified an unimplementable shape.
The singular domain is the cleanest workaround:
- It mirrors the existing
aimx domain/aimx domainsclap alias pattern, which is operator-visible precedent. - The
#[serde(rename = "domain")]onConfig.per_domainkeeps the Rust field name accurate (per_domain). - The custom
untaggedenumDomainField { Legacy(String), PerDomain(HashMap<...>) }correctly disambiguates the legacydomain = "x.com"string form from the canonical[domain."<name>"]table form on the same TOML key, so v1 back-compat is preserved.
The PRD and book will need to be updated in S7 to reflect the singular key — but that's a docs concern, not a Sprint 1 blocker. Recommend tracking this for the docs sprint.
2. Legacy local-part-keyed mailboxes keep their key in the in-memory map
Verdict: APPROVE.
The sprint plan does say "legacy keys normalized to FQDN keys in the in-memory Config" (S1-3 final criterion). I checked what re-keying in this sprint would require, and the implementer's argument holds: every existing call site does config.mailboxes.get(local_part) (ingest.rs, send_handler.rs, mailbox.rs, hook_handler.rs, hooks.rs, mailbox_list_handler.rs). Normalizing the in-memory key would cascade into all of these, which is explicitly Sprint 2's territory ("no runtime changes" is the literal Sprint 1 scope statement).
The compromise the implementer landed on is reasonable:
MailboxConfig.addressis always the full FQDN (validated againstdomains), so any code path that needs the FQDN already has it.- The in-memory key shape stays operator-friendly for single-domain installs, matching v1 behavior.
- Sprint 2's upgrade migration (FR-G1 step 3) is the right place to do the on-disk re-key, and once that lands the in-memory map will only ever see FQDN keys on multi-domain installs.
Recommend updating the S1-3 acceptance criterion in the sprint plan to reflect that the in-memory normalization moves to Sprint 2.
3. DomainOverride.trust typed as Option<String> (not Option<TrustedValue>)
Verdict: APPROVE.
TrustedValue (defined in src/trust.rs) is the resolved trust outcome on inbound emails — None / True / False — serialized as "none" / "true" / "false". The policy values that go into config.toml are "none" / "verified" (validated via VALID_TRUST_VALUES). These are categorically different vocabularies.
The existing top-level Config.trust: String already uses the policy vocabulary validated by validate_trust_values. The implementer extended validate_trust_values to also walk config.per_domain and apply the same allowlist check (lines 1310-1319 in src/config.rs), so per-domain trust gets the same validation as the global trust. This is exactly the right shape — and using Option<TrustedValue> here would have been a type confusion that allowed the policy value "verified" to fail to parse.
Potential Bugs
None of Blocker severity.
Security Issues
None observed.
Code Quality
Non-blocker: Doc comment on Config.mailboxes contradicts the implementation.
src/config.rs:154-157 reads:
Legacy local-part-keyed mailboxes (
[mailboxes.info]) are accepted on read and normalized to<local>@<domains[0]>in the in-memoryConfig; the canonical serialized shape is FQDN-keyed.
But normalize_mailboxes_field (line 549) explicitly preserves the legacy key in the in-memory map (out.insert(key.clone(), mb) for the no-@ branch), which is the documented deviation from the sprint plan. The same wording also appears at lines 338-339 ("normalizes legacy local-part mailbox keys to FQDN against domains[0]") in the from_raw doc comment.
This is a doc-vs-code drift introduced by the deviation. Future maintainers reading the struct will be misled about the in-memory shape. Updating both doc comments to say "preserved as the operator-friendly key in the in-memory map" — matching the normalize_mailboxes_field body comment — would resolve it.
Non-blocker: PRD example needs follow-up update.
The PRD §6.1 FR-A6 example shows [domains."b.com"] (plural), which is invalid TOML. This needs to be updated to [domain."b.com"] (singular) when the docs sprint lands, to match what the parser actually accepts. Already covered by Deviation #1 disposition; flagging here for sprint-plan tracking. (No change required in this PR — the PRD lives in docs/, which is a separate repo, and Sprint 7 handles user-facing docs.)
Alignment with PRD
Sprint 1's deliverable is the config schema. Every FR-A1 through FR-A7 invariant the PRD enumerates is implemented or correctly deferred:
- FR-A1 (
domains: Vec<String>, non-empty, RFC 1035 valid, lowercased, dedup, order-significant) — covered. - FR-A2 (legacy
domain: Stringaccepted, mixed shape rejected with exact wording, legacy form read-only) — covered. - FR-A3 (every config rewrite emits normalized shape) — covered by removing the legacy
domainfield from the serializableConfigstruct, sowrite_atomiccannot emit it. - FR-A4 / A5 (FQDN-keyed mailboxes, key=address invariant, address-domain-in-domains invariant, catchall
*@<domain>) — covered. - FR-A6 (per-domain sub-table, all four override fields optional, resolution order documented for Sprint 6) — parsing covered; runtime resolution correctly deferred.
- FR-A7 (top-level
dkim_selectornowOption<String>, fallback to"aimx") — covered viaConfig::default_dkim_selector.
The only divergence from the PRD prose is Deviation #1 (singular domain key), which is correct per the analysis above. The PRD text itself needs a follow-up update in S7.
Summary and Recommended Actions
- Overall verdict: Needs minor fixes (one Non-blocker doc fix; the three implementer-flagged deviations all approved on merit).
- Blockers: None.
- Non-blockers:
- Update the doc comments on
Config.mailboxes(src/config.rs:154-157) andConfig::from_raw(src/config.rs:338-339) so they match the actual behavior: legacy local-part keys are preserved in the in-memory map, not normalized to FQDN. The on-disk re-keying is Sprint 2's territory. - (Track for sprint plan/PRD updates, not this PR): update
multi-domain-prd.md§6.1 FR-A6 example from[domains."b.com"]to[domain."b.com"], and updatemulti-domain-sprint.mdS1-3 final acceptance criterion to reflect that in-memory normalization moves to Sprint 2.
- Update the doc comments on
- Nice-to-haves: None.
The doc comments on `Config.mailboxes` and `Config::from_raw` previously claimed legacy local-part mailbox keys were normalized to FQDN against `domains[0]` in the in-memory `Config`. The actual behavior is that legacy keys are preserved as the operator-friendly key in the in-memory map; only the `address` field is validated against `domains`. The canonical FQDN-keyed shape is what gets serialized on rewrite, and the on-disk re-keying of legacy installs is handled by the upgrade migration on first daemon start — not during config load. Pure doc-comment change; no runtime behavior changes.
Doc-comment drift fix (sole Non-blocker addressed)Pushed What changed
Pure doc-comment change; no runtime behavior changes. Scope confirmationThis was the only Non-blocker raised. The three implementer-flagged deviations were approved on merit by the reviewer (no code changes needed), and the PRD example / sprint-plan AC updates are tracked as docs-repo follow-ups in the separate Ready for re-review / merge. |
uzyn
left a comment
There was a problem hiding this comment.
Re-Review
The single Non-blocker raised in the previous review — doc-comment drift on Config.mailboxes and Config::from_raw — was addressed in commit 89b5af5. Verified the fix against the implementation.
Issue Resolution
Doc-comment drift on Config.mailboxes (previously src/config.rs:154-157) — RESOLVED.
The field doc now reads:
Mailboxes keyed by full address (FQDN), e.g.
"info@a.com". Legacy local-part-keyed mailboxes ([mailboxes.info]) are accepted on read and preserved as the operator-friendly key in the in-memory map; only theaddressfield is validated againstdomains. The canonical serialized shape on rewrite is FQDN-keyed, and on-disk re-keying of legacy installs is performed by the upgrade migration on first daemon start — not during config load.
This matches normalize_mailboxes_field (src/config.rs:546-557), which on the no-@ (legacy) branch inserts via key.clone() — i.e. preserves the operator-friendly local-part key as-is. The doc now correctly identifies the in-memory map shape, the canonical serialized shape, and the upgrade-migration boundary.
Doc-comment drift on Config::from_raw (previously src/config.rs:338-339) — RESOLVED.
The from_raw doc-comment bullet that previously read "normalizes legacy local-part mailbox keys to FQDN against domains[0]" now reads:
- preserves legacy local-part mailbox keys as-is in the in-memory map (on-disk re-keying to FQDN is handled later by the upgrade migration, not here);
- enforces the key/
addressinvariant for FQDN-keyed mailboxes and the address-domain-in-domainsinvariant for every mailbox;
Both bullets are accurate. Tightening the key/address bullet to scope it to FQDN-keyed mailboxes is a correct call too — legacy local-part keys structurally cannot satisfy key == address (since the key has no @), so the invariant only applies to the FQDN branch in normalize_mailboxes_field.
Regression Check
- Diff scope between
56c3a70and89b5af5:src/config.rsonly, 11 insertions / 6 deletions, all inside doc-comment blocks. No code paths, no tests, no module shape changed. cargo fmt -- --checkclean on the current PR HEAD.cargo testandcargo clippy --all-targets -- -D warningswere already confirmed clean by the implementer in the original PR description (1181 unit + 116 integration tests); a doc-comment edit cannot affect either gate.- No new issues introduced.
Disposition on Previously Approved Deviations (unchanged)
- Singular
[domain."<name>"]TOML key vs PRD's plural[domains."<name>"]— APPROVED on merit in the prior review (PRD example is invalid TOML; singular form is the only workable shape). No PR change required. - Legacy local-part-keyed mailboxes preserved in the in-memory map — APPROVED on merit in the prior review (in-memory re-keying is correctly deferred to the upgrade migration). No PR change required.
DomainOverride.trust: Option<String>(policy vocabulary, not the resolvedTrustedValue) — APPROVED on merit in the prior review (policy vocabulary"none"/"verified"is categorically different from the resolved-outcomeTrustedValue). No PR change required.
The PRD example update and sprint-plan AC update remain tracked as docs-repo follow-ups (separate aimx-docs repo), per the prior review.
Summary
- Overall verdict: Ready to merge.
- Blockers: None.
- Non-blockers: None.
- Nice-to-haves: None.
The one Non-blocker from the prior review is resolved; no new findings.
Recommended merge commit message
Multi-domain config schema with FQDN-keyed mailbox back-compat
Lands the multi-domain config schema in `config.rs` (parse + validate
only; no migration, no daemon/runtime changes):
- `Config.domains: Vec<String>` replaces `Config.domain: String`.
Canonical shape is `domains = ["a.com", "b.com"]`; legacy `domain
= "x.com"` accepted on read and normalized to a one-entry vec.
Mixed `domain` + `domains` rejects with the exact wording
"specify either 'domain' (singular, legacy) or 'domains' (plural),
not both". Entries lowercased, case-insensitively deduplicated,
RFC 1035-validated. Order significant — `domains[0]` is default.
- `[mailboxes."info@a.com"]` (FQDN-keyed) parses; key must equal
`address` (case-insensitive on the domain). Legacy
`[mailboxes.info]` (local-part-keyed) preserves the
operator-friendly key in the in-memory map; `address` must
reference a configured domain. On-disk re-keying to FQDN is
deferred to the later upgrade migration so single-domain runtime
paths keep working unchanged this sprint.
- `MailboxConfig::is_catchall(&self, config: &Config)` matches
`*@<d>` for any `d` in `config.domains`.
- `Config.per_domain: HashMap<String, DomainOverride>` parses from
`[domain."<name>"]` sub-tables (singular `domain` key — TOML
cannot let `domains` be both an array and a table). Each
`DomainOverride` carries optional `signature`, `dkim_selector`,
`trust`, `trusted_senders`. Dangling sub-tables reject at load.
Per-domain trust validates against the same allowlist as the
global trust.
- Top-level `dkim_selector` is now `Option<String>`;
`Config::default_dkim_selector(&self) -> &str` resolves to
`"aimx"` when unset. `Config::default_domain(&self) -> &str`
returns `domains[0]`.
- 47 unit tests in `config::tests` cover every legal and rejected
shape. 6 fixture configs land at `tests/fixtures/config/*.toml`
with structural-invariant load tests for each.
All 1181 unit + 116 integration tests pass. `cargo clippy
--all-targets -- -D warnings` and `cargo fmt -- --check` clean.
Sprint Goal
Land the multi-domain config schema in
config.rs—domains: Vec<String>deserialization with legacydomainback-compat, FQDN-keyed[mailboxes]with legacy local-part back-compat, optional[domain.<name>]sub-tables, and all validation rules. No migration code, no daemon changes, no DKIM changes — just parse, validate, snapshot-test the shape.Stories Implemented
S1-1: Cut
epic/multi-domainintegration branchepic/multi-domainexists on origin (cut frommain); this sprint PR targets it.epic/multi-domain.S1-2:
domains: Vec<String>deserializer with legacydomainback-compatConfig.domains: Vec<String>field exists; the oldConfig.domain: Stringis removed.domain = "x.com"parses intodomains = ["x.com"]via a customDeserializeonConfig.domains = ["a.com", "b.com"]parses verbatim.domain+domainsis a load error with the exact message"specify either 'domain' (singular, legacy) or 'domains' (plural), not both".domains = []rejects.domains = ["a.com", "A.com"]) reject via case-insensitive duplicate detection."a..com","a com","-foo.com", single-label, etc.) rejects.S1-3: FQDN-keyed
[mailboxes]with legacy local-part back-compat[mailboxes."info@a.com"]parses; storesaddress = "info@a.com".[mailboxes.info]keeps its operator-friendly key in the in-memory map (see "Technical Decisions" below);addressis still validated againstdomains.addressis a load error.addressreferring to a domain not inconfig.domainsis a load error.MailboxConfig::is_catchall(&self, config: &Config) -> boolreturns true iffself.address == format!("*@{}", d)for somedinconfig.domains.*@a.comand*@b.com) parse cleanly as two distinct mailboxes.info@a.comandinfo@b.com) parses cleanly.[mailboxes]table parse cleanly.S1-4: Per-domain
[domain.<name>]sub-table parsingConfig.per_domain: HashMap<String, DomainOverride>field populated from[domain."<name>"]sub-tables.signature,dkim_selector,trust,trusted_senders) parse correctly when present and remainNonewhen absent.config.domains) is a load error.dkim_selectoris nowOption<String>;Config::default_dkim_selector(&self) -> &strreturns"aimx"when unset.validate_hooksstill runs and still rejects the banned legacy hook fields (no regression).trustvalues validate against the same allowlist as the global trust.dkim_selectordefaulting.S1-5: Fixture snapshot tests covering every legal config shape
tests/fixtures/config/*.toml:legacy-v1-single-domain.tomllegacy-v1-with-catchall.tomlcanonical-single-domain.tomlcanonical-two-domains.tomlcanonical-two-domains-with-overrides.tomlmixed-legacy-fqdn.tomldomain/domains, empty list, duplicate, mismatched key/address, dangling sub-table, mailbox domain not indomains, invalid RFC 1035 syntax, invalid trust value in override).cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt -- --checkall clean.Technical Decisions
Per-domain sub-table TOML key is
domain(singular), notdomains(plural).The PRD example
[domains."b.com"]is not valid TOML —domainsis already the top-level array, and TOML cannot let one key be both an array and a table. The singular form[domain."b.com"]sidesteps the conflict and mirrors the existingaimx domain/aimx domainsclap alias pattern. The Rust field staysper_domainwith#[serde(rename = "domain")]. PRD and downstream sprints should be updated to match.Legacy local-part-keyed mailboxes preserve their key in the in-memory map.
The sprint plan says "legacy keys normalized to FQDN keys in the in-memory
Config", but enforcing that this sprint would force a cascading runtime refactor (storage paths, catchall handling, ingest, mailbox CRUD) that the sprint explicitly defers ("no migration code, no daemon changes, no DKIM changes, no runtime changes"). The chosen disposition: in-memory map keys stay operator-friendly, theaddressfield is the FQDN, and the later upgrade migration handles the on-disk re-key. The FQDN-keyed shape ([mailboxes."info@a.com"]) is fully supported as the canonical-write form; legacy keys ([mailboxes.info]) are accepted on read with the operator's friendly name preserved. Tests cover both shapes.DomainOverride.trusttyped asOption<String>(notOption<TrustedValue>).TrustedValueis the resolved trust outcome on inbound emails (none/true/false); the policy values are"none"/"verified"(validated at load). Matching the existing top-levelConfig.trust: Stringshape keeps the per-mailbox / per-domain / global resolution layers consistent.Deferred Items
None. Every acceptance criterion is satisfied within the scope of this sprint. The runtime wiring of per-domain overrides (trust / signature / DKIM selector) is explicitly downstream work.
Review Focus Areas
src/config.rslines 122–340 — the newConfigstruct, customDeserialize, andDomainOverride. The legacy-fields handling lives innormalize_domains_fieldandnormalize_mailboxes_field.src/config.rsfrom_raw— confirms the legacydomain(string) and canonical[domain."<name>"](sub-table) share a TOML key via anuntaggedenumDomainField.src/config.rsmod testscover happy paths and negative cases; fixture-driven tests live at the end.tests/fixtures/config/*.toml.🤖 Generated with Claude Code