This file lists the key user-visible behaviors that must not break. It exists because
subtle regressions have slipped in before (e.g. the · reply indicator silently broke when
reply detection was refactored — see CHANGELOG 2026-07-09).
How to use this file (for AI agents and humans):
- Before changing code, scan the section(s) that touch your area and keep those invariants intact.
- After changing code, re-scan the same section(s) and confirm each invariant still
holds — run the pinning test if one is listed (
go test ./internal/... -run TestName). - When adding a feature, add its invariant here (one bullet: behavior, code anchor, pinning test).
- At the end of every user-visible change, add a
CHANGELOG.mdentry (dated heading, bold title, what/why/where, test name).
Build commands, architecture, and API quirks live in CLAUDE.md. Feature docs live in
README.md and docs/content/docs/. This file is only the "do not break" list.
neomd is used for business email: a mangled recipient, subject, attachment name, or
leaked Bcc reaches real clients. The hardening suite is the safety net that catches
this class of regression. After any change touching internal/smtp, internal/imap,
internal/schedule, internal/contacts, or the send path in internal/ui, run:
go test ./... -run Hardening # unit: full build→wire→parse-back, no network
make test-integration # live: real SMTP+IMAP fidelity (demo account)What it pins (all byte-exact, not substring checks):
internal/imap/roundtrip_hardening_test.go— every message the builders produce is parsed back with go-message (what the recipient's client does) andparseBody(what neomd itself does): From/To/Cc/Subject decode exactly, plain part before HTML, body lines survive quoted-printable (umlauts), attachment names AND bytes identical (0–255 binary fixture), Message-ID uses sender domain, threading headers only on replies (never duplicated), drafts keep Bcc + literal markdown + attachments, send-later delivers byte-identical messages with noX-Neomd-*leak — except the Date header, which the daemon stamps with the ACTUAL delivery time viaschedule.RewriteDate(changing any other byte fails the test). Also: long multi-encoded-word subjects and emoji decode back exactly (TestHardening_RoundTrip_SubjectExtremes), 20+ recipients keep order and count (_ManyRecipients), body edge cases survive — two-space hard breaks, lone.and--lines, header-lookalike lines, 2000-char lines, CRLF input, no wire line ends in literal whitespace (_BodyEdgeCases), inline image + file attachment combined shape with byte-exact payloads both views (_InlineImagePlusAttachment), wire format — strict CRLF, ≤998-char lines, parseable Date, unique Message-IDs (TestHardening_WireFormat), and emoji reactions parse back with exact To/threading/body (_ReactionMessage).TestHardening_HeaderInjection— CRLF in subject/recipients/threading IDs and hostile attachment filenames ("/newline) can never smuggle headers (sanitizeHeaderValue,sanitizeFilenameParamininternal/smtp/sender.go; control-char rejection incontacts.Add).internal/ui/workflow_hardening_test.go— WORKFLOW-level: drives the real bubbletea Update handlers end-to-end (reply-all → realneomd-*.mdcompose file → editorDoneMsg → pre-send → enter) and delivers to a fake in-process TLS SMTP server, asserting only what the outside world sees (auth user, MAIL FROM, RCPT TO, delivered wire bytes). This catches composition/wiring bugs that per-function tests can't (e.g. swapped cc/bcc arguments insendEmailCmd= Bcc leak — verified by mutation test).TestHardening_Workflow_ReplyAllUsesReceivingAccount: reply goes out via the account whose address received the email (auth + MAIL FROM + From header), reply-all Cc excludes every own address (logins, account Froms, sender aliases), auto_bcc reaches RCPT but never headers, threading headers point at the original,·-indicator data survives.TestHardening_Workflow_MarkdownFileToWire: a real markdown compose file (# [neomd: ...]headers,[attach],[html-signature], signature block) arrives with To/Cc/Bcc routing, umlaut subject, literal-markdown plain part, rendered HTML (bold/link/callout), text sig in both parts, HTML sig in HTML only, attachment bytes identical, and no internal marker delivered.internal/ui/send_hardening_test.go— RCPT TO is complete (To+Cc+Bcc), deduped, bare addresses only, and unchanged by contact-name decoration; messy input (double/trailing commas, whitespace) never yields empty or malformed RCPT entries (TestHardening_RcptNoEmptyOrMalformedEntries); auto_bcc merges case-insensitively against decorated forms and reaches RCPT exactly once (TestHardening_AutoBccPipeline).internal/integration_hardening_test.go(live) — draft attachment round-trip under its original filename with identical bytes (the 2026-08 rename incident), full send fidelity through a real server (umlaut subject + binary attachment, no Bcc/X-Neomd header on the delivered message), scheduled-queue APPEND/FETCH round-trip, reply threading through the server (delivered In-Reply-To/References match the original's real Message-ID, envelope view included —TestIntegration_Hardening_ReplyThreadingThroughServer), and body fidelity as the recipient decodes it (hard breaks, dot-stuffed.line, 1200-char line, umlauts/emoji —TestIntegration_Hardening_BodyFidelityThroughServer).
When you add a new field or path to outgoing messages, extend the round-trip suite in the same commit — a field that isn't parse-back-asserted is a field that can silently break.
Hardening assertions may only be extended, never weakened. If a hardening test fails after a code change, the default assumption is that the CODE broke a user-visible contract — investigate the code first. Relaxing, deleting, or rewriting a hardening assertion to make a change pass requires the user's explicit approval in that conversation; "the test was too strict" is not a decision an agent makes alone.
·reply indicator — after sending a reply, the original email gets the IMAP\Answeredflag (MarkAnsweredininternal/imap/client.go, called fromsendEmailCmdininternal/ui/model.go) and the inbox shows·(or·╰inside a thread,internal/ui/inbox.go). The local flag also updates immediately onsendDoneMsgwithout a refetch. Tests:TestSendDoneMsgUpdatesAnsweredFlag,TestReplyIndicatorWithThread.- Reply tracking survives pre-send round-trips —
pendingIsReplyis session-scoped: re-edit (e), spell check (s), AI handoff (i), and CC/BCC edit (ctrl+b) from pre-send must all preservereplyToUID/replyToFolderand theIn-Reply-To/Referencesheaders. The flag is cleared only when the compose session ends (send, discard, editor abort/error/empty, new compose/forward). Test:TestEditorDoneReplyTrackingSurvivesReEdit. - Threading headers on every reply-ish send — regular replies, emoji reactions
(
ctrl+e), and iCalendar RSVPs all setIn-Reply-To+Referencesso conversations thread in Gmail/Outlook/Apple Mail. Tests:TestBuildReactionMessage_ThreadingHeaders,TestBuildRSVPMessage_ThreadingHeadersBracketed. - Reply prefix handling —
Re:is prepended only when the subject doesn't already start withre:/aw:/sv:/vs:(case-insensitive); localized prefixes are treated as replies, never double-prefixed. Test:TestHasReplyPrefix. - Reply From auto-selection — replying picks the From address matching the email's
To/CC; in the Sent folder the user's own address is in
Frominstead (matchFromForReply,internal/ui/model.go). - Reply-all excludes all own addresses — both IMAP login addresses (
account.User) and send-as addresses (accounts +[[senders]]aliases) are stripped from CC. Test:TestReplyAllExcludesAllOwnAddresses. - Threaded inbox rendering — threads grouped via
In-Reply-To/Message-IDwith subject+participant fallback,│/╰connectors, newest on top; the Sent folder is intentionally not threaded. Tests:TestNormalizeSubject,TestParticipantMatch. - Sender view (
V) — from the inbox list, searchesfrom:<addr>(bare address from the selected email) across every configured folder via the sameSearchAllFoldersIMAP infra as/-search, opening results in aSenderoff-tab (internal/ui/search.go:senderAddr,fetchSenderCmd,handleSenderResult).Vwas chosen overE/F— both already bound (E= continue draft in the reader,F= mark as Feed). Tests:TestSenderAddr,TestHandleSenderResultSetsOffTabAndEmails,TestHandleSenderResultNoMatches.
- Pre-send round-trip preservation — every path that re-opens the editor or returns to
pre-send (
e,s,i,ctrl+b, draft continue,:recover) must preserve: body, attachments (re-injected as# [attach]lines — editor body is source of truth), Bcc, selected From, and reply tracking (see above). History: CHANGELOG 2026-04-08, 2026-05-06, 2026-07-02, 2026-07-09 — this area regresses easily. - MIME structure by content (
BuildMessageininternal/smtp/sender.go): no attachments →multipart/alternative; file attachments →mixed > alternative; inline images →related > (alternative + image parts with Content-ID); both →mixed > (related > alt+images) + file parts. Tests:TestBuildMessage*. - Inline images — local
<img src="/abs/path">rewritten tocid:; paths with spaces use theangle-bracket form and URL-decoding before file read; remotehttps://images (HTML signatures) are fetched (10 s timeout) and embedded ascid:, falling back to the URL on fetch failure. Tests:TestBuildMessage_WithInlineImage,TestBuildMessage_InlineImagePathWithSpaces. [attach]markers are visible plain text —# [attach] /path(header form) and[attach] /path(inline form), never HTML comments (treesitter hides them in nvim). Only regular files are accepted (filterValidAttachments); skipped paths are surfaced in the status bar. Test:TestFilterValidAttachments.- BCC privacy — Bcc is excluded from message headers but included in SMTP
RCPT TO; comma-separated recipients are split into individual RCPT commands;auto_bccis deduped and visible (never silent). Test:TestCollectRcptTo. - RFC compliance — Message-ID uses the sender's domain (never
@neomd/@localhost); quoted-printable encodes trailing whitespace before CRLF (=20) so Markdown two-space hard breaks survive SMTP relays; text/plain part comes before text/html. - Signatures — per-account
[accounts.signature_block]overrides the global block all-or-nothing viaConfig.Signature(account); text signature goes to editor + plain part, HTML signature to HTML part only;[html-signature]placeholder controls inclusion per-email and is extracted right before send. Test:TestSignature. - Drafts — saved as plain text only (multipart caused round-trip corruption), keep
Bcc; every compose session is backed up to~/.cache/neomd/drafts/(:recover); discarding unsent mail always asks y/n confirmation. - Draft attachments keep their original filename — continuing a draft writes
extracted attachments into a fresh temp dir under their real basename (never a
mangled
CreateTempname — the sent filename is the path's basename). Duplicates dedupe asname-2.ext; traversal/empty names sanitized (writeAttachmentsTemp,internal/ui/model.go). Test:TestWriteAttachmentsTempPreservesFilename. - Contact-name decoration is headers-only — bare To/Cc addresses with a harvested
contact name become
Name <addr>in message headers at send time, butcollectRcptToalways uses the raw undecorated fields and Bcc is never decorated (BCC privacy + comma-split RCPT must not break). Unsafe names (,<>"), and any part already containing<, are left untouched;first.last@derivation never fires for role mailboxes (contacts.Decorate,contacts.DeriveName). Tests:TestHarvestNameAndDecorate,TestAddRejectsUnsafeNames,TestDeriveName. - Compose autocomplete matches contact names — To/Cc/Bcc suggestions come from
the contacts store (matched by display name OR address, suggested as
Name <addr>) plus screener-list addresses (decorated when the name is known); nil store never panics. TypedName <addr>recipients are harvested into the cache at send/schedule time (harvestTypedRecipients) so a name typed once persists. Tests:TestComposeSuggestions_*,TestHarvestTypedRecipients. - Send-later queue marker is display-only — queued messages are identified by
a peek'd
X-Neomd-Send-Atheader-fields fetch (Email.SendAt,parseSendAtSection) and shown with a[send-later …]subject prefix at render time only; the stored subject/message is NEVER mutated (it is what gets delivered) and GTD mail sharing the Scheduled folder stays unmarked. Tests:TestParseSendAtSection,TestSendLaterPrefix, live assert inTestIntegration_Hardening_ScheduledQueueRoundTrip. - Re-saving a working copy replaces, never duplicates — and never deletes
early — continuing a queued send-later message OR a saved draft via
Etracks the original (requeueininternal/ui/model.go); it is moved to Trash (recoverable) ONLY after the replacement is successfully scheduled, saved, or sent. Regular emails opened withEare NEVER tracked (a received mail must never be trashed by sending an edit of it). Abort/discard/error paths clear the tracking without touching the original; a failed cleanup warns loudly (double-delivery risk). Tests:TestContinueDraftTracksQueuedOriginal,TestScheduleDoneReplacesQueuedOriginal,TestSendDoneReplacesQueuedOriginal,TestSaveDraftReplacesPreviousVersion,TestContinueRegularEmailNeverTracked,TestEditorAbortKeepsQueuedOriginal,TestRequeueCleanupFailureWarns. - Send-later watchdog — the TUI checks Scheduled on startup and every
background sync; queued messages more than 10 min past due (
overdueGrace) raise a red OVERDUE status warning — a down daemon must never silently swallow a scheduled email. Fetch errors are silent (retried next tick). Tests:TestCountOverdueScheduled,TestOverdueScheduledWarns. - Send later never double-delivers — the daemon claims a due Scheduled message
with
\Flaggedbefore SMTP; flagged leftovers are skipped and logged, never retried automatically (processScheduled,internal/daemon/daemon.go). The delivered message and its Sent copy must carry noX-Neomd-*headers (X-Neomd-Rcptcontains Bcc!); messages withoutX-Neomd-Send-Atin the Scheduled folder (GTD items) are never touched. At delivery the Date header is rewritten to the actual send time (schedule.RewriteDate— only that one line may change), so recipients and the Sent copy show when the mail went out, not when it was queued. Tests:TestInjectExtractRoundTrip,TestExtractIgnoresRegularMail,TestSMTPConfigFor,TestRewriteDate. - Callouts —
> [!note]/> [!tip]/> [!warning](with or without space after>) render as styled boxes in the HTML part and as emoji text (no blockquote markers) in the plain part. Tests:TestToHTML_Callout_*,TestFormatCalloutsForPlainText_*. - Listmonk interception — sending to a configured trigger address creates a scheduled
Listmonk campaign instead of SMTP delivery; pre-send shows list IDs + template + delay.
Tests:
TestResolveListIDs,TestResolveTemplateID. - From cycling (
ctrl+f) — SMTP credentials, Sent-folder destination, and From header must all follow the selected identity (accounts first, then[[senders]]aliases). Tests:TestPresendSMTPAccount,TestReactionAutoSelectsCorrectFromAndSMTP,TestSentDraftsIMAPClient_*.
- Priority order — spam > screened_out > feed > papertrail > screened_in; per-address
entries always beat
@domainentries. Tests:TestClassify,TestClassifyForScreen. - Reclassification is atomic — classifying removes the address from ALL conflicting
lists (snapshot/rollback on failure, both files and moved emails). Test:
TestCrossListCleanup_Reclassification. - Empty lists pause screening — TUI and headless daemon both skip auto-screening until
the first sender is classified (prevents sweeping a fresh inbox to ToScreen). Test:
TestScreenInbox_EmptyScreenerLists. - Screener destinations may never be Trash — refuses to run otherwise. Test:
TestValidateScreenerSafetyRejectsTrashDestination. - ToScreen sender-level classify — acting on one unmarked message applies to all queued mail from that sender.
- Lists are line-based with
#comments (full-line and inline); daemon only reads lists and moves mail, never writes classifications. - External classify goes through
neomd screen(cmd/neomd/screen.go) — the CLI subcommand for widgets (omarchy bar plugin) must keep TUI parity: list update BEFORE any move, sender-level expansion over all queued ToScreen mail, and theValidateScreenerSafetyTrash gate. Its read-only siblingsneomd list(cmd/neomd/list.go) andneomd read(cmd/neomd/read.go) must never mutate flags or folders —readgoes throughFetchBody'sBODY.PEEK, so a widget glance can never set\Seen. All three always emit one JSON object and exit 0 even on failure. Tests:TestRunScreen_ApproveMovesAllFromSender,TestRunScreen_RefusesTrashDestination,TestRunList_JSONShape,TestRunRead_JSONShape.
-
Rows never overflow the terminal width — complex scripts (Bengali/Arabic/Thai/emoji) collapse to
·for display only; CJK passes through (East Asian Wide is deterministic); the original subject is never mutated (reply/forward/thread logic uses the real RFC subject). Tests:TestRowFitsTerminalWidth,TestDisplaySafe. -
Indicator columns — unread,
·replied,°spy pixel,│/╰thread connectors. -
Undo (
u) — reverses the last move/delete using UIDPLUS destination UIDs captured on move; batch operations preserve partial-undo info on failure. Integration test:TestIntegration_IMAPMoveAndUndo. -
Search matches contact names —
internal/contactsharvestsName <addr>pairs from loaded headers into~/.cache/neomd/contacts; the local/filter appends resolved names to its haystack, and server-side search (space /) expands a name query into per-address queries (never forsubject:), deduped by folder+UID. Envelope To/CC/BCC keep display names (formatEnvelopeAddr) — names that would break comma-splitting fall back to the bare address. Tests:TestFormatEnvelopeAddr,TestExpandSearchQueries,TestContactNamesForResolvesBareAddresses. -
The user's
[contacts]file is read-only —contacts.MergeFileonly reads; neomd persists exclusively to its own cache (config.ContactsCachePath()), so the cache can be deleted anytime and rebuilds from harvesting + the file. The picker (space c,internal/ui/contacts_picker.go) copies via external clipboard tools and never mutates the store. Tests:TestMergeFileGoogleCSVRealExport,TestContactsPickerFilterAndSelect.
- Spy pixels blocked — two layers: curated denylist with attribution
(
internal/imap/tracker_list.go) + generic 1×1 heuristic; glamour never fetches remote resources; results cached in~/.cache/neomd/spy_pixels(+keyspy /-keyclean). Tests:TestSpyPixelDetection,TestSpyPixelSpacersNotFlagged. - Browser view (
O) injects CSP —script-src 'none'; frame-src 'none'; object-src 'none'; remote images intentionally allowed there. Test:TestIntegration_BrowserSanitization. - Link opening whitelist — only
http://,https://,mailto:schemes. Test:TestURLSchemeValidation. - Attachment open safety — executable extensions are saved but never auto-opened;
magic-byte mismatch detection (
http.DetectContentType) blocks disguised files; sender-supplied filenames are sanitized against path traversal (.., separators) in every write path (downloads,.ics,cid:temp files). - Timer-based mark-as-read — opening an email marks
\Seenonly aftermark_as_read_after_secs(default 7 s); quick peeks stay unread; reply/forward marks immediately.
- Retry policy —
withConnRetry(one retry) only for read-only ops (FETCH/SEARCH/ STATUS); mutating ops (MOVE/APPEND/STORE) usewithConn, never retried (duplicate-mail risk). NOOP health probe after 2+ min idle handles suspend/resume. safeGoeverywhere — background goroutines must usesafeGo()(panic →~/.cache/neomd/crash.log), never barego func(). Maps passed to goroutines are snapshotted on the main goroutine first (spy-pixel cache race, CHANGELOG 2026-05-08).- Nothing blocks the bubbletea Update loop — notifications have a 2 s timeout, no DNS
lookups in the send path, background sync never tight-loops on error. Test:
TestSend_TimeoutCannotBlockTUI. imap_disabled = trueaccounts produce nil clients by design — every helper that resolves an IMAP client must skip nil entries (send, Sent-copy,\Answered,:debug, headless). Tests:internal/ui/imap_client_helpers_test.go.
- Desktop notifications are VIP-only and TUI-only — fire solely for senders/domains in
notify.txt(independent of screener categories); the headless daemon never notifies; first run records a UID baseline (state key uses the IMAP folder name, not the UI label) so enabling never floods;[notifications].foldersallowlist matches viaLabelFor(). Tests:TestMaybeNotify_*,TestShouldNotify. - Default theme never drifts —
kanagawamust stay byte-for-byte identical to the pre-theming palette;[theme]overrides merge on top of any built-in. Tests:TestKanagawaDefault, theme override/fallback tests.
- Config validation on load — host:port format, port 1–65535, required fields;
$VAR/${VAR}expansion inuser/password. Tests:TestValidate*,TestExpandEnv. password = "keyring"sentinel — resolved inconfig.Load()so IMAP, SMTP, and[[senders]]aliases all see it; preserved with a warning if the keyring is unavailable. Test:TestUseKeyring.- Secrets never leak — token files/dirs written with restrictive permissions; error
messages never include tokens/passwords. Tests:
TestTokenErrors_NoTokenLeak,TestSaveToken_FilePermissions.
internal/ui/keys.gois the single source of truth — drives the?overlay and the generateddocs/keybindings.md(make docs, runs inmake build). Never hand-edit the markdown tables.- Avoid modifier keys for new bindings — user's tmux prefix is
C-t;ctrl+a/ctrl+ecollide with textinput line-start/end. Prefer plain letters, especially on pre-send. - README.md syncs to the docs site (
scripts/sync-readme-to-docs.shviamake docs).