[Sprint 2] DKIM + Production-Quality Outbound#2
Conversation
- S2.1: DKIM key generation (2048-bit RSA keypair, DNS TXT record output) - S2.2: DKIM signing on all outbound email (RSA-SHA256 via mail-auth) - S2.3: Email threading with In-Reply-To and References headers - S2.4: File attachments on send with MIME type inference Adds `aimx dkim-keygen` command, `--reply-to` and `--attachment` flags to `aimx send`, configurable DKIM selector in config.yaml, and 23 new tests covering all acceptance criteria.
uzyn
left a comment
There was a problem hiding this comment.
Sprint 2 Review: DKIM + Production-Quality Outbound
Sprint Goal Assessment
Goal: Make outbound email pass authentication checks (DKIM/SPF/DMARC) so messages land in inboxes, not spam folders.
The sprint delivers all four stories (S2.1-S2.4): DKIM key generation, DKIM signing on outbound, email threading, and file attachments on send. The implementation is clean, well-tested (60 tests total, up from 37), and properly integrated with the existing Sprint 1 code. The sprint goal is met.
Acceptance Criteria Checklist
S2.1 -- DKIM Key Generation
| Criterion | Status |
|---|---|
aimx dkim-keygen generates 2048-bit RSA keypair |
MET |
Keys stored in <data_dir>/dkim/ directory |
MET |
| Command outputs the DNS TXT record value for the DKIM public key | MET |
| Existing keys are not overwritten without confirmation | MET (--force flag) |
| Unit test: generated keypair is valid 2048-bit RSA | MET |
| Unit test: DNS TXT record output is correctly formatted | MET |
S2.2 -- DKIM Signing on Outbound
| Criterion | Status |
|---|---|
| All outbound email is signed with DKIM-Signature header | MET |
| Signature algorithm is RSA-SHA256 | MET |
DKIM selector is configurable (default: dkim) |
PARTIAL -- see Bug #1 |
| Signed message passes verification when checked against published DNS record | MET (sign-and-verify roundtrip test) |
| Missing private key produces a clear error, not a crash | MET (warning to stderr, sends unsigned) |
| Unit test: sign-and-verify roundtrip | MET |
| Unit test: missing key returns appropriate error | MET |
S2.3 -- Email Threading
| Criterion | Status |
|---|---|
aimx send --reply-to <message-id> sets correct In-Reply-To header |
MET |
| References header is built from original email's References + Message-ID | MET (build_references() utility) |
| Thread-aware replies display correctly in Gmail's conversation view | NOT VERIFIABLE (manual test) |
| Unit tests: In-Reply-To, References chain | MET |
S2.4 -- File Attachments on Send
| Criterion | Status |
|---|---|
--attachment /path/to/file.pdf attaches file with correct MIME type |
MET |
Multiple --attachment flags supported |
MET |
| Attachment Content-Type inferred from file extension | MET (via mime_guess) |
| Missing file produces clear error | MET |
| Unit tests: single, multiple, MIME inference, missing file | MET |
Bugs
Bug #1 (Blocker): DKIM selector config field ignored in send::run()
File: src/send.rs, line 226
The dkim_selector field was added to Config but send::run() hardcodes "dkim" instead of using c.dkim_selector.as_str():
(Some(c), Some(k)) => Some((k, c.domain.as_str(), "dkim")),
// ^^^^^ should be c.dkim_selector.as_str()This means the configurable selector feature does not actually work at runtime. If a user sets dkim_selector: "s1" in config.yaml, the DKIM signature will still use selector dkim, causing verification failures because the DNS record would be at s1._domainkey.domain but the signature header says s=dkim.
Fix: Replace "dkim" with c.dkim_selector.as_str() on line 226.
Bug #2 (Blocker): DKIM private key written with no file permission restrictions
File: src/dkim.rs, generate_keypair()
The DKIM private key is written with default file permissions (typically 0644 on Linux), making it world-readable. This is a cryptographic signing key that should be restricted to 0600 (owner-only read/write). Any local user on the server can read the key and forge DKIM signatures for the domain.
Fix: After writing private.key, set permissions to 0600:
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&private_path, std::fs::Permissions::from_mode(0o600))?;
}Security Issues
- Private key file permissions -- covered in Bug #2 above.
- Attachment filename injection in MIME headers -- The
filenamevalue from the filesystem path is interpolated directly intoContent-TypeandContent-Dispositionheaders without escaping quotes or special characters. A filename likefile"name.txtwould produce malformed MIME headers. This is low-risk since the filenames come from local filesystem paths (not untrusted input), but worth noting for future hardening when MCP-based sending is added in Sprint 3.
Test Coverage Gaps
- No integration test for the
dkim-keygenCLI command end-to-end (the unit tests cover the underlying functions well, but there is noassert_cmdintegration test that runsaimx dkim-keygenas a subprocess). - The
sign_and_verify_roundtriptest verifies the DKIM-Signature header is present and contains expected fields, but does not perform actual cryptographic verification of the signature against the public key usingmail-auth's verifier. This is acceptable for now since themail-authcrate is trusted, but a true round-trip verification test would be stronger. - No test for the
run()function insend.rsverifying thatdkim_selectorfrom config is actually used (which would have caught Bug #1).
Code Quality Notes (Non-blocking)
build_references()is#[allow(dead_code)]-- This function is prepared for Sprint 3 MCP reply. The dead_code allow is fine as a forward-looking utility, but should be removed when it gets used.- Duplicated message composition logic -- The attachment and non-attachment paths in
compose_message()duplicate the header construction. A helper function to build the common headers would reduce the surface area for divergence bugs. Non-blocking. randcrate version -- Usingrand = "0.8"whilersa = "0.9"may use a different RNG version internally. Works fine now, but worth noting for future dependency hygiene.
Alignment with PRD
The implementation aligns well with the PRD:
- FR-17 (RFC 5322 composition): Met with threading and attachment support
- FR-18 (DKIM signing RSA-SHA256): Met via
mail-authcrate - FR-19 (Hand signed message to OpenSMTPD): Met via sendmail handoff
- FR-20 (File attachments by path): Met
- FR-21 (In-Reply-To and References): Met
- NFR-3 (Permissive licensing):
mail-authuses Apache-2.0,rsauses MIT/Apache-2.0 -- compliant
Summary
Verdict: Needs minor fixes
The sprint delivers all four stories with solid test coverage (60 tests, all passing, clippy clean). The architecture is clean and well-integrated with Sprint 1. Two bugs need fixing before merge:
- The
dkim_selectorconfig field is ignored at runtime (hardcoded"dkim"insend::run()) -- trivial one-line fix - DKIM private key file permissions are too open (world-readable) -- security fix, also straightforward
Both are small fixes that should not require re-review. Once addressed, this is ready to merge.
- Use c.dkim_selector from config instead of hardcoded "dkim" in send::run() - Set 0600 permissions on generated DKIM private key (unix) - Add test for private key file permissions
Review Fixes AppliedBlockers Fixed
Non-blockers Left As-Is
Changes
All 61 tests passing, clippy clean, formatting clean. |
uzyn
left a comment
There was a problem hiding this comment.
Re-review: Sprint 2 — DKIM + Production-Quality Outbound
Blocker Resolution
Bug #1: DKIM selector config field ignored — RESOLVED
Verified in commit ffccde2. Line 226 of src/send.rs now reads:
(Some(c), Some(k)) => Some((k, c.domain.as_str(), c.dkim_selector.as_str())),The hardcoded "dkim" string has been replaced with the config value. Users who set a custom dkim_selector in config.yaml will now get the correct selector in their DKIM-Signature header.
Bug #2: DKIM private key written world-readable — RESOLVED
Verified in commit ffccde2. src/dkim.rs generate_keypair() now sets 0o600 permissions on private.key immediately after writing (unix-only, gated by #[cfg(unix)]). A new test private_key_has_restricted_permissions validates this by checking the file mode after generation. The #[cfg(unix)] gate is appropriate since this is a unix-specific API.
Deferred Non-blockers — Accepted
- Attachment filename not escaped in MIME headers — Agreed, low risk since filenames come from local filesystem paths. Reasonable to defer to Sprint 3.
build_references()dead_code allow — Forward-looking for Sprint 3 threading. Fine.- Duplicated header construction logic — Low divergence risk at current scale. Acknowledged.
- Test coverage gaps — Incremental improvement is a reasonable approach. The existing 61 tests provide solid coverage.
Verification
- All 61 tests pass (56 unit + 5 integration)
- Fix commit is minimal and targeted — only the two blocker fixes plus the permissions test
- No new issues found
Verdict: APPROVED
Both blockers are cleanly resolved. The sprint delivers all four stories (S2.1–S2.4) with solid test coverage, clean clippy output, and proper security hygiene on the DKIM private key.
Recommended merge commit message
Implement Sprint 2: DKIM signing, threading, and attachments
- S2.1: DKIM key generation (2048-bit RSA keypair, DNS TXT record output)
- S2.2: DKIM signing on all outbound email (RSA-SHA256 via mail-auth)
- S2.3: Email threading with In-Reply-To and References headers
- S2.4: File attachments on send with MIME type inference
Adds `aimx dkim-keygen` command, `--reply-to` and `--attachment` flags
to `aimx send`, configurable DKIM selector in config.yaml, and 61 tests
covering all acceptance criteria.
…robustness - Reorder validate-before-sanitize in write_common_headers so compose_message() returns an error when From/To/Subject contain CRLF, with sanitization as a safety net (Bug #1) - Sanitize In-Reply-To and References headers to prevent CRLF injection from malicious inbound Message-ID values (Bug #2) - Add upper-bound guard (999,999) on counter in create_file_atomic to prevent theoretical u32 overflow (Suggestion #4) - Replace duplicate setup_verify_step_available test with a compile-time signature check that is distinct from verify_runner_trait_mock_pass (Suggestion #3) - Update CRLF injection tests to verify error return instead of silent sanitization, add new tests for reply_to/references injection
…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 )
Implement Sprint 2: DKIM signing, threading, and attachments - S2.1: DKIM key generation (2048-bit RSA keypair, DNS TXT record output) - S2.2: DKIM signing on all outbound email (RSA-SHA256 via mail-auth) - S2.3: Email threading with In-Reply-To and References headers - S2.4: File attachments on send with MIME type inference Adds `aimx dkim-keygen` command, `--reply-to` and `--attachment` flags to `aimx send`, configurable DKIM selector in config.yaml, and 61 tests covering all acceptance criteria.
…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 )
Summary
aimx dkim-keygengenerates 2048-bit RSA keypair, stores in<data_dir>/dkim/, outputs DNS TXT record for DKIM public key. Existing keys protected with--forceflag.mail-authcrate. Signs From, To, Subject, Date, Message-ID, In-Reply-To, References headers. Configurable selector (default:dkim). Missing key produces clear warning, does not crash.--reply-to <message-id>flag onaimx sendsets In-Reply-To and References headers. Bare message IDs auto-wrapped in angle brackets.build_references()utility for constructing References chains (used by Sprint 3 MCP reply).--attachment /path/to/fileflag (repeatable) creates multipart/mixed MIME with base64-encoded attachments. MIME type inferred from file extension viamime_guess. Missing files produce clear errors.Changes
src/dkim.rsmodule: key generation, DNS record formatting, private key loading, DKIM signingsrc/cli.rs: addedDkimKeygencommand,--reply-toand--attachmentflags toSendsrc/send.rs: MIME multipart composition, threading headers, DKIM signing integration, attachment handlingsrc/config.rs: addeddkim_selectorfield with"dkim"defaultsrc/main.rs: wiredDkimKeygencommandrsa,base64,mail-auth,mime_guess,randTest plan
cargo clippy -- -D warningscleancargo fmt -- --checkclean