|
| 1 | +# AGENTS.md — Feature Invariants |
| 2 | + |
| 3 | +This file lists the key **user-visible behaviors that must not break**. It exists because |
| 4 | +subtle regressions have slipped in before (e.g. the `·` reply indicator silently broke when |
| 5 | +reply detection was refactored — see CHANGELOG 2026-07-09). |
| 6 | + |
| 7 | +**How to use this file (for AI agents and humans):** |
| 8 | + |
| 9 | +1. **Before changing code**, scan the section(s) that touch your area and keep those |
| 10 | + invariants intact. |
| 11 | +2. **After changing code**, re-scan the same section(s) and confirm each invariant still |
| 12 | + holds — run the pinning test if one is listed (`go test ./internal/... -run TestName`). |
| 13 | +3. **When adding a feature**, add its invariant here (one bullet: behavior, code anchor, |
| 14 | + pinning test). |
| 15 | +4. **At the end of every user-visible change, add a `CHANGELOG.md` entry** (dated heading, |
| 16 | + bold title, what/why/where, test name). |
| 17 | + |
| 18 | +Build commands, architecture, and API quirks live in `CLAUDE.md`. Feature docs live in |
| 19 | +`README.md` and `docs/content/docs/`. This file is only the "do not break" list. |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +## Reply & Threading |
| 24 | + |
| 25 | +- **`·` reply indicator** — after sending a reply, the original email gets the IMAP |
| 26 | + `\Answered` flag (`MarkAnswered` in `internal/imap/client.go`, called from `sendEmailCmd` |
| 27 | + in `internal/ui/model.go`) and the inbox shows `·` (or `·╰` inside a thread, |
| 28 | + `internal/ui/inbox.go`). The local flag also updates immediately on `sendDoneMsg` without |
| 29 | + a refetch. Tests: `TestSendDoneMsgUpdatesAnsweredFlag`, `TestReplyIndicatorWithThread`. |
| 30 | +- **Reply tracking survives pre-send round-trips** — `pendingIsReply` is *session-scoped*: |
| 31 | + re-edit (`e`), spell check (`s`), AI handoff (`i`), and CC/BCC edit (`ctrl+b`) from |
| 32 | + pre-send must all preserve `replyToUID`/`replyToFolder` and the `In-Reply-To`/`References` |
| 33 | + headers. The flag is cleared only when the compose session ends (send, discard, editor |
| 34 | + abort/error/empty, new compose/forward). Test: `TestEditorDoneReplyTrackingSurvivesReEdit`. |
| 35 | +- **Threading headers on every reply-ish send** — regular replies, emoji reactions |
| 36 | + (`ctrl+e`), and iCalendar RSVPs all set `In-Reply-To` + `References` so conversations |
| 37 | + thread in Gmail/Outlook/Apple Mail. Tests: `TestBuildReactionMessage_ThreadingHeaders`, |
| 38 | + `TestBuildRSVPMessage_ThreadingHeadersBracketed`. |
| 39 | +- **Reply prefix handling** — `Re:` is prepended only when the subject doesn't already |
| 40 | + start with `re:`/`aw:`/`sv:`/`vs:` (case-insensitive); localized prefixes are treated as |
| 41 | + replies, never double-prefixed. Test: `TestHasReplyPrefix`. |
| 42 | +- **Reply From auto-selection** — replying picks the From address matching the email's |
| 43 | + To/CC; in the Sent folder the user's own address is in `From` instead |
| 44 | + (`matchFromForReply`, `internal/ui/model.go`). |
| 45 | +- **Reply-all excludes all own addresses** — both IMAP login addresses (`account.User`) |
| 46 | + and send-as addresses (accounts + `[[senders]]` aliases) are stripped from CC. Test: |
| 47 | + `TestReplyAllExcludesAllOwnAddresses`. |
| 48 | +- **Threaded inbox rendering** — threads grouped via `In-Reply-To`/`Message-ID` with |
| 49 | + subject+participant fallback, `│`/`╰` connectors, newest on top; the Sent folder is |
| 50 | + intentionally **not** threaded. Tests: `TestNormalizeSubject`, `TestParticipantMatch`. |
| 51 | + |
| 52 | +## Compose → Pre-send → Send Pipeline |
| 53 | + |
| 54 | +- **Pre-send round-trip preservation** — every path that re-opens the editor or returns to |
| 55 | + pre-send (`e`, `s`, `i`, `ctrl+b`, draft continue, `:recover`) must preserve: body, |
| 56 | + attachments (re-injected as `# [attach]` lines — editor body is source of truth), |
| 57 | + Bcc, selected From, and reply tracking (see above). History: CHANGELOG 2026-04-08, |
| 58 | + 2026-05-06, 2026-07-02, 2026-07-09 — this area regresses easily. |
| 59 | +- **MIME structure by content** (`BuildMessage` in `internal/smtp/sender.go`): |
| 60 | + no attachments → `multipart/alternative`; file attachments → `mixed > alternative`; |
| 61 | + inline images → `related > (alternative + image parts with Content-ID)`; both → |
| 62 | + `mixed > (related > alt+images) + file parts`. Tests: `TestBuildMessage*`. |
| 63 | +- **Inline images** — local `<img src="/abs/path">` rewritten to `cid:`; paths with spaces |
| 64 | + use the `` angle-bracket form and URL-decoding before file read; remote |
| 65 | + `https://` images (HTML signatures) are fetched (10 s timeout) and embedded as `cid:`, |
| 66 | + falling back to the URL on fetch failure. Tests: `TestBuildMessage_WithInlineImage`, |
| 67 | + `TestBuildMessage_InlineImagePathWithSpaces`. |
| 68 | +- **`[attach]` markers are visible plain text** — `# [attach] /path` (header form) and |
| 69 | + `[attach] /path` (inline form), never HTML comments (treesitter hides them in nvim). |
| 70 | + Only regular files are accepted (`filterValidAttachments`); skipped paths are surfaced |
| 71 | + in the status bar. Test: `TestFilterValidAttachments`. |
| 72 | +- **BCC privacy** — Bcc is excluded from message headers but included in SMTP `RCPT TO`; |
| 73 | + comma-separated recipients are split into individual RCPT commands; `auto_bcc` is |
| 74 | + deduped and visible (never silent). Test: `TestCollectRcptTo`. |
| 75 | +- **RFC compliance** — Message-ID uses the sender's domain (never `@neomd`/`@localhost`); |
| 76 | + quoted-printable encodes trailing whitespace before CRLF (`=20`) so Markdown two-space |
| 77 | + hard breaks survive SMTP relays; text/plain part comes before text/html. |
| 78 | +- **Signatures** — per-account `[accounts.signature_block]` overrides the global block |
| 79 | + all-or-nothing via `Config.Signature(account)`; text signature goes to editor + plain |
| 80 | + part, HTML signature to HTML part only; `[html-signature]` placeholder controls |
| 81 | + inclusion per-email and is extracted right before send. Test: `TestSignature`. |
| 82 | +- **Drafts** — saved as plain text only (multipart caused round-trip corruption), keep |
| 83 | + `Bcc`; every compose session is backed up to `~/.cache/neomd/drafts/` (`:recover`); |
| 84 | + discarding unsent mail always asks y/n confirmation. |
| 85 | +- **Callouts** — `> [!note]` / `> [!tip]` / `> [!warning]` (with or without space after |
| 86 | + `>`) render as styled boxes in the HTML part and as emoji text (no blockquote markers) |
| 87 | + in the plain part. Tests: `TestToHTML_Callout_*`, `TestFormatCalloutsForPlainText_*`. |
| 88 | +- **Listmonk interception** — sending to a configured trigger address creates a scheduled |
| 89 | + Listmonk campaign instead of SMTP delivery; pre-send shows list IDs + template + delay. |
| 90 | + Tests: `TestResolveListIDs`, `TestResolveTemplateID`. |
| 91 | +- **From cycling (`ctrl+f`)** — SMTP credentials, Sent-folder destination, and From header |
| 92 | + must all follow the selected identity (accounts first, then `[[senders]]` aliases). |
| 93 | + Tests: `TestPresendSMTPAccount`, `TestReactionAutoSelectsCorrectFromAndSMTP`, |
| 94 | + `TestSentDraftsIMAPClient_*`. |
| 95 | + |
| 96 | +## Screener (HEY-style) |
| 97 | + |
| 98 | +- **Priority order** — spam > screened_out > feed > papertrail > screened_in; per-address |
| 99 | + entries always beat `@domain` entries. Tests: `TestClassify`, `TestClassifyForScreen`. |
| 100 | +- **Reclassification is atomic** — classifying removes the address from ALL conflicting |
| 101 | + lists (snapshot/rollback on failure, both files and moved emails). Test: |
| 102 | + `TestCrossListCleanup_Reclassification`. |
| 103 | +- **Empty lists pause screening** — TUI and headless daemon both skip auto-screening until |
| 104 | + the first sender is classified (prevents sweeping a fresh inbox to ToScreen). Test: |
| 105 | + `TestScreenInbox_EmptyScreenerLists`. |
| 106 | +- **Screener destinations may never be Trash** — refuses to run otherwise. Test: |
| 107 | + `TestValidateScreenerSafetyRejectsTrashDestination`. |
| 108 | +- **ToScreen sender-level classify** — acting on one unmarked message applies to all |
| 109 | + queued mail from that sender. |
| 110 | +- **Lists are line-based with `#` comments** (full-line and inline); daemon only reads |
| 111 | + lists and moves mail, never writes classifications. |
| 112 | + |
| 113 | +## Inbox Display |
| 114 | + |
| 115 | +- **Rows never overflow the terminal width** — complex scripts (Bengali/Arabic/Thai/emoji) |
| 116 | + collapse to `·` for display only; CJK passes through (East Asian Wide is deterministic); |
| 117 | + the original subject is never mutated (reply/forward/thread logic uses the real RFC |
| 118 | + subject). Tests: `TestRowFitsTerminalWidth`, `TestDisplaySafe`. |
| 119 | +- **Indicator columns** — unread, `·` replied, `°` spy pixel, `│`/`╰` thread connectors. |
| 120 | +- **Undo (`u`)** — reverses the last move/delete using UIDPLUS destination UIDs captured |
| 121 | + on move; batch operations preserve partial-undo info on failure. Integration test: |
| 122 | + `TestIntegration_IMAPMoveAndUndo`. |
| 123 | + |
| 124 | +## Reading & Security |
| 125 | + |
| 126 | +- **Spy pixels blocked** — two layers: curated denylist with attribution |
| 127 | + (`internal/imap/tracker_list.go`) + generic 1×1 heuristic; glamour never fetches remote |
| 128 | + resources; results cached in `~/.cache/neomd/spy_pixels` (`+key` spy / `-key` clean). |
| 129 | + Tests: `TestSpyPixelDetection`, `TestSpyPixelSpacersNotFlagged`. |
| 130 | +- **Browser view (`O`) injects CSP** — `script-src 'none'; frame-src 'none'; |
| 131 | + object-src 'none'`; remote images intentionally allowed there. Test: |
| 132 | + `TestIntegration_BrowserSanitization`. |
| 133 | +- **Link opening whitelist** — only `http://`, `https://`, `mailto:` schemes. Test: |
| 134 | + `TestURLSchemeValidation`. |
| 135 | +- **Attachment open safety** — executable extensions are saved but never auto-opened; |
| 136 | + magic-byte mismatch detection (`http.DetectContentType`) blocks disguised files; |
| 137 | + sender-supplied filenames are sanitized against path traversal (`..`, separators) in |
| 138 | + every write path (downloads, `.ics`, `cid:` temp files). |
| 139 | +- **Timer-based mark-as-read** — opening an email marks `\Seen` only after |
| 140 | + `mark_as_read_after_secs` (default 7 s); quick peeks stay unread; reply/forward marks |
| 141 | + immediately. |
| 142 | + |
| 143 | +## IMAP & Runtime Resilience |
| 144 | + |
| 145 | +- **Retry policy** — `withConnRetry` (one retry) only for read-only ops (FETCH/SEARCH/ |
| 146 | + STATUS); mutating ops (MOVE/APPEND/STORE) use `withConn`, never retried (duplicate-mail |
| 147 | + risk). NOOP health probe after 2+ min idle handles suspend/resume. |
| 148 | +- **`safeGo` everywhere** — background goroutines must use `safeGo()` (panic → |
| 149 | + `~/.cache/neomd/crash.log`), never bare `go func()`. Maps passed to goroutines are |
| 150 | + snapshotted on the main goroutine first (spy-pixel cache race, CHANGELOG 2026-05-08). |
| 151 | +- **Nothing blocks the bubbletea Update loop** — notifications have a 2 s timeout, no DNS |
| 152 | + lookups in the send path, background sync never tight-loops on error. Test: |
| 153 | + `TestSend_TimeoutCannotBlockTUI`. |
| 154 | +- **`imap_disabled = true` accounts produce nil clients by design** — every helper that |
| 155 | + resolves an IMAP client must skip nil entries (send, Sent-copy, `\Answered`, `:debug`, |
| 156 | + headless). Tests: `internal/ui/imap_client_helpers_test.go`. |
| 157 | + |
| 158 | +## Notifications & Theming |
| 159 | + |
| 160 | +- **Desktop notifications are VIP-only and TUI-only** — fire solely for senders/domains in |
| 161 | + `notify.txt` (independent of screener categories); the headless daemon never notifies; |
| 162 | + first run records a UID baseline (state key uses the IMAP folder name, not the UI label) |
| 163 | + so enabling never floods; `[notifications].folders` allowlist matches via `LabelFor()`. |
| 164 | + Tests: `TestMaybeNotify_*`, `TestShouldNotify`. |
| 165 | +- **Default theme never drifts** — `kanagawa` must stay byte-for-byte identical to the |
| 166 | + pre-theming palette; `[theme]` overrides merge on top of any built-in. Tests: |
| 167 | + `TestKanagawaDefault`, theme override/fallback tests. |
| 168 | + |
| 169 | +## Config & Credentials |
| 170 | + |
| 171 | +- **Config validation on load** — host:port format, port 1–65535, required fields; |
| 172 | + `$VAR`/`${VAR}` expansion in `user`/`password`. Tests: `TestValidate*`, `TestExpandEnv`. |
| 173 | +- **`password = "keyring"` sentinel** — resolved in `config.Load()` so IMAP, SMTP, and |
| 174 | + `[[senders]]` aliases all see it; preserved with a warning if the keyring is unavailable. |
| 175 | + Test: `TestUseKeyring`. |
| 176 | +- **Secrets never leak** — token files/dirs written with restrictive permissions; error |
| 177 | + messages never include tokens/passwords. Tests: `TestTokenErrors_NoTokenLeak`, |
| 178 | + `TestSaveToken_FilePermissions`. |
| 179 | + |
| 180 | +## Keybindings & Docs |
| 181 | + |
| 182 | +- **`internal/ui/keys.go` is the single source of truth** — drives the `?` overlay and the |
| 183 | + generated `docs/keybindings.md` (`make docs`, runs in `make build`). Never hand-edit the |
| 184 | + markdown tables. |
| 185 | +- **Avoid modifier keys for new bindings** — user's tmux prefix is `C-t`; `ctrl+a`/`ctrl+e` |
| 186 | + collide with textinput line-start/end. Prefer plain letters, especially on pre-send. |
| 187 | +- **README.md syncs to the docs site** (`scripts/sync-readme-to-docs.sh` via `make docs`). |
0 commit comments