Skip to content

Commit 2b89c2d

Browse files
committed
Merge branch 'dev'
2 parents ce94276 + 3afb6d7 commit 2b89c2d

6 files changed

Lines changed: 288 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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 `![](<path>)` 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`).

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
# 2026-07-09
4+
5+
- **Fix: `·` reply indicator lost after pre-send round-trips** — the 2026-07-06 reply-detection change made reply tracking a one-shot flag that was consumed (and cleared) on the first editor exit. Re-entering the editor from pre-send — `e` (re-edit), `s` (spell check), or `i` (AI handoff) — fired a second `editorDoneMsg` with the flag already false and rebuilt `pendingSend` from scratch, silently dropping `replyToUID`/`replyToFolder` **and** the `In-Reply-To`/`References` threading headers. Result: the original email was never marked `\Answered` (no `·` dot in the inbox, in neomd or any other client) and the reply didn't thread in the recipient's mail client. `pendingIsReply` is now session-scoped: it survives every editor round-trip and is cleared only when the compose session actually ends — send, discard confirm (`y`), editor abort/error/empty body, or launching a new compose/forward. Regression test `TestEditorDoneReplyTrackingSurvivesReEdit` simulates two consecutive editor exits and asserts `replyToUID` + `In-Reply-To` survive the second one
6+
- **`AGENTS.md` feature-invariant checklist** — new repo-root file listing the key user-visible behaviors that must not break (reply dot, threading headers, MIME shapes, screener priority order, pre-send round-trip guarantees, security checks, …), each with code anchors and the test that pins it. Written for AI-assisted development: scan it before changing related code, re-scan after, and add an entry when shipping a new feature. `CLAUDE.md` now points to it and requires a `CHANGELOG.md` entry at the end of every user-visible change
7+
8+
# 2026-07-06
9+
10+
- **Fix: replies to `AW:` / `SV:` / `VS:` / uppercase-`RE:` subjects now get `\Answered` tracking** — reply detection used to sniff the composed subject for a lowercase-normalized `re:` prefix at editor exit; replying to a German `AW:`, Swedish/Danish `SV:`, or Finnish `VS:` thread produced a subject that never matched, so the original was not marked `\Answered` and the threading headers were skipped. Replaced subject sniffing with an explicit `pendingIsReply` flag set when `r`/`ctrl+r` launches the reply editor (forwards and new mail never set it). Note: the flag was initially consumed on first editor exit, which broke reply tracking for pre-send round-trips — fixed 2026-07-09
11+
12+
# 2026-07-02
13+
14+
- **HTML signature images embedded automatically (CID)** — remote `<img src="https://...">` references in the outgoing HTML part (typically the logo in an HTML signature) are now downloaded at send time and embedded as `cid:` inline MIME parts, so Gmail and every other client that blocks external images still renders the signature image. New second rewrite pass `imgSrcHTTPRe` in `buildMessageWithBCC` (`internal/smtp/sender.go`) after the existing local-path pass; `fetchRemoteImage` uses a 10-second HTTP timeout so a slow CDN can't hang the send, MIME type comes from `Content-Type` with `http.DetectContentType` fallback, and any fetch failure leaves the original URL in place instead of failing the send
15+
- **Fix: `ctrl+b` CC/BCC edit from pre-send no longer drops a composed body** — toggling Cc/Bcc with `ctrl+b` on the pre-send screen switched to the compose form, and advancing through the fields would fall through to `stepSubject` and relaunch the editor from the compose fields — discarding the already-written body. New `fromPresend` flag on the compose model: To/Subject are pre-filled for context, advancing past Bcc now patches `cc`/`bcc` directly into `pendingSend` and returns straight to pre-send, and `esc` cancels back to pre-send unchanged
16+
17+
# 2026-06-22
18+
19+
- **`make syncthing-tunnel`** — starts Syncthing on the headless server (if not already running) and opens an SSH tunnel to its web UI at `http://localhost:8385`; companion `run-syncthing` target in `scripts/headless-server/Makefile` for starting Syncthing in the background on the server itself
20+
21+
# 2026-06-15
22+
23+
- **Docs: tagline refresh** — README and docs-site landing page reworded around "write in Neovim, render as Markdown, screen senders first, organize emails once"
24+
325
# 2026-06-13
426

