Read a mailbox read-only, in an IMAP client small enough to audit (#246) #63
Closed
claude
wants to merge 1 commits from
overnight/email-imap into overnight/money-zenmoney
pull from: overnight/email-imap
merge into: kami:overnight/money-zenmoney
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/email-imap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
First of the email-reader chain (Vikunja #246, docs/plans/01-email-reader.md). This branch is the reading half only — it extracts nothing and writes nothing to the store.
What changed
internal/email/imap.go— minimal IMAP4rev1 client: LOGIN, EXAMINE, UID SEARCH SINCE, UID FETCH (BODY.PEEK[]), LOGOUT. Implicit TLS only, certificate verified, per-connection deadline. No IDLE.internal/email/message.go— MIME → plaintext: RFC 2047 headers, quoted-printable/base64, multipart walk preferring text/plain, regex HTML strip, attachments skipped, body truncated to 4000 bytes.internal/email/junk.go— header-only bulk/automated filter.internal/email/fetch.go—FetchSince.Run(password): connect, read, drop. The password is an argument, never a struct field kept alive.Why this shape
EXAMINE makes the session read-only at the protocol level; BODY.PEEK means reading his mail does not mark it \Seen. Hand-rolled instead of go-imap because this path holds his mailbox credential and reads his private mail — five commands with no dependencies is auditable in one sitting.
Junk is decided by headers before any model sees the message: List-Unsubscribe/List-Id/List-Post, Precedence: bulk, Auto-Submitted, X-Spam-Flag/Status, Gmail category labels. No sender lists, no subject keywords.
Privacy: nothing logs a body, subject or address; the junk reason names a header; an unsupported charset degrades to headers-only rather than mojibake.
Verified
make buildandmake test(go test -race) both green.internal/emailtests cover the .eml fixtures (Russian quoted-printable, HTML-only, multipart with attachment, cp1251, truncation) and the IMAP client against an in-process fake server, asserting EXAMINE/BODY.PEEK/quoted password/SINCE date on the wire. No live IMAP account exists on this box, so the live half is unverified by design.Vikunja #246
The hand-rolled client earns its place. EXAMINE instead of SELECT makes read-only a protocol fact rather than a promise.
BODY.PEEK[]keeps his unread state his. Implicit TLS with no STARTTLS path removes the one option that could go wrong quietly. The password never lands on theConnstruct, andexeclogs neither command nor response. Both obvious paths for a credential into a log file are closed. Junk being decided on headers the sender set on itself, with no keyword or contact lists, is the right rule.Four findings.
1.
Fetchallocates whatever size the server announces, with no cap and no deadline refresh.ncomes off the wire. A{2147483647}literal is a 2GB allocation before a single byte is read. That does not need a hostile server. One mail with a 60MB attachment gives mavmaild a 60MB peak RSS. That is on a box already holding a 1.7B model resident. The attachment is then discarded byplaintextBodyand the body truncated toMaxBodyBytesof 4000, so the whole allocation exists to be thrown away.zenmoney.diffin PR 62 wraps its read inio.LimitReader(res.Body, 32<<20), and this path is the more exposed one.The deadline is the second half.
setDeadlineruns on eachreadLine, butio.ReadFullgets no refresh. SoTimeoutstops being an idle timeout and becomes a whole-message budget. A 30MB message on a slow uplink fails at the timeout, however healthy the connection is. It then fails on every poll after that. The mailbox stalls behind one big message.Cap the literal at something a mail body could plausibly need, and skip the message when the announced size exceeds it. That also fixes the deadline case, because a capped read finishes.
2.
FetchSince.Runabandons the poll on the first bad message, and its comment says it does not.The comment describes
continue. The code returns. Walk it: 40 new UIDs, newest first, UID 900 is the 60MB message from finding 1 and times out. UIDs 899 down to 861 are never fetched.Runreturns a non-nil error, and every caller I would expect will treat that as a failed poll. Next poll repeats the same order and dies on the same UID. One oversized message permanently blocks every message behind it. The two findings compound, which is why this one is not just a doc fix.3. The Gmail category branch in
classifyJunkcan never fire through this client.gmailCategoriesis matched againsth.Get("X-GM-LABELS")andh.Get("X-Gmail-Labels"), wherehis the header block parsed out of the raw RFC 5322 bytes.X-GM-LABELSis not a header. It is a Gmail IMAP FETCH data item, requested asUID FETCH n (X-GM-LABELS), and it never appears inside the message source.X-Gmail-Labelsis a Takeout mbox export header, which is not what arrives over IMAP either.Fetchasks only forBODY.PEEK[]. So the branch is dead against a real Gmail mailbox.junk_test.gohides this because it builds the header by hand:That test asserts the matcher, not the plumbing. Either add
X-GM-LABELSto the FETCH item list and carry it intoclassifyJunkout of band, or drop the branch. Leaving it as is means the doc comment claims a Promotions filter that is not running. Promotions is the largest junk category in a Gmail mailbox.4.
Timeoutof zero disables every timeout in the path, and nothing rejects it.RunvalidatesAddr,UserandMailbox, and says nothing aboutTimeout. With zero,setDeadlinereturns immediately without setting anything, andDialbuilds anet.Dialer{Timeout: 0}. A dead server then parks the poller on a socket read forever. The session stays authenticated, with his credential live in a TLS state. That is the exact thing theFetchSincedoc comment says the connect-read-drop shape exists to avoid. Either default it inRunor reject zero the way the other three fields are rejected.Smaller notes.
Fetchonly ever returns bytes it found in a literal. A server answering a smallBODY.PEEK[]with a quoted string producesraw == nil.Runreads that as "vanished between SEARCH and FETCH" and skips the message silently. A message that exists and was readable is dropped with no log line.Fetchruns the tagged-completion check against every line it reads. The message bytes go throughio.ReadFullrather than the line loop, so that is safe. Theexecliteral-skip path is the one to keep an eye on if a command is ever added that returns headers.multipartText,mediaType == "text/html" && !strings.HasPrefix(mediaType, "multipart/")has a second clause that cannot be false when the first is true. The case it looks like it was written for is a nestedmultipart/alternativewhose recursion returned HTML-derived text. That text lands inplaintoday, so a sibling realtext/plainpart later in the message is discarded by theif plain == ""guard.decodeBodyrejects windows-1251 and returns subject-only. The comment argues the trade andtestdata/cp1251.emlcovers it. It is still a live gap. cp1251 remains common in Russian mail from older senders, and subject-only means those messages never produce a task candidate in PR 64.quotestrips CR and LF from the password rather than rejecting it. A credential file that picked up an embedded newline authenticates as a different string and fails with the server's generic NO. An error naming the problem would save a long debugging session, and it cannot leak the password.untagged(l, "SEARCH")would also match a hypothetical* SEARCHFOO. No server sends one. Mentioning it only because the prefix check is the sort of thing that ages badly next to an extension.Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed