[Sprint 6] Verify Service + Polish#8
Conversation
- Add verify service (services/verify/) with port probe HTTP endpoint and email echo MDA handler, with 17 unit tests - Implement `aimx status` showing domain, mailboxes, message counts, DKIM key presence, OpenSMTPD status (12 unit tests) - Implement `aimx verify` for end-to-end email verification - Enhance CLI with --help descriptions and version flag - Add comprehensive README with quick start, config reference, channel manager docs, trust policy docs, MCP setup - Add MIT LICENSE file - Update sprint plan: mark Sprint 6 done
uzyn
left a comment
There was a problem hiding this comment.
Sprint 6 Review: Verify Service + Polish
Sprint Goal Assessment
Sprint 6 aimed to complete the product with the hosted verification service, remaining CLI commands, and documentation for open source release. All four stories (S6.1--S6.4) have been implemented. The code is clean, tests pass, clippy is clean, and formatting is correct.
Acceptance Criteria Checklist
S6.1 -- Verify Service: Port Probe
- Met --
check.aimx.emailaccepts probe requests with target IP (GET/probe?ip=and POST/probe) - Met -- Service connects to target IP on port 25 and reports open/closed (
check_port25with 10s timeout) - Met -- Response is JSON
{"reachable": true/false, "ip": "..."}(matches spec, extraipfield is additive) - Met -- Service source code is in
services/verify/ - Met -- Self-hostable with deployment instructions (README has systemd unit, self-hosting guide, env var config)
- Met -- 7 unit tests for the probe service
S6.2 -- Verify Service: Email Echo
- Met --
verify@aimx.emailreceives email and sends auto-reply viaaimx-verify echosubcommand - Met -- Reply includes DKIM/SPF verification results from Authentication-Results header
- Met -- Concurrent handling: parse functions are stateless; the echo is an MDA invocation (one process per email, inherently concurrent)
- Met -- Source code alongside probe service in
services/verify/src/echo.rs
S6.3 -- CLI Polish: status, preflight, verify
- Met --
aimx statusshows domain, mailbox counts (total/unread), DKIM key presence, OpenSMTPD status - Met --
aimx preflightruns port 25 + DNS checks (delegates tosetup::run_preflight_command) - Met --
aimx verifysends test email toverify@aimx.email, polls for reply, reports pass/fail - Met -- All commands have clear, formatted output
- Met -- All commands have
--helpwith descriptive usage text - Met -- 12 unit tests for status (formatting, frontmatter parsing, message counting) + 7 unit tests for verify
S6.4 -- Documentation
- Met -- README.md with project description, quick start, requirements, installation, full usage examples
- Met -- Compatible VPS providers listed (expanded to include DigitalOcean and Linode)
- Met -- MCP configuration example for Claude Code
- Met -- Channel manager configuration examples with template variables table
- Met -- Trust policy documentation
- Met --
config.yamlreference with all fields documented - Met -- MIT LICENSE file added
Test Coverage
- Main crate: 220 unit tests + 35 integration tests (all pass)
- Verify service: 17 unit tests (all pass)
- Total: 272 tests
cargo clippy -- -D warnings: clean (both crates)cargo fmt -- --check: clean (both crates)
Coverage Assessment
Test coverage for the new Sprint 6 code is solid. The status module has 12 unit tests covering formatting, frontmatter extraction, message counting, and edge cases. The verify module has 7 unit tests for file listing and helper functions. Integration tests cover aimx status end-to-end (ingest an email, then run status and check output) and --help for both status and verify.
Potential Issues (Non-blockers)
1. Wrong GitHub URL in README (minor)
README.md line 80 references https://github.com/nicholasgasior/aimx.git but the actual repo is uzyn/aimx. Same issue in services/verify/README.md line 3. This should be corrected before public release.
2. Probe service: No IP validation (suggestion)
The /probe endpoint accepts any string as ip and passes it directly to TcpStream::connect. While the service only connects on port 25 (reducing SSRF risk), a malicious caller could probe internal/private IPs (127.0.0.1, 10.x, 192.168.x, etc.) or hostnames. Consider validating that the target is a public IPv4/IPv6 address before connecting. This is a hardening suggestion, not a blocker for merge, but worth noting for production deployment.
3. Echo service: Multiline Authentication-Results headers (suggestion)
extract_auth_result iterates line by line. In real-world email, Authentication-Results headers are frequently folded across multiple lines (RFC 2822 header folding). If the DKIM/SPF result is on a continuation line, it would be missed. This is acceptable for the initial implementation since the echo service runs on aimx's own infrastructure where the MTA typically produces single-line headers, but it is worth being aware of.
4. Echo reply missing Message-ID and Date headers (suggestion)
The compose_reply function generates the reply email without a Message-ID or Date header. Per RFC 5322, both are required ("SHOULD" for Date, "SHOULD" for Message-ID). In practice, sendmail will likely add them, but it is better to be explicit.
5. Verify command sends from catchall@{domain} (minor)
If the domain has no "catchall" mailbox configured, or if the catchall address format differs, the from address might not match any configured mailbox. This is an edge case that would be caught by the user during aimx verify (the send would fail with a clear error from the send pipeline).
Security Assessment
No significant security issues found. The probe service only connects on port 25, limiting SSRF impact. The echo service reads from stdin and sends via sendmail -- no network listeners. The from field from incoming email is used as the reply To: header, which is standard MDA behavior and not a vulnerability.
Alignment with PRD
All Sprint 6 functional requirements are addressed:
- FR-38: Hosted probe service at
check.aimx.email-- implemented - FR-39: Hosted email endpoint at
verify@aimx.email-- implemented - FR-40: Verify service is open source and self-hostable -- implemented with deployment docs
- FR-42:
aimx preflight-- connected to setup's preflight logic - FR-47:
aimx status-- fully implemented - FR-48:
aimx verify-- fully implemented
The README documentation covers all items listed in the PRD's Section 9 (In Scope) from a user-facing perspective. The config.yaml reference is comprehensive. The LICENSE is MIT, satisfying NFR-3.
Summary
This is a clean final sprint. All four stories are implemented with good test coverage (272 tests total, all passing). The code is well-structured, idiomatic Rust, and follows the patterns established in previous sprints. The documentation is thorough and covers all the required areas for an open source release.
The non-blocker suggestions above (wrong GitHub URL, IP validation, multiline headers, missing RFC headers) are all improvement items that can be addressed in a follow-up. None of them prevent the code from functioning correctly.
Verdict: No blockers. Clean implementation.
uzyn
left a comment
There was a problem hiding this comment.
APPROVED (posted as comment due to GitHub self-review restriction)
All Sprint 6 acceptance criteria met. 272 tests passing, clippy clean, fmt clean. See the detailed review comment above for non-blocker suggestions.
Recommended merge commit message:
Add verify service, status/verify CLI commands, and documentation
Implement Sprint 6 (Verify Service + Polish):
- Port probe HTTP service (services/verify/) using axum for inbound port 25 checks
- Email echo MDA handler for DKIM/SPF verification replies
- `aimx status` command showing domain, mailboxes, message counts, DKIM/OpenSMTPD status
- `aimx verify` command for end-to-end email verification via verify@aimx.email
- Enhanced --help text for all CLI commands
- Comprehensive README with quick start, usage, config reference, DNS records
- MIT LICENSE file
- 272 tests (220 unit + 35 integration + 17 verify service)
* Make IPv6 outbound opt-in via enable_ipv6 config flag aimx send now defaults to IPv4-only outbound delivery. Set enable_ipv6 = true in config.toml to opt into dual-stack (OS chooses). This matches the SPF record aimx setup generates by default (ip4: only) and avoids SPF failures at Gmail when the server has a global IPv6 but DNS isn't set up for it. - Config: new enable_ipv6: bool field, defaults to false - send.rs: LettreTransport carries the flag; re-introduces resolve_ipv4() helper and gates the connect target in try_deliver() - mcp.rs: pass config.enable_ipv6 when constructing transport - book/configuration.md: new "IPv6 delivery (advanced)" section documenting the required AAAA + ip6: SPF additions - book/setup.md: note under DNS table that AAAA / ip6: are only needed when enable_ipv6 is on - aimx setup is unchanged — this is a hidden config flag Verified live: default config connects over IPv4 (AF_INET); adding enable_ipv6 = true makes it connect over IPv6 (AF_INET6). * Update PRD for enable_ipv6 opt-in (FR-7, FR-19, resolved decision #8) * Gate aimx setup IPv6 detection behind enable_ipv6 flag aimx setup now reads `enable_ipv6` from the existing config (if any) and only calls `get_server_ipv6()` when the flag is true. With the flag unset or false, AAAA is not advertised, `ip6:` is not added to SPF, and neither is verified — matching the IPv4-only default of `aimx send`. Any existing AAAA record in DNS is left alone. Re-entrant workflow: - Fresh install -> no config yet -> IPv4-only setup. - User edits config.toml and adds `enable_ipv6 = true`. - User re-runs `sudo aimx setup <domain>` -> setup detects the flag, shows AAAA + ip6: SPF in the DNS table, and verifies them. Verified live on a dual-stack VPS: default config produces an IPv4-only DNS table; adding `enable_ipv6 = true` produces AAAA + ip6: SPF in the same output. - src/setup.rs: read enable_ipv6 when loading existing config, gate get_server_ipv6() call in run_setup - book/configuration.md: updated IPv6-delivery section to describe the re-run-setup workflow and the ignore-when-disabled behavior - book/setup.md: note that AAAA / ip6: are only shown when the flag is on - docs/prd.md: FR-7 updated to reflect the new gate * Address PR #50 review feedback on enable_ipv6 flag - Extract connect-target selection into pure `select_connect_target()` with `ConnectTarget` enum; add unit tests covering all four IPv4/IPv6 combinations. - Skip MX hosts with no A record when `enable_ipv6 = false` instead of silently falling through to the hostname (which could still resolve to IPv6 and violate the flag). Emits a clear "no A record; skipping" error so `try_deliver` moves on to the next MX. - Replace blocking `ToSocketAddrs` in `resolve_ipv4` with a new `mx::resolve_a()` helper built on `hickory-resolver`, for consistency with MX resolution and to avoid blocking getaddrinfo in tokio workers. - Add a doc comment on `resolve_ipv4` explaining why it exists (flag semantics, third add/remove in three sprints) to prevent future deletion. - Add a doc comment on TLS SNI vs. connect-target mismatch while `dangerous_accept_invalid_certs` is set. - Extract `detect_server_ipv6(enable_ipv6, net)` gate in setup.rs and add tests using `get_server_ipv6_calls` counter on `MockNetworkOps` to verify the network call is made iff the flag is true. - Correct book/configuration.md: `aimx verify` only probes port 25, not IPv6; the flag only affects `aimx setup`. - Add post-merge addendum under Sprint 26 in docs/sprint.md noting that this follow-up flipped the default to opt-in IPv6.
…lans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
* docs: add manual test results with findings Full execution of docs/manual-test.md against agent.zeroshot.lol. 10 findings recorded with severity and fix direction, notably: - P0 DKIM key on disk does not match DNS TXT (root cause of outbound dkim=fail at Gmail) - P0 Shell injection in on_receive cmd template expansion - P1 MCP write ops (email_mark_read, etc.) fail when MCP runs as non-root due to root:root 0644 mailbox files * docs: add Recommended fixes section with per-finding implementation plans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
Sprint 44 (post-launch security + quick fixes) addresses findings #9, #10, #7, #1-tier-1, #3. Sprint 45 (strict outbound + MCP writes via daemon addresses #4, #5, #8 and introduces UDS MARK-READ/MARK-UNREAD verbs. Sprint 46 (mailbox CRUD via UDS) addresses #1-tier-2 with MAILBOX-CREATE/MAILBOX-DELETE verbs so daemon picks up changes live. PRD FR-18d tightened: outbound send now requires a concrete non-wildcard mailbox under config.domain; catchall is inbound-only. PRD FR-18e added to cover the new state-mutation verbs on the UDS socket. Finding #2 (SPF envelope MAIL FROM) excluded — already shipped in cd22428. EOF )
- `aimx send` no longer reads /etc/aimx/config.toml; daemon parses From: from the submitted body and resolves the sender mailbox against its in-memory Config. - Outbound rejects both foreign-domain From (ERR DOMAIN) and any From whose local part does not match an explicitly configured non-wildcard mailbox (ERR MAILBOX). Catchall is inbound-only per FR-18d (tightened). - New AIMX/1 MARK-READ / MARK-UNREAD UDS verbs with typed codec, per- mailbox tokio RwLock in a StateContext, and client wiring in `aimx mcp` so email_mark_read / email_mark_unread work without root write access to mailbox files. - New error codes PROTOCOL / NOTFOUND / IO added; legacy From-Mailbox: header silently ignored for forward-compatibility. Closes findings #4, #5, #8 from the 2026-04-17 manual test run.
Add verify service, status/verify CLI commands, and documentation Implement Sprint 6 (Verify Service + Polish): - Port probe HTTP service (services/verify/) using axum for inbound port 25 checks - Email echo MDA handler for DKIM/SPF verification replies - `aimx status` command showing domain, mailboxes, message counts, DKIM/OpenSMTPD status - `aimx verify` command for end-to-end email verification via verify@aimx.email - Enhanced --help text for all CLI commands - Comprehensive README with quick start, usage, config reference, DNS records - MIT LICENSE file - 272 tests (220 unit + 35 integration + 17 verify service)
* Make IPv6 outbound opt-in via enable_ipv6 config flag aimx send now defaults to IPv4-only outbound delivery. Set enable_ipv6 = true in config.toml to opt into dual-stack (OS chooses). This matches the SPF record aimx setup generates by default (ip4: only) and avoids SPF failures at Gmail when the server has a global IPv6 but DNS isn't set up for it. - Config: new enable_ipv6: bool field, defaults to false - send.rs: LettreTransport carries the flag; re-introduces resolve_ipv4() helper and gates the connect target in try_deliver() - mcp.rs: pass config.enable_ipv6 when constructing transport - book/configuration.md: new "IPv6 delivery (advanced)" section documenting the required AAAA + ip6: SPF additions - book/setup.md: note under DNS table that AAAA / ip6: are only needed when enable_ipv6 is on - aimx setup is unchanged — this is a hidden config flag Verified live: default config connects over IPv4 (AF_INET); adding enable_ipv6 = true makes it connect over IPv6 (AF_INET6). * Update PRD for enable_ipv6 opt-in (FR-7, FR-19, resolved decision #8) * Gate aimx setup IPv6 detection behind enable_ipv6 flag aimx setup now reads `enable_ipv6` from the existing config (if any) and only calls `get_server_ipv6()` when the flag is true. With the flag unset or false, AAAA is not advertised, `ip6:` is not added to SPF, and neither is verified — matching the IPv4-only default of `aimx send`. Any existing AAAA record in DNS is left alone. Re-entrant workflow: - Fresh install -> no config yet -> IPv4-only setup. - User edits config.toml and adds `enable_ipv6 = true`. - User re-runs `sudo aimx setup <domain>` -> setup detects the flag, shows AAAA + ip6: SPF in the DNS table, and verifies them. Verified live on a dual-stack VPS: default config produces an IPv4-only DNS table; adding `enable_ipv6 = true` produces AAAA + ip6: SPF in the same output. - src/setup.rs: read enable_ipv6 when loading existing config, gate get_server_ipv6() call in run_setup - book/configuration.md: updated IPv6-delivery section to describe the re-run-setup workflow and the ignore-when-disabled behavior - book/setup.md: note that AAAA / ip6: are only shown when the flag is on - docs/prd.md: FR-7 updated to reflect the new gate * Address PR #50 review feedback on enable_ipv6 flag - Extract connect-target selection into pure `select_connect_target()` with `ConnectTarget` enum; add unit tests covering all four IPv4/IPv6 combinations. - Skip MX hosts with no A record when `enable_ipv6 = false` instead of silently falling through to the hostname (which could still resolve to IPv6 and violate the flag). Emits a clear "no A record; skipping" error so `try_deliver` moves on to the next MX. - Replace blocking `ToSocketAddrs` in `resolve_ipv4` with a new `mx::resolve_a()` helper built on `hickory-resolver`, for consistency with MX resolution and to avoid blocking getaddrinfo in tokio workers. - Add a doc comment on `resolve_ipv4` explaining why it exists (flag semantics, third add/remove in three sprints) to prevent future deletion. - Add a doc comment on TLS SNI vs. connect-target mismatch while `dangerous_accept_invalid_certs` is set. - Extract `detect_server_ipv6(enable_ipv6, net)` gate in setup.rs and add tests using `get_server_ipv6_calls` counter on `MockNetworkOps` to verify the network call is made iff the flag is true. - Correct book/configuration.md: `aimx verify` only probes port 25, not IPv6; the flag only affects `aimx setup`. - Add post-merge addendum under Sprint 26 in docs/sprint.md noting that this follow-up flipped the default to opt-in IPv6.
…lans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
* docs: add manual test results with findings Full execution of docs/manual-test.md against agent.zeroshot.lol. 10 findings recorded with severity and fix direction, notably: - P0 DKIM key on disk does not match DNS TXT (root cause of outbound dkim=fail at Gmail) - P0 Shell injection in on_receive cmd template expansion - P1 MCP write ops (email_mark_read, etc.) fail when MCP runs as non-root due to root:root 0644 mailbox files * docs: add Recommended fixes section with per-finding implementation plans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
Sprint 44 (post-launch security + quick fixes) addresses findings #9, #10, #7, #1-tier-1, #3. Sprint 45 (strict outbound + MCP writes via daemon addresses #4, #5, #8 and introduces UDS MARK-READ/MARK-UNREAD verbs. Sprint 46 (mailbox CRUD via UDS) addresses #1-tier-2 with MAILBOX-CREATE/MAILBOX-DELETE verbs so daemon picks up changes live. PRD FR-18d tightened: outbound send now requires a concrete non-wildcard mailbox under config.domain; catchall is inbound-only. PRD FR-18e added to cover the new state-mutation verbs on the UDS socket. Finding #2 (SPF envelope MAIL FROM) excluded — already shipped in f5cebd2. EOF )
- `aimx send` no longer reads /etc/aimx/config.toml; daemon parses From: from the submitted body and resolves the sender mailbox against its in-memory Config. - Outbound rejects both foreign-domain From (ERR DOMAIN) and any From whose local part does not match an explicitly configured non-wildcard mailbox (ERR MAILBOX). Catchall is inbound-only per FR-18d (tightened). - New AIMX/1 MARK-READ / MARK-UNREAD UDS verbs with typed codec, per- mailbox tokio RwLock in a StateContext, and client wiring in `aimx mcp` so email_mark_read / email_mark_unread work without root write access to mailbox files. - New error codes PROTOCOL / NOTFOUND / IO added; legacy From-Mailbox: header silently ignored for forward-compatibility. Closes findings #4, #5, #8 from the 2026-04-17 manual test run.
Summary
services/verify/) using axum. Accepts GET/POST/proberequests with target IP, connects back on port 25, returns JSON{"reachable": true/false}. Self-hostable with deployment docs.aimx-verify echo). Parses incoming email, extracts Authentication-Results for DKIM/SPF status, sends auto-reply via sendmail with verification results.aimx status(domain, mailbox counts with total/unread, DKIM key presence, OpenSMTPD status),aimx verify(sends test email to verify@aimx.email, polls for reply), enhanced--helptext for all commands.Test counts
New files
src/status.rs-- status command (12 unit tests)src/verify.rs-- verify command (7 unit tests)services/verify/src/main.rs-- probe HTTP service (7 unit tests)services/verify/src/echo.rs-- email echo handler (10 unit tests)services/verify/README.md-- deployment instructionsLICENSE-- MIT licenseTest plan
cargo testpasses (220 unit + 35 integration)cargo clippy -- -D warningscleancargo fmt -- --checkcleancargo testpasses (17 tests)cargo clippy -- -D warningscleanaimx status --helpshows usageaimx verify --helpshows usageaimx statusshows domain and mailboxesaimx setup+aimx status+aimx verifyflow