The webmail creates app passwords (shown once, stored hashed, with scopes) but
the protocol servers only compared a login against mailboxes.password_hash, so
an app password was always refused with 535 / authentication failed. They exist
because mail clients cannot do a second factor.
- port.MailboxAuth carries the mailbox's app passwords (newest 20) with their
scopes; the Postgres repository loads them and stamps last_used_at on use
- verifyCredential accepts the mailbox password or an app password that holds
the scope of the protocol; SMTP, IMAP and POP3 use it. ManageSieve keeps the
mailbox password only, as no default scope covers it
- a malformed app password hash is skipped instead of failing the login; a
malformed mailbox hash still surfaces as the server-side error it was
Tests: unit cases for scopes and malformed hashes, the three entry points,
repository tests against Postgres, and an end-to-end test on the real
submission server that fails with 535 without the fix and passes with it.
Full `go test ./...` with TEST_DATABASE_URL, go vet and lang-lint are clean.
The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.
The rewrite also undid work that was already in place:
- The postcss override was dropped and the advisory came back, along
with a critical one in vitest. postcss is a direct dependency now, so
the fix is the version plus an override scoped to Next's own copy;
vitest moves to 3.2.7. The lockfile needed regenerating for either to
take effect. Zero advisories now.
- 100 language-lint violations: the admin console had been rewritten
with its strings in Portuguese, hardcoded. That fails CI, and it also
puts those strings beyond the reach of the translation bundles - an
English or Spanish user would have read Portuguese. They are English
in the source again.
- The mail screen put a fixed 320px list beside the reader with no
breakpoint, so on a phone the viewport overflowed and the message was
a few pixels wide. One pane at a time below md, with a way back.
- The icon-only navigation toggles had nothing for a screen reader to
announce.
The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.
Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DKIM rotation broke the domain it was run on. It retired the live key and
inserted a placeholder as ACTIVE - private_key_enc was the literal string
"dummy-encrypted-key" - so FindActiveKeys then failed for the whole
domain and outbound mail stopped being signed, with the real key already
gone. Reproduced against a database before changing anything.
The root cause runs deeper: the auth service derives its encryption key
with a plain SHA-256 of the master key while the protocols service uses
HKDF with its own info string, so nothing the auth service seals can ever
be opened by the signer. Rotation therefore moves to the protocols
service, which owns that vault and the real key generator. It retires the
previous key with a seven-day overlap in the same transaction rather than
deleting it, because mail already sent under it is still being verified.
Domain onboarding had three defects in one function. It built the alias
destination by interpolating the domain name into the SQL text, which was
injectable; it reused one placeholder for a CITEXT and a VARCHAR column,
which made the statement fail and happened to hide the injection; and it
ignored its scope argument entirely, so any DOMAIN_ADMIN could create
domains. It also provisioned postmaster, abuse and security, missing the
dmarc and tlsrpt aliases that the published records point at - PLAN.md
section 7.4b is explicit that reports bounce without them.
The TLS panel returned fixed values: acme_status "VALID" and 72 days to
expiry, whatever the process actually held. It now reads the certificate
watcher, and a compile-time assertion keeps the wiring from silently
regressing into "disabled". The audit log and the readiness scorecard were
fixtures too, and their field names did not match the API they now read.
Two tests failed and a third asserted a fabricated constant: one depended
on test order through a shared fixture that had a second factor enrolled,
one used a fixed domain name and so passed once per database, and one
expected "records: 13" from a function that never touches DNS. All three
are fixed, and the suite now passes twice in a row against one database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F4's acceptance criterion asks for the three translations to be 100%
complete with the language lint green. The bundles had parity but the
admin screens and the account area were still hardcoded English: 63
visible strings that no locale could reach.
They now come from a shared ui namespace, so the three bundles cannot
drift apart, and all three carry 126 keys.
The language lint also caught this branch's own Go tests: the RFC 2047
cases need accented output to be worth anything, and the delivery test
had Portuguese fixture text. The expectations are written as Go escapes
instead, which keeps the assertions and leaves the source ASCII, and the
fixture text is English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F4 lists real-time updates and the interface had none: an open inbox only
changed when the user reloaded it.
Events come from the transactional outbox rather than from an in-process
callback, which is what the outbox is for (PLAN.md section 9.4): the row
is written in the same transaction as the message, so an event can never
describe a delivery that rolled back, and a crash between commit and
publish loses nothing because the row is still there on restart. The relay
claims rows with SKIP LOCKED so a second instance can help without
publishing anything twice.
The hub fans events out per mailbox, keyed by the mailbox the session
resolved to, so one account's arrivals are never visible to another even
though they share the process. A subscriber that stops reading is dropped
rather than allowed to stall the relay; it reconciles on reconnect.
The client reconnects with capped exponential backoff and treats every
successful connect as a resync, refetching the folder. Without that, a
socket that dropped for a minute would leave the list quietly stale, which
is worse than having no real-time updates at all.
The upgrade goes through a Next rewrite, not the API route handler: a
route handler sees a finished request and cannot upgrade a connection.
Verified against the running stack - the handshake returns 101 through the
Next server, and a message delivered on port 25 arrives as a frame on an
already-open socket without the page asking for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mail screens had no backend at all: they rendered a hardcoded array,
and the storage service that ADR-001 assigned this to is still a health
stub. The API lands here instead, because the folder, UID, MODSEQ, flag
and blob logic already exists and is tested in this service for IMAP.
Reimplementing it in TypeScript would mean two implementations of the
same semantics and two chances to disagree about what a UID means.
Reads use a projected read model rather than hydrated aggregates, which
is the CQRS exception PLAN.md section 2 carves out for webmail queries.
Every query is scoped to the mailbox resolved from the verified session,
never to an identifier taken from the request, so one account cannot read
another's mail by guessing a UID - covered by a test that tries exactly
that and expects 404.
Sending reuses ProcessOutboundEmailUseCase, so mail written in the browser
gets the same treatment as mail submitted over 587: send limits, DKIM
signing, and the durable queue. The From address comes from the session
and never from the request body; letting the body choose it is how a
webmail becomes a relay for spoofed mail.
MIME is parsed server-side because this service already depends on a MIME
library. A message that will not parse still returns its raw text - an
unreadable message beats an inbox that refuses to open one.
Marking a message read updates the flag and the folder counter in one
transaction, and only on a real transition, so re-opening a message cannot
drive unread_count negative.
Verified against the running stack: a message delivered over port 25
appears in the list with its decoded subject and sender, opens, marks
itself read, is found by search, and a message composed in the webmail
reaches outbound_jobs with the session's address as the envelope sender.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The E2E suite has never run in CI. repoRoot() walked up looking for
PLAN.md, but .gitignore excludes PLAN.md on purpose ("Internal planning
docs - never published with the code"), so it is absent from every fresh
checkout and the helper always failed. All twelve E2E tests died on it,
in this run and identically in #18 before any of these changes.
The marker is now the migrations directory, which is committed and is
what every caller of repoRoot is ultimately looking for.
Reproduced by exporting the tree with git archive - which yields exactly
what actions/checkout puts on the runner - and running the suite there:
it fails with the old helper and passes with the new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the second F2 sub-project on top of the merged IMAP core: full
SEARCH criteria matching (flags, dates, size, headers, body/text,
NOT/OR), EXPUNGE (soft-delete via expunged_at), COPY, and MOVE (RFC
6851, advertised via CapMove/CapUIDPlus). Verified end-to-end with a
real go-imap/v2 client against embedded Postgres.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a real :143 IMAP listener to the Go protocols service: a mailbox
owner authenticates over IMAP (Argon2id against mailboxes.password_hash,
enumeration-resistant per PLAN.md section 14.6), lists folders, selects
INBOX, fetches messages F1's SMTP path already persisted - including real
ENVELOPE/BODY[] built from the actual stored MIME bytes via go-imap/v2's
own ExtractEnvelope/ExtractBodySection helpers, not reconstructed from
denormalized columns - and sets/reads flags via STORE.
- ImapSessionUseCase orchestrates auth, folder listing/selection, message
querying, and flag mutation against new Postgres repositories (auth,
folders, message query, flags) and a local-disk blob reader.
- A github.com/emersion/go-imap/v2 (pinned v2.0.0-beta.8) Backend/Session
implements 7 real methods (Login, List, Select, Unselect, Status, Fetch,
Store) plus a correct no-op Poll; the other 10 of the interface's 18
methods (Create/Delete/Rename/Subscribe/Unsubscribe/Append/Idle/Expunge/
Search/Copy) are explicit stubs returning a clear "not yet implemented"
IMAP error - SEARCH/EXPUNGE/COPY/MOVE, IDLE/CONDSTORE, mailbox
management, and APPEND are separate follow-up sub-projects per the
design doc's agreed ordering with the user.
Verified end-to-end with a real go-imap/v2 imapclient (embedded Postgres,
no Docker needed) driving Login -> Select -> Fetch -> Store -> List
against a message seeded through F1's real SMTP use case, proving true
SMTP-write/IMAP-read interop, not just isolated unit tests.
Built via 8 sequential tasks under subagent-driven development, each
independently reviewed, plus a final whole-branch review. Two real bugs
were found and fixed before merge, both exactly the kind unit tests with
fakes cannot catch:
- Store's UID-set resolution silently no-op'd on dynamic ranges like
"UID STORE 1:*" (the common "mark all as read" operation) after an
earlier draft bypassed the folder's real message list; restored to
resolve against real messages via the same pattern Fetch already used
correctly, with tests proving both dynamic ranges and non-UID STORE.
- go-imap/v2's server calls Session.Poll() implicitly before the status
response of nearly every authenticated command (FETCH/STORE/SEARCH/
APPEND/COPY), not just IDLE as assumed - a hard-stubbed Poll broke every
real command through this path; fixed to a correct no-op.
- List had zero test coverage despite being committed scope; added unit
tests for wildcard/delimiter matching plus a real E2E LIST assertion.
Known follow-up, not blocking: a pre-existing test-fixture hygiene gap
(hardcoded content_sha256 blob hashes with no cleanup, shared across
several Postgres integration test files including one new to this
branch) causes local-dev test collisions on repeated runs against a
persistent database - invisible to CI (fresh Postgres per run) but worth
a cleanup pass.
Per feature/3-f0-f1-validation's own gap analysis (commit 097fa37):
- InboundMessageRepository.Persist now updates folders.unread_count,
folders.total_count, and mailboxes.used_bytes in the same transaction
as the message insert (PLAN.md section 6.1 "++counters" was a no-op
before this).
- MailboxRecord carries QuotaBytes/UsedBytes; ResolveRecipient rejects
RCPT with 452 4.2.2 when a target mailbox has already reached its
quota (used_bytes >= quota_bytes), before DATA - never silently
accepting mail into a full mailbox.
- MailboxRepository.ResolveDeliveryTargets tries a direct mailbox match
first, then falls back to the aliases table (exact source_address
match takes precedence over a domain catch-all). One RCPT TO can now
fan out to multiple mailboxes; alias destinations that don't resolve
to an internal active mailbox are silently skipped, since outbound/
external forwarding doesn't exist yet. postmaster@/abuse@/dmarc@-style
aliases now actually deliver.
The E2E test's counters_updated subtest flips from a documented KNOWN
GAP (t.Skipf) to a hard assertion now that it passes, and gains two new
subtests (quota_rejected_when_mailbox_full, alias_resolves_to_destination_mailbox)
covering the other two fixes end-to-end. New unit and Postgres
integration test coverage added for all three fixes at every layer.
Verified: full suite green on Windows (unit + E2E via embedded
Postgres, no Docker needed) and via WSL Docker against the real
docker-compose Postgres (all infrastructure/postgres and diskstorage
integration tests, not just E2E). lang-lint clean.
Boots a real Postgres (no Docker), applies the real migration and seed,
starts the real go-smtp server wired as main.go does, and drives a live
SMTP session. Asserts: 550 for unknown RCPT, durable EML + row with
uid_next allocation, sha256 disk/db match, outbox event, blob dedup with
ref_count. Documents the counters gap (PLAN 6.1) as an explicit skip.
Phase: F1 validation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>