2 Commits

Author SHA1 Message Date
kami 8c6332f95c mavmaild: own its state volume, retire aged-out UIDs, stop restart-looping
The commented compose service mounted dbdata, the encrypted database volume,
read-write, for one JSON file of UIDs. The header of that same file says only
mavend holds the key and the db volume, and the whole argument for a separate
reader is that a compromise on either side does not reach the other. It gets its
own volume now, at its own path, so neither can be restored from a backup of the
other.

The high-water mark only advances through a contiguous run, and a failed ingest
is deliberately not marked. One message that never ingested therefore pinned the
mark forever: after the lookback window passed it could never be fetched again,
so the gap never closed, every UID above it stayed in the explicit set, and save
rewrote all of them every poll. FetchSince now reports the SEARCH window and the
poller retires everything below it, since a UID that can no longer be searched
for can never be read.

On ErrUnknownMethod the daemon logged "stopping" and then exited at the next
tick with status 0. The compose service inherits restart: unless-stopped, which
restarts a clean exit, so the real behaviour was a loop of four IMAP logins an
hour against a mailbox core would not accept anything from. It now stays up and
polls nothing.

The reader also sends the Junk verdict instead of counting bulk locally, which
is what the wire doc says it does. The verdict carries no mail content, since
nothing on the other side will read it. RunWith is gone, so the tests fake the
read rather than the transport.
Found in review of #65.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:08:32 +04:00
kami ee7bec11e3 Add mavmaild, the read-only IMAP poller that feeds mail intake (#246)
The extraction seam landed on the previous branch but nothing fed it. This
adds the daemon that does: every interval it opens one mailbox read-only
(EXAMINE + BODY.PEEK, so reading leaves no \Seen behind), fetches the UIDs
it has not handed over yet, and posts each message to core over
ingest_mail. Core runs the model and writes task candidates; this daemon
writes nothing and cannot create a reminder.

It is a separate daemon because of the credential. mavpoll set the
precedent with the zenmoney token (#125): the module talking to the third
party holds the secret, reads it from a file so it never lands in argv, in
docker-compose.yml or in shell history, and core never sees it. There is
deliberately no -password flag, and a test asserts that.

Off unless configured at both ends: without -password-file the daemon
refuses to start, and if core has no email block the first ingest returns
ErrUnknownMethod, which disables the reader instead of hammering a socket
that will keep refusing. A seen-UID state file (0600, atomic write) keeps a
restart from re-extracting the whole lookback window; correctness does not
depend on it, since capture dedupes on normalised text. Logs are counts and
UIDs — no subject, sender or body.

Verified with an in-process IMAP server and a fake core: bulk mail is
filtered before core is asked, seen UIDs are not re-fetched, a failed
ingest is retried next poll, ErrUnknownMethod stops at the first message,
and state survives a restart. The live half is untested by design — no IMAP
credential exists on this box; setup is written up as QA steps.

Vikunja #246
2026-08-01 03:13:18 +04:00