527
- **Per-account signatures** (thanks [@notthatjesus](https://github.com/notthatjesus)) — each `[[accounts]]` entry can now carry its own `[accounts.signature_block]` table with `text` and `html` fields, overriding the global `[ui.signature_block]` for that account. Personal account → casual Markdown blurb (`text` only, goldmark renders it into the HTML part); business account → `text = """[html-signature]"""` placeholder + a styled `html` table signature; send-only aliases → no block, falls back to the global block (or legacy `[ui].signature` for `text`). Resolution lives in `Config.Signature(account)` (`internal/config/config.go`); every signature consumer in `model.go` — compose prelude in `launchEditorCmd`, draft re-open, AI handoff, pre-send HTML preview, SMTP send path — now passes the active account so the resolved signature follows whichever account `ctrl+f` lands on. **The block is all-or-nothing:** once you create `[accounts.signature_block]`, populate every field you care about — fields do not individually merge with the global block, so setting only `html` leaves the editor without an `[html-signature]` placeholder and the HTML never gets injected at send time. Unit test `TestSignature` pins the resolution order; docs at `docs/content/docs/configuration/_index.md` (Per-Account Signatures section with both shapes worked) and `docs/content/docs/sending.md` (cross-link). Cherry-picked from @notthatjesus's signatures-and-folders branch — the per-account folder override half of the original PR was intentionally left out, as neomd's GTD/HEY-Screener folder set is generic by design and per-account overrides would add complexity around virtual folders like Drafts and Spam

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ Folder operations prefer RFC 6851 MOVE; `u` undo uses UIDPLUS destination UIDs c
9898

9999
## Project-Specific Conventions
100100

101+
- **Check `AGENTS.md` before and after changes** — it lists the user-visible feature invariants that must not break (reply `·` dot, threading headers, MIME shapes, screener priority, pre-send round-trip guarantees, …) with code anchors and pinning tests. Scan the relevant section before touching related code, re-verify after, and add an entry when shipping a new feature.
102+
- **Always update `CHANGELOG.md` at the end of every user-visible change** — dated `# YYYY-MM-DD` heading (newest first), bold title, what/why/where, and the regression test name.
101103
- **Keep diffs minimal** — fix the specific thing asked; do not refactor adjacent code.
102104
- **Avoid modifier keys for new bindings** — user's tmux prefix is `C-t`, and `ctrl+a`/`ctrl+e` collide with bubbles textinput line-start/end. Prefer plain letters, especially on the pre-send screen.
103105
- **Inline markers must be visible plain text** — use `[attach] /path`, never HTML comments (hidden by treesitter in the neovim compose buffer).

internal/ui/compose.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type composeModel struct {
2626
subject textinput.Model
2727
step composeStep
2828
extraVisible bool // ctrl+b toggles Cc+Bcc together; off by default
29+
fromPresend bool // true when ctrl+b was pressed from the pre-send screen (CC-only edit)
2930

3031
// Address autocomplete
3132
knownAddrs []string // all addresses from screener lists (set once)
@@ -70,6 +71,7 @@ func (c *composeModel) reset() {
7071
c.subject.Reset()
7172
c.step = stepTo
7273
c.extraVisible = false
74+
c.fromPresend = false
7375
c.to.Focus()
7476
c.cc.Blur()
7577
c.bcc.Blur()
@@ -254,6 +256,9 @@ func (c composeModel) advanceField() (composeModel, tea.Cmd, bool) {
254256
c.bcc.Focus()
255257
return c, nil, false
256258
case stepBCC:
259+
if c.fromPresend {
260+
return c, nil, true // signal: done editing CC/BCC, return to pre-send
261+
}
257262
c.step = stepSubject
258263
c.bcc.Blur()
259264
c.subject.Focus()

0 commit comments

Comments
 (0)