Skip to content

Latest commit

 

History

History
366 lines (272 loc) · 98.8 KB

File metadata and controls

366 lines (272 loc) · 98.8 KB

Changelog

2026-08-26

  • Sender view (V) — from the inbox list, hit V on any email to see every message from that sender across every folder (Inbox, Sent, Archive, ToScreen, Feed, PaperTrail, ScreenedOut, Waiting, Scheduled, Someday, Spam, Drafts, Trash, Work), not just the current folder. Reuses the existing from: IMAP SEARCH infra (SearchAllFolders) that already powers space+/ search — no new IMAP capability needed. Results open in a Sender off-tab exactly like Search/Everything/Thread; esc closes it. V was picked over E (already bound to "continue draft" in the reader) and F (already bound to "mark as Feed"). New senderAddr, fetchSenderCmd, handleSenderResult in internal/ui/search.go. Tests: TestSenderAddr, TestHandleSenderResultSetsOffTabAndEmails, TestHandleSenderResultNoMatches
  • neomd list caps sender-controlled header fields — From/Subject in the JSON output are now truncated UTF-8-safely at 500 bytes (listHeaderMaxBytes, via the existing truncateUTF8), so a hostile mail with a multi-hundred-KB crafted Subject can no longer inflate the output that widgets (e.g. the omarchy bar plugin) buffer whole in shell variables and cache files. read bodies were already bounded by --max-bytes (default 64 KB). cmd/neomd/list.go. Test: TestRunList_TruncatesHostileHeaders

2026-08-25

  • neomd list + neomd screen subcommands — JSON data source for the omarchy bar plugin — two headless one-shot subcommands so external widgets can read and triage mail without a second IMAP setup. neomd list --folders Inbox,ToScreen,Feed,PaperTrail --limit 15 prints one JSON object ({ok, account, folders:[{name, emails:[{uid, from, subject, date, unread}]}]}, dates RFC 3339 UTC, first IMAP-enabled account) — strictly read-only via FetchHeaders. neomd screen --from <addr> --action in|out|feed|paper classifies a sender exactly like the TUI's I/O/F/P keys: screener list update first (atomic cross-list cleanup via Approve/Block/MarkFeed/MarkPaperTrail), then the sender-level move of ALL queued ToScreen mail from that sender, gated by the same ValidateScreenerSafety Trash check. Both always print JSON and exit 0 on failure ({"ok":false,"error":...}) — a bar widget that gets no JSON has nothing to show but a crash. cmd/neomd/list.go, cmd/neomd/screen.go, wiring in cmd/neomd/main.go. Tests: TestRunList_JSONShape, TestRunList_ResolvesConfiguredIMAPName, TestRunList_FetchErrorJSON, TestRunScreen_ApproveMovesAllFromSender, TestRunScreen_ActionDestinations, TestRunScreen_RefusesTrashDestination

  • neomd read subcommand — one message body as JSON, guaranteed peek — completes the widget data source: neomd read --folder Feed --uid 37112 [--max-bytes N] prints {ok, folder, uid, body, truncated} with neomd's markdown rendering of the mail, truncated UTF-8-safely at --max-bytes (default 64 KB). Goes through FetchBody, which fetches with BODY.PEEK — glancing at a mail in the bar widget can never set \Seen (verified live: message still unread after read). Same JSON-always/exit-0 contract as list/screen. cmd/neomd/read.go. Tests: TestRunRead_JSONShape, TestRunRead_TruncatesLongBody, TestRunRead_FetchErrorJSON, TestRunRead_UnknownFolderErrorJSON

2026-08-24

  • Re-saving a draft replaces the previous version; send-later gets a watchdog — two gaps closed: (1) continuing a saved draft (E) and pressing d again stacked a new copy next to the old one; the requeue-replace mechanism now also covers drafts — the previous version is moved to Trash (recoverable) only after the new save/schedule/send succeeds, while abort/discard/error leave it untouched, and regular emails opened with E are never tracked (an edit of a received mail can never trash the original). (2) If the headless daemon is down, a scheduled email would silently sit in Scheduled past its delivery time; the TUI now checks Scheduled on startup and on every background sync and shows a red ⚠ N send-later email(s) OVERDUE status warning for anything more than 10 minutes past due — an important scheduled email can no longer be swallowed unnoticed. internal/ui/model.go. Tests: TestSaveDraftReplacesPreviousVersion, TestContinueRegularEmailNeverTracked, TestCountOverdueScheduled, TestOverdueScheduledWarns

  • Fix: scheduled emails carried the queue time as their Date, not the send time — a message scheduled at 19:50 for 19:55 arrived with Date: …19:50…, so the recipient's client and neomd's own Sent/reader views showed it as sent at 19:50 (Gmail's list looked correct only because it sorts by received time). The headless daemon now stamps the ACTUAL delivery time into the Date header right before SMTP (schedule.RewriteDate, called in processScheduled) — strictly a single-line rewrite: the hardening suite asserts every other byte of the delivered message stays identical. The Sent copy gets the same corrected Date. internal/schedule/schedule.go, internal/daemon/daemon.go. Tests: TestRewriteDate, extended TestHardening_RoundTrip_SendLaterDeliversIdenticalBytes

  • Rescheduling a send-later email no longer leaves a duplicate — continuing a queued message (gc → open → E) and re-scheduling (l) or sending it (enter) previously left the ORIGINAL copy queued, so the daemon delivered both. neomd now tracks the original (requeue, internal/ui/model.go) and moves it to Trash — recoverable, never expunged — strictly AFTER the replacement is safely stored or sent. Every other session end (editor abort, discard, error, new compose) clears the tracking without touching the original, so no edit path can lose the queued email. If the automatic removal fails (e.g. connection drop between the two operations), a loud red warning tells you to delete the old copy in Scheduled manually. Docs updated (sending.md → Reschedule or Edit a Queued Message). Tests: TestContinueDraftTracksQueuedOriginal, TestScheduleDoneReplacesQueuedOriginal, TestSendDoneReplacesQueuedOriginal, TestEditorAbortKeepsQueuedOriginal, TestRequeueCleanupFailureWarns

  • Compose autocomplete finds people by name — typing "max" or "muster" in To/Cc/Bcc now suggests Max Muster <max@muster.example>: the contacts store (names harvested from email headers + optional [contacts] file / Google CSV export) is a suggestion source alongside the bare screener-list addresses, matched by display name OR address; screener addresses with a known name are shown decorated, duplicates removed. And names now persist without a contacts file: user-typed Name <addr> recipients are harvested into ~/.cache/neomd/contacts at send/schedule time (harvestTypedRecipients), so a name written once autocompletes forever — Bcc names too (the cache is local-only, nothing leaves the machine). internal/ui/compose.go, internal/ui/model.go. Tests: TestComposeSuggestions_MatchContactName, TestComposeSuggestions_NilStoreSafe, TestComposeSuggestions_MultiRecipientInsert, TestHarvestTypedRecipients

  • [send-later …] marker separates queued mail from GTD mail in Scheduled — send-later messages share the Scheduled folder with manually-moved GTD emails and were indistinguishable. Header fetches now additionally peek the X-Neomd-Send-At header field (Email.SendAt, zero for regular mail), and the inbox list prefixes queued rows with [send-later Aug 25 09:00] — display-only: the stored message is byte-identical to what gets delivered, and GTD mail stays unmarked. Verified against a real server (FetchHeaders surfaces the queue time after APPEND). internal/imap/client.go, internal/ui/inbox.go. Tests: TestParseSendAtSection, TestSendLaterPrefix, extended TestIntegration_Hardening_ScheduledQueueRoundTrip

  • Workflow hardening: end-to-end send-pipeline tests against a fake SMTP server — new internal/ui/workflow_hardening_test.go closes the "every function correct, composition wrong" hole: instead of testing helpers, it drives the real bubbletea Update handlers through the whole user flow — launchReplyWithCC writes the actual neomd-*.md compose file, the test "edits" it like a user in nvim, editorDoneMsg and the pre-send enter key go through the real handlers, and the mail is delivered to a fake in-process TLS SMTP server (self-signed cert; neomd's existing loopback insecure-retry means zero production code changes). Assertions cover only what the outside world observes: SMTP auth user, MAIL FROM, the complete RCPT TO list, and the delivered wire bytes parsed back with go-message — so any internal refactor is free, and a failure means a real recipient would have seen something wrong. TestHardening_Workflow_ReplyAllUsesReceivingAccount pins multi-account reply-all: the reply authenticates and sends through the account whose address received the email, reply-all Cc strips every own address (IMAP logins, account Froms, [[senders]] aliases), auto_bcc reaches RCPT TO but never a header, threading headers point at the original, and sendDoneMsg keeps the ·-indicator data. TestHardening_Workflow_MarkdownFileToWire pins the markdown-first promise: a real compose file with # [neomd: to/cc/bcc/subject] headers, markdown body (bold/link/callout/umlauts), [attach] line, [html-signature] marker, and signature block arrives with exact To/Cc/Bcc routing, literal-markdown plain part, rendered HTML part, text signature in both parts, HTML signature in HTML only, byte-identical attachment under its original name, and no internal marker delivered. Verified by mutation testing: swapping the cc/bcc arguments in sendEmailCmd (the exact Bcc-leak bug class) fails both tests immediately. Also new in AGENTS.md: hardening assertions may only be extended, never weakened, without explicit user approval

  • Hardening suite round 2: subjects, recipients, body edges, threading through the wire — the byte-exact safety net now also pins the fidelity classes the first round left open. Unit side (internal/imap/roundtrip_hardening_test.go): long multi-encoded-word subjects with umlauts/emoji decode back exactly (TestHardening_RoundTrip_SubjectExtremes); 20 To + 10 Cc recipients survive with order and count intact (_ManyRecipients); the classic QP/transport mangling class — Markdown two-space hard breaks, lone . and -- lines, header-lookalike lines, From -prefixed lines, 2000-char lines, CRLF input — arrives byte-exact and no wire line ever ends in literal whitespace (_BodyEdgeCases); the combined inline-image + file-attachment shape (mixed > related) delivers both payloads byte-exact in the recipient's view and neomd's own parseBody (_InlineImagePlusAttachment); strict wire format — CRLF-only line endings, ≤998-char lines, parseable Date, unique Message-IDs across builds (TestHardening_WireFormat); and ctrl+e emoji reactions (instant sends, no pre-send review) parse back with exact To/threading/body (_ReactionMessage). UI side (internal/ui/send_hardening_test.go): messy recipient input (double/trailing commas, whitespace-only segments) can never produce an empty or malformed SMTP RCPT entry (TestHardening_RcptNoEmptyOrMalformedEntries), and auto_bcc dedupes case-insensitively against decorated Name <addr> forms and reaches RCPT exactly once (TestHardening_AutoBccPipeline). Live side (internal/integration_hardening_test.go): a reply built with threading headers is sent through the real server and the delivered In-Reply-To/References must equal the original's actual Message-ID — both on the raw wire and in neomd's envelope view that drives thread grouping and the · indicator (TestIntegration_Hardening_ReplyThreadingThroughServer); and body fidelity decoded the way a recipient's client does it — hard breaks keep trailing spaces, the dot-stuffed . line survives SMTP, a 1200-char line survives QP soft breaks, umlauts/emoji intact (TestIntegration_Hardening_BodyFidelityThroughServer). All 5 hardening integration tests verified green against the Hostpoint demo server; full unit suite green

  • Hardening suite: byte-exact protection for the send/IMAP core — new test layer that any severe refactor must survive, run with go test ./... -run Hardening + make test-integration (see the new "Hardening Suite" section at the top of AGENTS.md). Unit side (internal/imap/roundtrip_hardening_test.go, no network): every built message is parsed back with go-message and neomd's own parseBody, asserting From/To/Cc/Subject decode exactly, body lines survive QP (umlauts), attachment names and bytes are identical (full 0–255 binary fixture), drafts keep Bcc + literal markdown, send-later delivers byte-identical messages, and threading headers appear only on replies. Live side (internal/integration_hardening_test.go): draft-attachment round-trip under the original filename (the exact rename incident from 2026-08-23), full SMTP→server→IMAP fidelity with umlaut subject + binary attachment and no Bcc/X-Neomd header on the delivered message, scheduled-queue round-trip — all 21 integration tests verified green against the Hostpoint demo server. Three real defenses were added while writing the suite: header-injection sanitization (sanitizeHeaderValue strips CR/LF from From/To/Cc/threading values — a harvested contact name or forwarded header could otherwise smuggle a Bcc:), hostile attachment filenames neutralized (sanitizeFilenameParam: quotes/newlines in a sender-chosen filename can no longer break MIME part headers when forwarding), and contacts reject control characters (contacts.Add). Bonus fix: References chains are never duplicated when a broken sender includes its own Message-ID (buildRefChain). Tests: TestHardening_* across internal/imap, internal/ui, internal/contacts, internal/integration_hardening_test.go

  • Contacts picker (space c) — browse the merged address book (harvested names + [contacts] file) from the inbox: / filters by name or address, j/k move, y copies the address to the clipboard (wl-copy/xclip/xsel/pbcopy fallback chain — first clipboard integration in neomd), Y copies Name <addr>, enter starts a compose with the contact as To. New internal/ui/contacts_picker.go + contacts.Store.All(). Docs clarify the file relationship: the user's [contacts] file is read-only for neomd (only the ~/.cache/neomd/contacts cache is ever written; delete it anytime — it rebuilds from harvesting + the file). Test: TestContactsPickerFilterAndSelect

  • Send later — new l key on the pre-send screen prompts for a delivery time (+2h, 17:30, tomorrow 09:00, 2026-08-25 17:30) and queues the fully built message in the Scheduled folder with two extra headers: X-Neomd-Send-At (RFC 3339) and X-Neomd-Rcpt (the complete RCPT list incl. Bcc — both stripped before delivery, so the Sent copy stays Bcc-clean). The headless daemon (neomd --headless, e.g. on an always-on server) delivers due messages on its existing sync cycle: claim with \Flagged → SMTP send (account resolved from the From header, accounts first then [[senders]] aliases) → copy to Sent → delete from Scheduled. The \Flagged claim means a crash mid-send can never deliver twice — a flagged leftover is skipped and logged (unflag to retry). Deleting the message from Scheduled cancels the send; mail without the header (GTD items in that folder) is never touched; Listmonk-trigger recipients are rejected (Listmonk schedules itself). New internal/schedule package. Tests: TestParseSendAt, TestInjectExtractRoundTrip, TestExtractIgnoresRegularMail, TestSMTPConfigFor

  • Contacts: optional user file + derived names[contacts] file = "~/.config/neomd/contacts.csv" merges a user-maintained address book into the harvested cache at startup; accepts simple lines (addr,name, addr<TAB>name, Name <addr>, # comments) or an unmodified Google Contacts CSV export (contacts.google.com → Export → Google CSV): First/Middle/Last name columns, :::-separated multi-address cells, multi-line quoted Notes fields, and a UTF-8 BOM are all handled — verified against a real 67-address export (100% capture, TestMergeFileGoogleCSVRealExport pins the format; TestRealImportManual is an opt-in smoke test via NEOMD_CONTACTS_FILE). Additionally, first.last@domain shaped addresses get a derived display name ("Example Name") as a fallback — used for search matching and outgoing To/Cc decoration; role mailboxes (info., no-reply., support. …) and single-segment or digit-containing local parts are never derived. Tests: TestMergeFileSimpleFormat, TestMergeFileGoogleCSV, TestDeriveName, TestDecorateFallsBackToDerivedName

  • Fix: continuing a draft no longer mangles attachment filenames — re-editing a saved draft extracted its MIME attachments to temp files created with os.CreateTemp(dir, "draft-"+name+"-*"), so Offer.pdf became draft-Offer.pdf-718599635 — and since the sent filename is derived from the attachment path's basename, the mangled name was what the recipient received. writeAttachmentsTemp (internal/ui/model.go) now creates one fresh draft-attachments-* directory per draft and writes each attachment under its original basename (duplicates deduped as name-2.ext, path traversal and empty names still sanitized). Regression test TestWriteAttachmentsTempPreservesFilename

  • Recipient display names kept in To/CC/BCC — envelope parsing (FetchHeaders / FetchHeadersByUID, internal/imap/client.go) previously dropped display names from To/CC/BCC (a.Addr() only), so the Sent tab showed bare addresses and the local / filter could never match a recipient's name. New formatEnvelopeAddr renders Name <addr> when a display name is present; names containing ,/</>/" fall back to the bare address so naive comma-splitting (SplitAddrs, RCPT extraction) never breaks. Test TestFormatEnvelopeAddr

  • Contacts cache: search by name, names on outgoing mail — neomd sends carried only bare addresses (you type l@domain.io, not Louise Nachname <l@domain.io>), so searching "louise" in Sent found nothing — the name simply isn't in any header IMAP SEARCH or the local filter can see. New internal/contacts package harvests every Name <addr> pair from loaded headers (From/To/CC on each folder load) into ~/.cache/neomd/contacts (addr\tname lines, atomic 0600 writes, saved via safeGo off the Update loop). Three consumers: (1) the local / filter appends resolved contact names to its haystack, so a bare address matches its person's name in both Inbox and Sent; (2) server-side search (space /) expands a name query into up to 3 extra per-address queries (expandSearchQueries, from:/to: prefixes preserved, subject: never expanded), results deduped by folder+UID; (3) at send time, bare To/Cc addresses with a known contact are decorated to Name <addr> in the headers only (RFC 2047 Q-encoding for non-ASCII names) — collectRcptTo keeps using the raw fields and Bcc stays undecorated, so BCC privacy and RCPT splitting are untouched. Future sent mail therefore carries real names and is searchable by name from any client. Tests: TestHarvestNameAndDecorate, TestAddRejectsUnsafeNames, TestAddrsMatchingNameAndPersistence, TestExpandSearchQueries, TestContactNamesForResolvesBareAddresses

2026-07-09

  • 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
  • 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

2026-07-06

  • 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

2026-07-02

  • 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
  • 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

2026-06-22

  • 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

2026-06-15

  • Docs: tagline refresh — README and docs-site landing page reworded around "write in Neovim, render as Markdown, screen senders first, organize emails once"

2026-06-13

  • Per-account signatures (thanks @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

2026-05-27

  • CJK subjects (Korean / Japanese / Chinese) now render as glyphs in the inbox list (#22, thanks @donny-son) — the 2026-05-19 width-safety fix was overzealous: it collapsed every non-Latin script to ·, including CJK, even though Hangul, Han, and kana are uniformly East Asian Wide (2 cells per code point) and both terminals and runewidth agree on their cell width. The original concern (terminal-vs-application disagreement on grapheme-cluster width — see lipgloss #562) only really applies to scripts with cluster ambiguity: Bengali, Devanagari, Thai, Arabic, Hebrew, emoji. safeForDisplay (internal/ui/inbox.go) now whitelists twelve CJK ranges so Korean / Japanese / Chinese subjects show their actual glyphs in the inbox row and in the reader's header box — the displaySafe transform still collapses Bengali / Arabic / Thai / emoji runs to a single · placeholder, so the inbox-list width invariant (verified by the pre-existing TestRowFitsTerminalWidth, which already exercised Korean and Japanese subjects at widths 80/120/190) is preserved. New ranges added to the safe whitelist: Hangul Jamo (U+1100-11FF), CJK Symbols & Punctuation (U+3000-303F), Hiragana (U+3040-309F), Katakana (U+30A0-30FF), Hangul Compatibility Jamo (U+3130-318F), CJK Unified Ideographs Extension A (U+3400-4DBF), CJK Unified Ideographs (U+4E00-9FFF), Hangul Jamo Extended-A (U+A960-A97F), Hangul Syllables (U+AC00-D7A3), Hangul Jamo Extended-B (U+D7B0-D7FF), CJK Compatibility Ideographs (U+F900-FAFF), Halfwidth & Fullwidth Forms (U+FF00-FFEF). TestDisplaySafe flipped from one "cjk collapses to single dot" case to three separate "passes through" cases for Japanese, Korean and Chinese. New integration test TestIntegration_SendMultiScriptDisplay sends a single email containing every script (CJK + Bengali + Devanagari + Thai + Arabic + emoji + Latin extended + Cyrillic + Greek) into the demo INBOX and leaves it there, so the inbox-list rendering can be visually verified end-to-end against a real IMAP server

2026-05-21

  • Fix: reply from Sent folder now picks the From address you actually sent from — pressing r on a message in the Sent tab used to default to the first configured From (e.g. simu@sspaeti.com) instead of the address that sent the message (e.g. simon@ssp.sh). Root cause: matchFromIndex only inspected the email's To/CC fields to find a matching configured account — correct in every folder except Sent, where the user's own address lives in From (To is the original recipient). Added a thin folder-aware wrapper matchFromForReply(e) (internal/ui/model.go:5206) that swaps to e.From when activeFolder() == cfg.Folders.Sent; replaced the four call sites (r/ctrl+r/ctrl+e pre-fetch in inbox + authoritative call in launchReplyWithCC). matchFromIndex signature unchanged so non-Sent behavior is byte-identical
  • Fix: attachment path picker can no longer silently attach the wrong file — a stray <Space> press inside the yazi floating window (yazi's default keymap binds <Space> to toggle-selection) was leaving a stale selection on whatever was under the cursor in the launch directory. When you later pressed <Enter> on the file you actually wanted, yazi's --chooser-file writes the selected files, not the hovered (see yazi-actor/src/mgr/open.rs:23-31) — so a directory like aur from the repo root could end up as the attachment. SMTP then failed with is a directory, or in the worst case sent the wrong file's bytes under the right name. Three defenses now stacked:
    • Go-side — new filterValidAttachments(paths) (internal/ui/model.go) os.Stats every path and accepts only regular files. Wired into both entry points where user-provided paths land in m.attachments (the editorDoneMsg post-extraction step and the attachPickDoneMsg picker callback). Skipped paths are surfaced via the status bar in red: ⚠ Skipped N invalid attachment path(s) (not a regular file): <path>. Regression test TestFilterValidAttachments covers directories, missing paths, and a real file
    • Nvim-side (dotfiles)custom.lua's <leader>a helper now fs_stats each path returned by yazi and vim.notifys a warning for any non-file entry, so the bad path never makes it into the buffer
    • Yazi-side (dotfiles) — new ps.sub("cd", ...) in init.lua calls ya.emit("escape", { select = true }) to clear any leftover selection on every cd, but only when rt.args.chooser_file is set (i.e. yazi was launched as a picker — neomd's <leader>a flow). Normal interactive yazi usage is untouched. escape --select (which runs tab.selected.clear(), see yazi-actor/src/mgr/escape.rs) is correct here; toggle_all --state=off would not work because it only touches files in the current tab directory, while the stale selection holds a full URL pointing at the previous directory
  • DocsREADME.md and docs/content/docs/sending.md#attachments now flag the yazi init.lua snippet as required for users whose nvim <leader> is <Space> (lazyvim's default)

2026-05-19

  • Fix: complex-script subjects (Bengali / CJK / Arabic / emoji) no longer break inbox layout — a Bengali spam subject like Re: আপনার দর্শকদের জন্য একটি আকর্ষণীয় বিষয়বস্তু caused the inbox list rows to overflow the terminal width in foot and kitty, which made the bubbles list lose its top row and visibly redraw the size column at a different position as the cursor moved. Root cause is the well-known terminal-vs-application width disagreement for complex grapheme clusters (see lipgloss #562, Mitchell Hashimoto on grapheme clusters in terminals, and the Kitty text-sizing protocol / OSC 66 work) — runewidth and uniseg report widths that foot/kitty don't actually render, so any per-row padding drifts. Since OSC 66 is not widely adopted and tmux strips it, neomd now sanitises the subject (and the From column) for display only: every run of characters outside Latin / Greek / Cyrillic / common punctuation collapses to a single · placeholder before the row is laid out, leaving width measurement deterministic. ASCII, German umlauts/ß, accented Latin, Greek and Cyrillic pass through untouched; combining marks and variation selectors (U+FE05 trailing on werden︅-style subjects) are stripped. Same transform is applied to the Subject: line in the reader header so the rounded-border box no longer wraps and pushes From/To off the top of the viewport. Original e.Subject is preserved on the email object — sanitisation is purely a rendering layer. Regression test TestDisplaySafe covers the script ranges and TestRowFitsTerminalWidth enforces the no-overflow invariant for a Bengali subject. New displaySafe / safeForDisplay helpers in internal/ui/inbox.go. Trade-off accepted: users who want to read non-Latin subjects in the list will see · placeholders — switch to a kitty + OSC 66 setup for full glyphs

2026-05-17

  • Fix: inline images with spaces in their path silently lost — attaching an image whose absolute path contained spaces (e.g. /home/.../Seat Alhambra Limousine/Fhz-Ausweis Seat Alhambra Limousine.jpg) caused both the pre-send browser preview and the sent mail to show the raw ![](/path with spaces.jpg) markdown text instead of rendering the image. CommonMark/goldmark rejects unescaped spaces in a link destination, so the parser left the literal text in place, no <img> tag was produced, and the inline-image rewrite step in internal/smtp/sender.go therefore never fired — the image was dropped from the MIME multipart/related envelope and the recipient just saw the source markdown. Two-line fix: (1) extractInlineAttachments now emits the angle-bracket CommonMark form ![](<path>) so paths containing spaces are accepted as a valid image destination; (2) goldmark percent-encodes the destination on render, so the captured <img src="..."> attribute is now URL-decoded before os.ReadFile reads the file off disk (otherwise the read would fail and the inline part would silently be skipped again). Non-image attachments and image paths without spaces take the exact same code path as before; regression test TestBuildMessage_InlineImagePathWithSpaces covers the full pipeline

2026-05-12

  • Per-trigger Listmonk template override[[listmonk.triggers]] entries now accept an optional template_id = N field that pins the Listmonk template for campaigns created from that trigger; triggers without the field fall through to Listmonk's own default template, so existing configs keep working unchanged. Useful when one virtual address should always use a specialised template (e.g. listmonk-book@ssp.sh → "Book Update Template", ID 5) while the rest stay on the generic newsletter template. The pre-send "Newsletter via Listmonk" review now surfaces the resolved template ID alongside the target list IDs and schedule delay, so it's visible before scheduling. Plumbed through ListmonkTrigger (config), listmonk.Trigger + new ResolveTemplateID (hook), campaignRequest.TemplateID (Listmonk REST payload, omitempty so default triggers send no field at all), and the UI send path. Regression test covers the override/default/no-match cases

2026-05-08

  • Ms move to Sent — added Ms chord to move the cursor or marked email(s) to the configured Sent folder, mirroring the gs (go to Sent) shortcut. Previously every other major folder had a matching M* mover (Mi/Ma/Mt/…) but Sent was missing, so manually filing already-sent correspondence forwarded from another client required : commands or a multi-step copy. Lives in the same M-prefix dstMap as the rest, no behaviour change for other letters
  • / filter searches recipients in Sent folder — the in-memory inbox filter (/) previously matched on From + Subject in every folder. In Sent that meant typing /hussain after sending Hussain an email returned nothing, because From is always you. The filter now matches on To + CC + BCC + Subject whenever the active folder is the configured Sent folder; all other folders are unchanged. The server-side IMAP search (<space>/ / :search) already searches To and is unaffected
  • Review and stability
    • Concurrency: data race in spy pixel cache save eliminatedbodyLoadedMsg and spyScanProgressMsg previously launched safeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) }), evaluating copyMap inside the goroutine — the closure captured the model and read the live maps while the main goroutine continued mutating them on the next message. Both call sites now snapshot the maps on the main goroutine before launching the writer, so copyMap cannot race with subsequent map writes. The // Takes copied maps to avoid concurrent access comment on saveSpyPixelCache was true of the helper signature but the call sites violated the contract; both are now correct
    • Concurrency: Screener is now safe for concurrent use — added sync.RWMutex guarding every map (screenedIn, screenedOut, feed, paperTrail, spam, notify). Auto-screen, background sync, search, notify, and TUI mutation paths all share one *Screener, so concurrent map writes would have panicked at runtime. Classify, ClassifyDebug, IsEmpty, AllAddresses, ShouldNotify, and Snapshot take a read lock; Approve, Block, MarkSpam, MarkFeed, MarkPaperTrail, AddNotify, RemoveNotify, and Restore take a write lock. Refactored Snapshot/Restore into public-locked + private snapshotLocked/restoreLocked variants so the existing transactional rollback inside Approve/Block/etc. doesn't double-lock. RWMutex keeps the hot read path parallel; make test -race clean
    • Security: path traversal in .ics calendar attachment open (<space> v o)filepath.Base("..") returns "..", and filepath.Join("~/.cache/neomd/ical", "..") escapes the cache dir. The previous filter rejected "", ".", and "/" but not "..", so a hostile Content-Disposition: filename of .. would let os.WriteFile(att.Data) overwrite ~/.cache/neomd/. Now also rejects ".." and any embedded path separator, falling back to invite-<unix-ts>.ics when the sender-supplied name is unusable
    • Security: path traversal in attachment download (xdg-open flow) — same root cause in downloadOpenAttachmentCmd: a malicious .eml part with Content-Disposition: filename=".." would write the attachment bytes to ~/Downloads/.. (i.e. ~). This is reached by every reader-driven attachment open, so a hostile sender controlled the destination path. The filter now rejects "", ".", "..", "/", and any embedded path separator, falling back to attachment-<unix-ts>
    • Reliability: ? no longer trapped in compose textinputs — the global ? → help binding fired from any state, including stateCompose. Compose feeds keys into a bubbles/textinput (To/CC/BCC/Subject), which never received ? because the global handler intercepted first. Now gated to all states except stateCompose, so ? types verbatim while composing and still toggles help everywhere else
    • Reliability: extractAddr no longer issues blocking DNS lookups on send — the helper called net.LookupHost for every recipient and From address inside the SMTP send hot path, blocking the bubbletea Update loop on flaky DNS for seconds at a time. The lookup was also functionally pointless: an || short-circuit returned the address whenever it contained @, so DNS only mattered for malformed-but-bracket-shaped strings. Replaced with pure string parsing — extracts the <addr> form when present, otherwise returns the trimmed input. Unused "net" import dropped
    • Reliability: outbound emails preserve trailing whitespace on hard-break lines — RFC 2045 §6.7 requires that any space or tab immediately before a CRLF be encoded as =20 / =09 in quoted-printable. The previous writeQP passed trailing whitespace through verbatim, which many SMTP relays silently strip — mangling Markdown's two-trailing-spaces hard-break syntax (precisely the syntax normalizePlainText adds when extracting plain text from HTML). The encoder now peeks ahead; trailing whitespace before \n or end-of-input is encoded
    • Reliability: bare go calls promoted to safeGosaveCmdHistory (: command-line history persistence) was launched as a bare go func(), bypassing the project's safeGo panic-recovery contract documented in CLAUDE.md. A panic inside the writer would have torn down the TUI without a stack trace. Now wrapped in safeGo, with a snapshot of the history slice taken on the main goroutine before launch so the writer cannot race with the next command-history mutation
    • Reliability: OAuth2 callback server panic recovery + non-blocking sendsrunAuthFlow launched the callback HTTP server as a bare go func(). A panic inside net/http's request handling would have crashed the process during the OAuth2 dance. Goroutine now has a defer recover() that surfaces the panic as an errCh send. Additionally, errCh and codeCh are buffered size 1, but a re-fired callback (browser refresh, double redirect) would block on subsequent sends and leak the handler goroutine; all sends now use a select/default non-blocking pattern
    • Reliability: IMAP retry catches more transient failuresisNetErr previously matched only four substrings (use of closed network connection, connection reset by peer, broken pipe, EOF), missing real-world transient failures: tls: connection lost, i/o timeout, connection timed out, connection refused, no route to host, network is unreachable. Those errors fell through the retry path, surfacing as user-visible failures instead of a clean reconnect. Now uses errors.Is(err, net.ErrClosed | io.EOF | io.ErrUnexpectedEOF) and net.Error.Timeout() typed checks plus a widened substring list covering TLS, syscall, and DNS-layer error formats
    • Reliability: --headless no longer panics when account 0 is imap_disabled = truedaemon.New(*cfg, imapClients[0], sc) blindly passed the first IMAP client to the daemon. Accounts with imap_disabled = true produce a nil client by design; if account 0 happened to be send-only, --headless would crash on the first IMAP call. The launcher now scans imapClients for the first non-nil entry and fails fast with a clear error message ("--headless requires at least one IMAP-enabled account") if no IMAP-enabled account exists, instead of panicking deep in the screening loop

2026-05-06

  • Fix: attachments invisible after pre-send round-trip — re-opening the editor from pre-send via e (re-edit), s (spell-check), or i (AI handoff), or continuing a saved draft, now re-injects tracked attachments as # [attach] /path lines right under the existing # [neomd: ...] headers instead of silently keeping them only in m.attachments. Previously the [attach] lines were extracted on the way to pre-send (correctly — they should never reach the recipient as text) but the editor reopened with the cleaned body, leaving no signal that PDFs/files were attached; users would re-attach and end up with duplicates. extractInlineAttachments now accepts both forms — # [attach] /path (header form, used on re-injection, visually grouped with the metadata headers) and [attach] /path (the form <leader>a continues to insert at cursor position for inline image placement). The editor body is now the source of truth: editorDoneMsg replaces m.attachments with whatever [attach] lines come back, instead of appending — so removing a line in the editor cleanly removes the attachment. For draft continuation the injected paths are the temp-extracted /tmp/neomd/draft-<name>-<random> files written by writeAttachmentsTemp from the saved MIME parts; the user can replace them with the original local paths if those files moved
  • Theming with 6 built-in palettes — pick via [ui].theme = "kanagawa" (default, byte-for-byte identical to pre-theme state), kanagawa-paper, kanagawa-light (only light theme — Lotus palette on a paperwhite #F2EFE9 background), rose-pine, gruvbox, or osaka-jade. Optional top-level [theme] block overrides individual colour slots (primary, unread, error, bg, …) on top of any built-in. ApplyTheme runs in ui.New() before any rendering, so theme switching needs only a config edit + restart. Regression test verifies the kanagawa default does not drift; theme tests cover override merge, unknown-name fallback, and round-trip uniqueness
  • AI handoff key in pre-send (i) — new [ai].command config (default claude) wires any LLM CLI (claude, codex, aichat, sgpt, …) to the pre-send i key. neomd writes the current draft to /tmp/neomd/neomd-ai-*.md with the standard # [neomd: ...] headers, spawns <command> [args...] <path>, and re-reads the file on exit so header and body edits round-trip back into the draft (same parser as the regular editor flow). Quit the AI tool to return to neomd's pre-send screen. Pre-send footer surfaces the active command (i AI (claude, quit to return)). nvim is intentionally not a useful choice here — the compose buffer is already in nvim before pre-send, so spawning nvim on i would just re-edit. Set command = "" to disable the binding
  • iCalendar RSVP + local calendar handoff — emails with a text/calendar part or .ics attachment now show a 📅 summary card in the reader header (event title · date · location). Leader chord <space> v {a|d|t|o} sends an RFC 5546/6047 (iMIP) accept/decline/tentative reply, or opens the .ics in [calendar].open_command (default xdg-open, set to morgen/khal//usr/bin/gnome-calendar to force a specific app). MIME envelope: multipart/mixed > [multipart/alternative > (text/plain + text/calendar;method=REPLY)] + .ics attachment with Subject: Accepted: <event> (matches Gmail's native button format) and bracketed RFC 5322 In-Reply-To headers. RSVPs save to the Sent folder and mark the original invite \Answered. Reliability note: Outlook 365 / Exchange / Apple iCloud / CalDAV servers auto-process iMIP REPLIES server-side; Gmail has deprioritized iMIP processing in 2026, so Gmail organizers may need to manually note your reply (or just use Gmail's native Yes/No button for Gmail-originated invites). New self-contained internal/calendar/ package on top of arran4/golang-ical; new internal/smtp/rsvp.go for the iMIP MIME envelope. Only the first VEVENT is processed; recurring rules, counter-proposals, and cancellations are out of scope
  • OS keyring credential storage (#5, thanks @notthatjesus) — set password = "keyring" in any [[accounts]] block to fetch the IMAP/SMTP password from the OS keyring (macOS Keychain, Linux Secret Service via gnome-keyring/kwallet, Windows Credential Manager) at startup. OAuth2 tokens also persist in the keyring with explicit file fallback for headless/SSH systems where no keyring service is available. Sentinel resolution runs inside config.Load() so every consumer — IMAP at boot, SMTP at send, [[senders]] aliases that reference an account — sees the resolved password automatically without per-call lookups. New internal/keyring/ package with mock-backed tests; storage keys are neomd/account/<name>/{password|oauth2}

2026-05-04

  • Fix: send from imap_disabled = true account no longer panics — sending an email from an account configured as send-only (typically Gmail with imap_disabled = true) crashed the TUI with nil pointer dereference in tokenSourceFor because the helper called .TokenSource() on the intentionally-nil IMAP client. The same nil-deref existed in imapCli, imapCliForAccount, and primaryIMAPClient — any code path that resolved an IMAP client for a send-only account would crash. All four helpers now skip nil entries (and fall back to the first non-nil client where appropriate); sendEmailCmd also guards cli.SaveSent and replyCli.MarkAnswered so a fully send-only configuration silently skips the Sent-folder copy instead of panicking. Four regression tests added in internal/ui/imap_client_helpers_test.go
  • Fix: notify state key keeps IMAP folder name (preserves baselines across upgrades) — yesterday's label-normalisation fix accidentally changed the persisted state key from Personal|INBOX to Personal|Inbox, which would have re-baselined every existing user on upgrade and silently swallowed one round of notifications. MaybeNotify now takes both the IMAP name (used for the state key) and the UI label (used only for the allowlist comparison) so existing notify_state.json files keep working untouched. Regression test added
  • Fix: [notifications].folders allowlist now matches custom IMAP folder names — the allowlist compared user-configured labels (e.g. "PaperTrail") against runtime IMAP folder names (e.g. "HEY/Paper Trail" or "[Gmail]/All Mail") and silently dropped notifications when the two differed. Folders are now normalised to their UI label via a new FoldersConfig.LabelFor() helper before the allowlist check, so users with non-default IMAP folder names get notifications correctly. Regression test added in internal/config/config_test.go
  • Fix: notifier can no longer freeze the TUISend now runs notify-send (or whatever [notifications].command points to) under a 2-second context.WithTimeout plus a 500 ms cmd.WaitDelay. A hung notification daemon (broken DBus, mako restarting, …) returns a clear notify-send: timed out after 2s status instead of blocking the bubbletea Update loop. Test exercises the timeout path with a script that sleeps 60 s
  • Fix: screener lists now strip inline # commentsloadList previously only skipped full-line comments, so @ssp.sh # everyone at ssp.sh (as shown in the docs) stored the entire line as the entry and never matched. The loader now strips everything after the first # (after trimming) so inline comments work as documented in any screener list (screened_in, feed, notify, …)

2026-05-03

  • Desktop notifications for VIP senders — new opt-in [notifications] config block fires notify-send (or any notify-send-compatible command) only for senders explicitly listed in ~/.config/neomd/lists/notify.txt; independent of the screener categories so being "screened in" does not automatically mean "notify me", and a sender on a Feed/PaperTrail list can still page you when their mail arrives. Defaults: enabled = false, command = "notify-send", icon = "mail-message-new", expire_ms = 5000, folders = ["Inbox"]. First fetch silently records a per-folder UID baseline at ~/.cache/neomd/notify_state.json so the existing inbox does not flood you on enable. Status bar reports activity ("Notified N VIP sender(s) in Inbox") so you can verify the pipeline is wired up. TUI-only — the headless daemon never fires notifications so a NAS does not pop popups no one will see. Hooks into background sync (every bg_sync_interval minutes) and into manual folder loads, so notifications also fire when the daemon screens a VIP email out of Inbox before the TUI sees it (the TUI then catches it on the destination folder load). Documented in docs/content/docs/notifications.md including a wrapper-script recipe for hyprctl notify more
  • Whole-domain screening — any screener list line beginning with @ (e.g. @ssp.sh) now matches every address at that domain; works in screened_in.txt, screened_out.txt, feed.txt, papertrail.txt, spam.txt, and notify.txt. Per-address entries always win over a @domain entry across all categories so a single blocked address inside an otherwise-approved domain stays blocked (priority order preserved: spam > out > feed > papertrail > in). New Di / Do chord (works in inbox and reader) appends @<domain> of the cursor or open email's sender to screened_in.txt / screened_out.txt after a y/n confirmation; complements the existing I / O per-address keys. Domain entries can also be added to the other lists by hand-editing — the matching is shared
  • :notify-test (:nt) command — fires a single test desktop notification with the current [notifications] config so you can verify notify-send, the icon theme, and the notification daemon (mako/dunst/swaync) are all working without waiting for a real VIP email to arrive
  • :debug reports notifications — diagnostic report now includes the resolved [notifications] config, the contents of notify_state.json (per-folder baseline UIDs), the path to notify.txt, and the list of non-Inbox folders being polled in the background for VIP mail
  • Visible notify-send errors — the notifier now captures the underlying command's stderr (mako not running, missing icon theme, etc.) and surfaces it on the status bar instead of swallowing the error silently — easier to diagnose when notifications stop firing
  • <space>n / <space>N (reader) — append the open email's sender (exact email, lowercase) or its @domain to notify.txt directly from the reader; instant write with confirmation in the status bar; complements the [notifications] opt-in flow so you can curate the VIP list while reading without leaving neomd
  • Fix: :debug panicked on accounts with imap_disabled = truewriteDebugReport dereferenced the nil IMAP client that imap_disabled accounts intentionally produce, taking the whole TUI down. Now those accounts render as "Name — IMAP disabled (send-only)" in the report instead of crashing
  • Fix: nil-client guard in VIP folder polling — the new background notification poll used to crash if the active tab was an imap_disabled account when the bg sync ticked; now skips the fetch silently and waits for the next tick
  • Notification diagnostics — when a notification doesn't fire, the status bar now explains why instead of staying silent: Notify baseline set for INBOX (UID 604221) on first run; Notify check INBOX: 5 new email(s), 0 from VIPs (baseline UID 604232 → 604239) when nothing matches the notify list; Notify check INBOX: 2 VIP email(s), but destination not in allowlist (folders=[Inbox PaperTrail]) when the VIP mail landed in a folder you didn't list. Removes the guesswork around "did it just not fire?"
  • Fix: notify.txt was never loaded into the screenercmd/neomd/main.go and internal/daemon/daemon.go constructed screener.Config without the new Notify path field, so the in-memory notify set stayed empty even when notify.txt had entries; ShouldNotify always returned false and no notification ever fired. Both call sites now pass Notify: cfg.Screener.Notify. Regression test added so this can't recur

2026-05-02

  • Fix: emoji reaction sent through wrong SMTP accountctrl+e reactions already auto-selected the correct From address (matching whichever of your addresses received the email), but sendReaction() then resolved the SMTP account with a buggy custom check (presendFromI > 0 && presendFromI-1 < len(senders)) that misinterpreted account indices as sender-alias indices in multi-account setups; e.g. mail to simon@ssp.sh (Work, account index 1) while in the Personal inbox would send the reaction header as simon@ssp.sh but authenticate via Personal's SMTP and grab Senders[0]; reactions now use presendSMTPAccount() (same helper as the regular send path), so SMTP credentials, the Sent-folder destination, and the From header all match the address that received the original email

2026-04-30

  • Send-only accounts (imap_disabled = true) — accounts can be marked as send-only by setting imap_disabled = true; neomd skips IMAP connection, folder fetching, and screening for that account; the account remains available as a From address via ctrl+f in compose/pre-send; ctrl+a account cycling skips disabled accounts; useful for adding Gmail or other providers purely for sending without fetching thousands of emails; :debug shows "(imap disabled)" label

2026-04-28

  • Spy pixel blocking — neomd automatically detects and blocks tracking pixels in emails using a two-layer approach (same as HEY): (1) a curated denylist of 150+ tracking services sourced from Simplify (BSD-3-Clause), LeaveMeAlone (CC-BY 3.0), and DHH's original HEY list (MIT) — matches are attributed by service name (e.g. "Mailchimp", "HubSpot", "SendGrid"); (2) a generic 1×1 pixel heuristic (empty alt + tiny dimensions or CSS hiding) catches custom/branded tracking domains not on the list; ° indicator in the inbox list for emails with tracking pixels; reader header shows ° N spy pixel(s) blocked (ServiceName) with tracker attribution; senders cannot tell if you read their email in the TUI since glamour never fetches remote resources
  • Spy pixel scan (<space>S / :scan-spy-pixels) — scan all emails in the current folder for tracking pixels in the background; fetches full UID list from IMAP server, skips already-scanned emails, uses IMAP PEEK (won't mark as read); results cached in ~/.cache/neomd/spy_pixels and persist across restarts; both positive and negative scan results are cached so repeat scans are instant
  • URL scheme whitelist — email links opened via space+digit are now validated; only http://, https://, and mailto: schemes are allowed; javascript:, data:, and other dangerous schemes are blocked with an error in the status bar
  • Dangerous attachment warning — two layers of protection: (1) files with executable extensions (.sh, .exe, .desktop, .bat, .py, .jar, etc.) are saved but not auto-opened; (2) magic-byte verification using Go's net/http.DetectContentType() catches disguised files (e.g. a script renamed to .png is detected as text/plain and blocked); status bar warns about dangerous or suspicious file types
  • Browser view sanitization — pressing O to open email in browser now injects a Content-Security-Policy that blocks JavaScript, iframes, and embedded objects (script-src 'none'; frame-src 'none'; object-src 'none') while allowing remote images
  • Reader space chord hints — pressing space in the reader now shows all available actions (1-0 links, d download .eml, l11-99 links 11+) instead of only link info; space+d for EML download now works even when no links are present
  • Colored attachments in reader — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility
  • Panic recovery — all background goroutines (mark-as-read, spy pixel cache, temp file cleanup) are now wrapped with safeGo() which recovers panics instead of crashing the TUI; panics are logged to ~/.cache/neomd/crash.log with timestamp and full stack trace for post-mortem debugging
  • IMAP connection health check — after 2+ minutes of inactivity (e.g. laptop suspend/resume), neomd probes the connection with IMAP NOOP before the next operation; if the connection is dead, it automatically reconnects — no more manual R refresh needed after sleep
  • IMAP retry for read-only operations — read-only IMAP commands (FETCH, SEARCH, STATUS) automatically retry once after reconnecting on network error; mutating operations (MOVE, APPEND, STORE) are NOT retried to prevent duplicate emails or replayed mutations
  • MIME charset/encoding fallback — emails with unknown charsets (ISO-8859-15, Windows-1256) or unknown transfer encodings no longer fail; neomd continues with raw bytes instead of crashing, matching aerc's graceful degradation pattern
  • Config validation — config is now validated on load: IMAP/SMTP addresses checked for valid host:port format with port range 1-65535, required fields enforced, UI values checked for non-negative ranges; clear error messages instead of silent failures
  • Integration tests for security features — new TestIntegration_SecurityFeatures (disguised attachment + callout) and TestIntegration_BrowserSanitization (CSP script/iframe blocking) send real test emails for live inspection

2026-04-27

  • Mailto handler (--mailto / positional URI) — neomd can now be used as the system default mailto: handler; clicking a mailto: link in any browser opens a foot terminal with neomd in compose mode, pre-filled with To, CC, BCC, Subject, and Body from the URI; supports both neomd --mailto "mailto:user@example.com?subject=Hello" and neomd "mailto:..." (positional, for .desktop integration); registered via xdg-mime with a neomd-mailto.desktop file; after sending or cancelling, neomd continues as normal

2026-04-24

  • Disable threading in Sent folder — the Sent tab now shows each email individually without thread grouping, ordered by date; threading remains active in all other folders; useful because Sent emails are your own outgoing messages and don't benefit from conversation grouping

2026-04-23

  • Download raw email source (space+d in reader) — saves the full raw MIME source as .eml to ~/Downloads/ with filename neomd-YYYYMMDD-<subject>.eml; useful for archiving, debugging email headers, or importing into other clients; status bar shows download progress and completion; filenames deduplicated automatically
  • Listmonk newsletter integration — send newsletters to subscribers by composing an email to a virtual trigger address (e.g. listmonk@ssp.sh); neomd intercepts the send and creates a scheduled campaign in Listmonk via its REST API instead of delivering via SMTP; configure multiple trigger addresses in [[listmonk.triggers]] to target different subscriber lists (newsletter, book, all); pre-send screen shows "Newsletter via Listmonk" with target list IDs and schedule delay; campaigns are created as draft then set to scheduled status with configurable delay (default 30 minutes); authentication via HTTP Basic Auth with environment variable expansion for API token; new self-contained internal/listmonk/ package with full test coverage (httptest mocks); documented in docs/integrations/listmonk.md

2026-04-21

  • RFC 5322 compliant Message-ID — Message-IDs now use the sender's domain instead of hardcoded @neomd; ensures proper email threading, spam filter compatibility, and domain reputation consistency; uses net/mail.ParseAddress() for robust RFC 5322 address parsing; validates From address before sending and rejects invalid addresses that would result in @localhost Message-IDs; added comprehensive test coverage for BuildMessage, BuildDraftMessage, and BuildReactionMessage paths; documented email standards compliance in docs/email-standards.md
  • Fix: From validation allows local-only addressesextractDomain() now returns (domain, ok bool) to distinguish between parsing failures (invalid address) and valid user@localhost addresses; validation only rejects unparseable addresses, not legitimate local mail system configurations; prevents regression that would have blocked valid RFC 5322 addresses
  • Fix: test nil dereference guardTestBuildMessage_InvalidFrom now uses t.Fatalf() when validation incorrectly succeeds, preventing nil pointer panic on err.Error() if test regresses
  • Fix: potential memory leak in background sync — fixed infinite loop that occurred when IMAP errors (e.g., after suspend/resume) triggered immediate retry instead of waiting for next scheduled interval; bgFetchInboxCmd() now returns nil on error instead of bgSyncTickMsg{}, preventing tight loop that consumes large amounts of RAM; added bgSyncInProgress flag that covers the entire fetch-and-screen cycle (kept set until bgScreenDoneMsg), preventing concurrent background syncs from piling up during slow network or long screening operations
  • Fix: reply-all excludes all own addressesctrl+r reply-all now excludes both IMAP login addresses (account.User) and send-as addresses (account.From, sender.From) from the CC field; fixes edge cases where user != from (e.g., login as user123@provider.com but send as simon@domain.com) would still leak the login address into CC; previously only excluded From addresses. Added test suite added covering single/multi-account, sender aliases, case sensitivity, and named addresses

2026-04-18

  • Headless daemon mode (--headless) — run neomd as a background daemon on a server (NAS, VPS, always-on device) to continuously screen emails without launching the TUI; daemon fetches inbox and auto-screens every bg_sync_interval minutes (configurable in config); watches screener list files (~/.config/neomd/lists/*.txt) and reloads when they change (designed for Syncthing multi-device sync); graceful shutdown on SIGTERM/SIGINT; structured logging to stdout with log/slog; use case: run daemon on NAS with bg_sync_interval = 5, disable background sync on laptop/Android with bg_sync_interval = 0, classify senders in TUI, Syncthing syncs lists to NAS, daemon auto-screens incoming emails so mobile IMAP apps see correctly filtered folders; perfect for using neomd's screener with native mobile email clients; daemon only reads screener lists and moves emails (never modifies lists), all sender classification happens in TUI; includes comprehensive tests for classification logic and daemon lifecycle; documented in docs/configurations/headless.md with Syncthing setup guide, systemd service example, and multi-device workflow; make daemon target for testing
  • Fix: headless screening paused when lists empty — daemon now checks screener.IsEmpty() and skips screening when all lists are empty, mirroring TUI behavior; prevents sweeping entire inbox to ToScreen on first run or before Syncthing completes initial sync; logs "screening paused: screener lists are empty (classify your first sender to activate)" until first classification exists; regression test added
  • Fix: cross-list cleanup on reclassification — all 5 screener classification functions (Approve, Block, MarkSpam, MarkFeed, MarkPaperTrail) now remove email addresses from ALL conflicting lists before adding to the target list; fixes bug where reclassifying a sender (e.g., Feed → ScreenedOut) would leave the address in both feed.txt and screened_out.txt, causing duplicates in screener state and sync conflicts across devices; previously only Approve, Block, and MarkSpam had partial cleanup logic, while MarkFeed and MarkPaperTrail only appended without removing; reclassification is now atomic using snapshot/restore: captures screener state before starting, checks all file operation errors (no longer silently ignored), and restores the snapshot on any failure to prevent partial writes or lost classifications; comprehensive regression test added covering Feed→ScreenedOut, PaperTrail→Feed, full reclassification chain (Inbox→Feed→PaperTrail→ScreenedOut→Spam→Inbox), and persistence after reload; all cleanup operations verified both in-memory and on-disk
  • Cross-compile FreeBSD binary on buildmake build now automatically creates neomd-freebsd static binary alongside Linux binary; FreeBSD binary can be copied to FreeBSD/OpenBSD servers without needing Go installed; make sync-headless target copies to remote server via scp

2026-04-17

  • GitHub/Obsidian-style callouts in emails — compose emails with callout syntax > [!note], > [!tip], > [!warning] for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons using Kanagawa theme colors (crystalBlue, springGreen, carpYellow, oniViolet, autumnRed); compact spacing with emoji and title matching body text size (15px) for minimal visual intrusion; supports custom titles (> [!note] Custom Title), multiple paragraphs, and nested callouts; always expanded (no collapsible behavior), no JavaScript required; works in both syntaxes: > [!note] (with space) or >[!note] (without space); plain text emails format callouts as emoji text without blockquote markers (readable in neomd reader and plain text clients); uses local fork of goldmark-obsidian-callout with email-optimized rendering; same syntax used in neomd's README now works in your composed emails
  • Timer-based mark-as-read — emails are no longer marked as read immediately when opened; instead, a configurable timer (default 7 seconds) starts when you enter the reader; if you stay for the full duration, the email is marked as \Seen; if you exit early (quick peek), it stays unread; prevents accidental marking when browsing through emails
  • mark_as_read_after_secs config — new [ui] option to control mark-as-read delay in seconds (default 7); set to 0 for immediate marking (old behavior); set to any value to customize the delay
  • Fix: local UI state sync on mark-as-read — inbox list now updates immediately when an email is marked as read, either via timer or manual toggle (n); previously the server was updated but the local UI showed stale unread indicators until manual refresh

2026-04-16

  • B move to Work/business — press B to move marked or cursor email(s) to Work folder (similar to A for Archive); quick single-key action without screener list updates; shows friendly error if Work folder not configured; useful for rapid GTD-style email processing; complements existing gb (go to Work) and Mb (move to Work) shortcuts
  • Redesigned welcome screen — new two-column layout with ASCII art logo, philosophy/getting started guide on the left, and essential shortcuts organized by category on the right; wider box (100 chars) with cleaner spacing; maintains kanagawa color scheme; more scannable and visually appealing for new users
  • ASCII logo in help overlay — pressing ? now shows the neomd ASCII art logo overlaid on the top-right corner of the help screen; shortcuts start immediately at the top without vertical space taken by the logo; logo only appears when scrolled to the top
  • space+w welcome shortcut — press space then w to reopen the welcome screen anytime; useful for reviewing keybindings and getting started guide; documented in help overlay and keybindings reference
  • N jump to next unread — press N to jump to the next unread email in the current folder; wraps around to the beginning if no unread found after cursor; displays status message if no unread emails exist
  • z toggle unread-only view — press z to filter the inbox to show only unread emails (mnemonic: "zero in on unread"); press z again to show all emails; works alongside text filter (/) and can be cleared with esc; status bar indicates current view mode
  • Fix: help menu search — all keys (including j, k, d, u) are now available for typing when searching in help overlay (? then /); scroll keys only work when not in search mode

2026-04-15

  • Scheduled folder keybindings — added gc (go to Scheduled, mnemonic: "calendar") and Mc (move to Scheduled) shortcuts; Scheduled folder now accessible via dedicated keybindings alongside existing tab navigation ([]HL, space+1-9); help overlay and generated keybindings documentation updated

2026-04-14

  • Extended link support (99 links) — link opener now supports up to 99 links per email (previously limited to 10); space+1-0 opens links 1-10, space+l11-99 opens links 11-99 using intuitive numeric shortcuts (e.g. space+l26 for link [26]); status line provides progressive feedback during multi-key input; footer help and ? overlay updated
  • Fix: link extraction with brackets in text — markdown link regex now correctly matches links with brackets inside the link text (e.g. [[Watch the studio tour here]](url)); changed from [^\]]+ (anything except ]) to non-greedy .+? to handle nested brackets; fixes newsletter links from Beehiiv and similar services

2026-04-13

  • Emoji reactions (ctrl+e) — fast, keyboard-driven emoji reactions from inbox or reader; press ctrl+e to open emoji picker overlay, select with 1-8 for instant send or navigate with j/k and press enter; sends minimal reaction email (emoji + italic footer + quoted original message) with proper threading headers; available reactions: 👍 ❤️ 😂 🎉 🙏 💯 👀 ✅; original email marked with \Answered flag; reaction saved to Sent folder; auto-selects From address matching recipient (same logic as regular replies)
  • Email threading headers — all replies (regular r/R and emoji reactions ctrl+e) now include proper In-Reply-To and References headers for conversation threading; ensures replies appear correctly grouped in Gmail, Outlook, and Apple Mail conversation views; References header extracted from IMAP message body and preserved in reply chain
  • Fix: refresh not showing new emails immediately — pressing R now correctly displays new emails on first refresh; previously the IMAP client cached the selected mailbox state, so the first R would skip re-SELECT and use stale UID SEARCH results (showing the old unread count but no new messages in the list); required a second R or tab switch to see new emails; now forces a fresh SELECT to ensure mailbox state is current; also fixed background sync path to prevent stale cache; added regression test (internal/imap/client_test.go:410)

2026-04-10

  • HTML signature support — new [ui.signature_block] config with separate text and html fields for dual-format signatures; text signature appears in the editor and text/plain MIME part, HTML signature appends to the text/html part only; use [html-signature] placeholder in text signature to control HTML signature inclusion per-email (visible in preview, deletable before sending); backward compatible with legacy signature field
  • Fix: draft formatting corruption — drafts are now stored as plain text only instead of multipart/alternative to prevent HTML→markdown conversion artifacts; fixes line break addition, pipe escaping (|\|), and italic style changes (*_) when reopening saved drafts
  • Sent/Drafts primary-account default restored — in multi-account setups, Sent and Drafts now default back to the first configured IMAP account while SMTP still uses the selected sending identity; added store_sent_drafts_in_sending_account = true for users who want Sent/Drafts to follow the sending account instead
  • Proton Mail Bridge compatibility — documented that Proton Mail works with neomd only via Proton Mail Bridge (paid Proton feature), added optional tls_cert_file support for trusting Bridge’s exported self-signed certificate, and added a narrow localhost-only TLS retry fallback for Bridge connections on 127.0.0.1/localhost; normal remote IMAP/SMTP providers keep their existing strict certificate verification behavior
  • Issue #6 verification pass — reviewed the user report against the current code and specifically verified that startup auto-screening does not route Inbox mail to Trash in the current implementation, while manual ToScreen screening remains message-by-message by design
  • Fix: Drafts/Spam reload off-tab folder mismatch — reloading while viewing an off-tab folder now reloads that actual mailbox instead of the currently selected tab's folder; fixes the confusing case where Drafts could show Inbox content after pressing R
  • Fix: committed / filter now clears with esc — pressing esc now reliably clears the in-memory inbox filter even after the filter was already applied
  • Help overlay improvements? help is now scrollable with j/k, arrow keys, and d/u; search begins only after pressing /, so opening help no longer immediately behaves like a search prompt
  • Attachment workflow guidance — startup/welcome messaging now warns when the optional inline Neovim attachment integration is unavailable; README install docs now list yazi and the external custom.lua integration as optional requirements for <leader>a, while clarifying that pre-send a still works independently
  • UX hints — inbox footer now exposes , sort; pre-send footer clarifies s as spell-check-and-edit versus plain e edit; compose/pre-send ctrl+f now shows a message when only one From identity is configured
  • Fix: sender-level screening from ToScreen — approving/blocking/feed/papertrail/spam on a single unmarked message in ToScreen now expands to all currently queued mail from that sender, matching the intended HEY-style workflow
  • Safety guard: screener destinations may not point to Trash — screening now refuses to run if ToScreen, ScreenedOut, Feed, PaperTrail, or Spam are configured to the same IMAP folder as Trash
  • Inbox paging clarity — the inbox header now shows the current fetch limit (loaded/limit) and d/u page movement directly, so the “only 50 emails” behavior is visible without guessing
  • Discard confirmation for unsent mailesc in compose and esc/x in pre-send now ask for confirmation before dropping the message; recovery hints still point to :recover
  • Default Inbox load raised to 200 — new configs now use inbox_count = 200; README, config docs, and welcome text now clarify that normal loads/auto-screening only process that loaded Inbox slice, while :screen-all scans the full Inbox on the IMAP server
  • Compose/draft round-trip preservation — editor/pre-send/draft/recover flows now preserve Bcc and selected From; continuing a draft also restores its attachments back into the compose session
  • Correct IMAP account for Sent/Drafts — sent copies and saved drafts now use the IMAP account that matches the selected sending identity / [[senders]] alias instead of always using the currently active inbox account
  • Draft MIME keeps Bcc — Drafts saved via IMAP now retain the Bcc header so reopening a draft does not silently lose hidden recipients
  • Search/Everything/Thread subjects no longer mutate — folder prefixes are now display-only in list rendering, so reply/forward/thread logic keeps using the real RFC subject
  • Screener rollback safety — screener actions now snapshot list state and roll back both list files and already-moved emails if a later move fails, keeping mailbox state and screener files consistent
  • :search help text fixed — the command description now correctly says it searches across configured folders, not just the current folder

Roborev

  • Security: path traversal vulnerability fixed — inline image handling (O browser preview) now sanitizes ContentID and Filename from email MIME headers to prevent attackers from writing files outside /tmp/neomd/ via malicious cid: references (e.g. ../../etc/cron.d/evil); all attachment paths now use filepath.Base() and verify the result stays under temp directory before writing
  • Fix: conversation view navigation — pressing T (thread view) now correctly shows error messages and empty-result warnings; imapSearchResults flag is cleared immediately so the status bar appears instead of the search bar; added general Esc handler for offTabFolder views that preserves search context: pressing Esc from thread view returns to IMAP search results if that's where you came from (checked via imapSearchText), otherwise returns to active folder; imapSearchText is cleared when navigating away from search via tab, clicks, or go-to commands (gi/ga/etc) to prevent stale search context from affecting unrelated views; search retry errors are now visible because imapSearchResults is only set by the handler on success
  • Fix: Work folder move guardMb (move to Work) is now disabled when the Work folder is not configured, preventing moves to an empty folder name; previously caused silent failures
  • Fix: Work folder keybindings in helpgb (go to Work) and Mb (move to Work) now appear in the ? help overlay and generated keybindings documentation, marked as "(if configured)" to indicate they're optional
  • Test coverage: expandEnv edge cases — added unit tests for environment variable expansion covering unset variables (silently return empty), bare $ alone, empty ${}, whitespace trimming, and variables with text suffixes/prefixes; documents current behavior for config password/user fields
  • Fix: welcome message formatting — onboarding screen instruction now reads clearly ("Go to Inbox tab; once screener is active, use ToScreen") instead of the previous formatting regression ("ToScreentab")

2026-04-09

  • Fix: non-standard IMAP/SMTP ports — neomd now correctly handles non-standard ports (e.g., Proton Mail Bridge on 127.0.0.1:1143 and 127.0.0.1:1025); previously hardcoded port-based logic ignored the user's starttls config and refused unencrypted connections to any port other than 993/143 (IMAP) or 465/587 (SMTP); new behavior: user's explicit starttls = true always forces STARTTLS, standard ports use their defaults (993→TLS, 143→STARTTLS, 465→TLS, 587→STARTTLS), non-standard ports default to TLS for security (user must set starttls = true if their provider uses STARTTLS on a custom port); fixes "refusing unencrypted connection to 127.0.0.1:1143" error reported by Proton Bridge users; comprehensive test coverage added for all port/config combinations

2026-04-08

  • Fix: pre-send e losing email body — pressing e in the pre-send review to re-edit now correctly reopens the editor with the existing body; previously it opened a blank compose with only the signature, silently discarding the email content (including reply history)
  • Draft backups — every compose session is automatically backed up to ~/.cache/neomd/drafts/ before the temp file is deleted; keeps a rolling 20 backups (configurable via draft_backup_count in [ui], set to -1 to disable); no more lost emails after crashes or accidental closes
  • :recover / :rec command — reopens the most recent draft backup as a compose session; To/Cc/Bcc/Subject are parsed from the backup and pre-filled automatically
  • Screener docs: "screening happens once" — documented that auto-screening only runs on the Inbox folder; emails moved to ToScreen by another device are not re-classified; use :reset-toscreen to move them back for re-screening
  • Test suite — 147 unit tests across 8 packages covering screener classification, MIME message building, editor parsing, config loading, IMAP search, OAuth2 token handling, rendering, and security invariants (file permissions, BCC privacy, credential leak prevention); CI workflow runs go test + go vet on every PR
  • Integration tests (make test-integration) — end-to-end tests against a real IMAP/SMTP server: send plain email and verify From/To/Subject/HTML body round-trip, CC header, file attachment content, non-ASCII subject encoding (umlauts + emoji), IMAP search with from:/subject: prefixes, move + undo, inline images, signature HTML rendering, SaveSent IMAP APPEND, comma-separated multiple recipients, and reply-all with 3 distinct addresses; all test emails cleaned up automatically; skipped without credentials so make test stays fast and offline
  • Fix: multiple To recipientsSend() now correctly splits comma-separated To addresses into individual SMTP RCPT TO commands; previously the entire "a@x.com, b@x.com" string was passed as a single address, causing delivery failures
  • Fix: To/CC display — reader and inbox now show all To and CC addresses, not just the first; FetchHeadersByUID (used by search/everything) now also populates To and CC fields
  • Reply-all rebind to ctrl+rR (Shift+R) is now consistently reload/refresh in all views; reply-all moved to ctrl+r which works from both inbox list and reader (previously R conflicted between reload in inbox and reply-all in reader)
  • Default signature for new users — new installs get *sent from [neomd](https://neomd.ssp.sh)* as the default signature
  • Reply indicator (·) — emails you've replied to show a · dot in the inbox list between the flag and thread columns; uses the standard IMAP \Answered flag so it works across clients (reply from webmail → neomd shows it)
  • \Answered flag on reply — after sending a reply, the original email is automatically marked as \Answered on the IMAP server
  • Conversation thread view (T / :thread) — press T from inbox list or reader to see the full conversation across folders (Inbox, Sent, Archive, Waiting, Work, etc.); searches by normalized subject + participant overlap; displays in a temporary "Thread" tab with [Folder] prefix and / threading connectors; esc returns to previous view
  • Custom folder support (work) — optional work = "Work" in [folders] config; add "work" to tab_order to show as a tab; gb to go, Mb to move; auto-created on first run if configured; included in Everything, Search, and conversation views
  • Inline images in browser preview — pressing O to open an email in the browser now shows inline images from other senders; cid: references are rewritten to temp files so the browser can display them; previously only your own sent emails rendered images correctly
  • compose_editor config option — optional compose_editor in [ui] to use a different editor for compose/reply/forward (e.g. "nvim --appname nvim-wp"); defaults to $EDITOR / nvim

2026-04-05

  • OAuth2 authentication (#3, thanks @notthatjesus) — accounts can set auth_type = "oauth2" with oauth2_client_id, oauth2_client_secret, oauth2_issuer_url, and oauth2_scopes instead of a password; on first launch neomd opens the browser for the authorization code flow, persists the token to ~/.config/neomd/tokens/<account>.json, and refreshes it automatically; works with Gmail, Office365, and any OIDC-discoverable provider via XOAUTH2 over IMAP and SMTP; password auth paths unchanged for existing accounts
  • auto_bcc config — root-level auto_bcc = "addr@example.com" appends an address to every outgoing email's Bcc so you keep a copy in an external mailbox (e.g. a hey.com archive); visible in the composer and pre-send review (no silent BCC), deduped against any manual Bcc entry
  • shift+tab in compose — navigate back through To/Cc/Bcc/Subject fields (previously could only move forward with tab/enter)
  • Reader shows local time — email dates in the reader header now convert to your system's local timezone and include the clock time (e.g. Apr 05, 00:51); previously showed the sender's timezone date without time

2026-04-02

  • Auto From on reply — replying auto-selects the From address that matches the email's To/CC field (e.g. email sent to simon@domain.com replies from simon@domain.com); r now works from inbox list view; # [neomd: from: ...] shown in editor; x in pre-send discards the email
  • Email safety hardening — bulk operations show live progress counter ("Screening: 42/1000…") for batches >10; screener now moves emails before updating list files (no inconsistent state on failure); SaveSent failure shown as warning instead of silently swallowed; batch failures report exact moved/total counts; partial batch undo info preserved on error; undo stack capped at 20
  • Screener lists created on startup — all 5 screener .txt files are created as empty files on first run (alongside directories), consistent with IMAP folder creation
  • Config-isolated cache — demo and production configs use separate cache directories (derived from config dir name), so make demo-reset never touches production data
  • Added benchmark to readme as Gmail was considerly slower than my IMAP provider here from Switzerland.

2026-04-01

  • Threaded inbox — related emails are automatically grouped in the inbox list with a Twitter-style vertical connector line (/); threads detected via In-Reply-To/Message-ID IMAP envelope headers with a reply-prefix subject fallback (only emails with Re:, AW:, Fwd: etc. are grouped by subject — recurring notifications/invoices stay separate); newest reply on top, root at bottom; threads sorted by most recent email so active conversations float to the top
  • Clickable tabs — folder tabs in the top bar are clickable with the mouse; click any tab to switch folders
  • Spell check in pre-send (s) — opens nvim with spell checking enabled (en_us + de), cursor jumps to the first misspelled word; use ]s/[s to navigate errors, z= for suggestions, zg to add to dictionary; corrected body flows back to pre-send
  • :debug / :dbg command — writes a diagnostic report covering IMAP connectivity (ping test), account config (emails masked), folder mapping, screener list status, UI config, and current state; opens in the reader and saves to /tmp/neomd/debug.log for sharing; no sensitive data (passwords, full emails) included
  • Drafts show recipient — Drafts folder now shows → recipient instead of From (same as Sent tab), since all drafts are from you
  • ctrl+b in pre-send — toggle CC/BCC fields from the pre-send review screen (previously only available during compose)
  • u / U rebindu is now free for page-up (vim-style half-page scroll); U is undo last move/delete; ctrl+u clears all marks
  • Temp files in /tmp/neomd/ — all temp files (compose, preview, spell check) now live in /tmp/neomd/ subdirectory for easy recovery after crashes and less clutter
  • Improved onboarding — auto-screening is now paused when screener lists are empty (first run), preventing all emails from being moved to ToScreen; activates automatically once the user classifies their first sender; welcome screen rewritten with step-by-step getting-started guide explaining the screener workflow, batch operations (m + I), and config hints (:debug, auto_screen_on_load)
  • ] / [ folder navigation — bracket keys now switch to next/previous folder tab (alongside L/H and tab/shift+tab)

2026-03-31

  • fix showing recipient in SENT tab (instead of from)
  • IMAP search across all folders (space / or :search) — server-side IMAP SEARCH across all configured folders (Inbox, Sent, Archive, Feed, etc.); results displayed in a temporary "Search" tab with [Folder] prefix on each subject; supports query prefixes: from:simon, subject:invoice, to:team@, or plain text to search all three fields; press esc to close results
  • Filter preserves across actions — the local / filter no longer clears when pressing n (toggle read), m (mark), U (clear marks), or sorting; filter stays active until esc
  • Address autocomplete in compose — To, Cc, and Bcc fields show autocomplete suggestions from screener lists (screened_in.txt, feed.txt, papertrail.txt); navigate with ctrl+n/ctrl+p/arrows, accept with tab; supports multi-address fields (autocomplete applies after the last comma)
  • Everything view (ge or :everything) — shows the 50 most recent emails across all folders in a temporary "Everything" tab, sorted by date descending; each subject prefixed with [Folder]; useful for finding emails that were screened out or moved to spam
  • Link opener (space+1-9 in reader) — links are extracted from the email body, numbered [1]-[0] in the header; press space then a digit to open in $BROWSER; up to 10 links per email, deduplicated by URL
  • Draft signature fix — re-opening a draft (E) no longer appends a duplicate signature; the draft body already contains it from the first compose
  • Draft reader footerE draft now appears in the reader footer when viewing an email from the Drafts folder
  • Android support (make android) — cross-compile for Android ARM64; runs in Termux; documented in docs/android.md with install instructions and useful shortcuts
  • Docs restructure — detailed documentation moved from README to docs/ folder: docs/keybindings.md (auto-generated), docs/screener.md, docs/sending.md, docs/configuration.md, docs/android.md; README kept concise with links

2026-03-30

  • added preview email in $BROWSER (images rendered, same as recipient sees) with p
  • Multiple From addresses / SMTP aliases — add [[senders]] blocks to config to define extra From identities (e.g. s@ssp.sh as an alias through an existing account's SMTP); cycle through all accounts + senders with ctrl+f in both compose and pre-send screens; the account = field matches by account name = (not email address)
  • Sent folder — after sending, neomd APPENDs a copy to the configured Sent IMAP folder with \Seen flag; the same raw MIME bytes used for SMTP delivery are reused for the APPEND (no double-build)
  • Attachment column in inbox@ appears in a dedicated column next to the date when an email has attachments (detected from IMAP BODYSTRUCTURE including inline images)
  • Attachment downloads in reader — the email header now lists all attachments as [1] report.pdf [2] photo.png; press 19 to download attachment N to ~/Downloads/ and open it with xdg-open; filenames are deduplicated automatically
  • Inline images as downloads — images embedded inline in emails (Content-Disposition: inline, e.g. PNG screenshots) are now shown alongside regular attachments in the reader header and downloadable with 19; previously only Content-Disposition: attachment parts were listed
  • Inline image placeholders in reader body<img src="cid:..."> tags now show [Image: filename.png] at their position in the body text instead of being silently stripped; uses Content-ID → filename mapping from MIME parts
  • Undo move / deleteu reverses the last single or batch move/delete (x, A, M*); uses the UIDPLUS destination UID so undo still works even when the server reassigns UIDs on MOVE; screener actions (I, O, F, P, $) are intentionally excluded because they also modify .txt list files
  • Subject (and headers) re-parsed from editor — editing # [neomd: subject: ...], # [neomd: to: ...], etc. in neovim now correctly updates those fields; previously the values were captured in a closure before the editor opened and changes were silently discarded; all three editor entry points (new compose, reply, continue draft) now call editor.ParseHeaders on the saved file content
  • ctrl+f for cycling From — changed from f (which conflicts with typing in text fields) to ctrl+f; works in both the compose form and the pre-send review screen
  • Forward (f) — forward an email from the reader or inbox; opens the editor with the original message quoted, Fwd: subject prefix, and empty To: field; from inbox the body is fetched automatically before opening the editor
  • Permanent delete (X, Trash only) — permanently deletes marked or cursor email(s) from the Trash folder via IMAP STORE \Deleted + UID EXPUNGE; blocked in other folders with a warning message
  • :empty-trash / :et — permanently delete all emails in Trash with y/n confirmation; works from any folder without navigating to Trash first
  • First-run welcome popup — on the very first launch, a centered popup shows quick-start keybindings and screener basics; any key dismisses it; marker at ~/.cache/neomd/welcome-shown ensures it only appears once
  • Auto-create IMAP folders on startupensureFoldersCmd runs during Init() so new users don't need to manually run :create-folders; idempotent for existing users
  • Auto-create screener list directories — parent directories for screener list paths are created automatically during config load; prevents errors when pressing I/O/F/P on a fresh install
  • Default screener paths — changed from ~/.config/mutt/ to ~/.config/neomd/lists/ for new installs; existing configs with custom paths are unaffected
  • Go prerequisite check in Makefilemake build/make install now prints clear Go installation instructions instead of a cryptic error when go is not found
  • Pre-send preview (p) — press p in the pre-send screen to open a browser preview of the composed email; renders through the same goldmark pipeline as sending, with local image paths converted to file:// URLs so inline images from [attach] lines display correctly

2026-03-29

  • CC field — compose and reply forms now include an optional Cc field (Tab/Enter to skip); CC recipients receive the email and appear in the Cc: header
  • BCC field — hidden by default; toggle with ctrl+b in compose; BCC recipients receive the email but are not visible in the message headers (standard BCC privacy)
  • Reply-allR in the reader replies to the original sender + all CC recipients; your own address is excluded automatically; uses Reply-To header when present
  • Pre-send review screen — after closing the editor, neomd shows a summary (To, Subject, body preview) before sending; press enter to send, a to attach files via yazi (auto-detected, no config needed; override with $NEOMD_FILE_PICKER), D to remove last attachment, d to save to Drafts, e to re-open the editor, esc to cancel; avoids tmux/terminal key-capture issues since a needs no modifier
  • Save to Draftsd in the pre-send screen APPENDs the composed message to the configured Drafts IMAP folder with \Draft + \Seen flags; navigate to it with gd
  • Attachments from neovim<leader>a in a neomd-*.md buffer opens yazi in a floating terminal; selected files are inserted as [attach] /path/to/file lines (visible in markdown, not hidden HTML comments); neomd strips them before sending and adds them as MIME attachments
  • Inline code and code blocks`inline code` and fenced ``` blocks are rendered in HTML emails (goldmark CommonMark + GFM; styled with monospace font and light grey background)

2026-03-27

  • gd Drafts navigation — jump to Drafts folder with gd even when it's not in the tab rotation
  • Off-tab folder indicator — when viewing Spam (gS) or Drafts (gd), the folder name appears highlighted in the tab bar with a separator; no regular tab stays falsely active
  • Security hardening — IMAP refuses unencrypted connections (non-993/143 ports error out instead of DialInsecure); email-extracted URLs validated to http/https only before opening in browser (case-insensitive, RFC 3986); SECURITY.md added documenting credential storage, TLS guarantees, screener list handling, and temp file lifecycle with links to source
  • Spam folder$ marks a sender as spam (writes to spam.txt, moves to Spam IMAP folder). Separate from ScreenedOut so you never have to look at it again. Navigate with gS or :go-spam — kept out of the tab rotation intentionally
  • Cross-list cleanup — reclassifying a sender removes them from conflicting lists automatically: I (approve) removes from screened_out + spam; O (block) removes from screened_in; $ (spam) removes from screened_in + screened_out. No manual .txt editing needed
  • : command history/ cycles through the last 5 distinct commands; accepts the ghost completion; ctrl+n/ctrl+p cycle forward/backward through completions. Persists across restarts in ~/.cache/neomd/cmd_history (outside dotfiles version control)
  • Leader keyspace is the leader; <space>1<space>9 jumps to a folder tab by number
  • Auto-screen on inbox load — screener applies automatically on every Inbox load (startup, R). Disable with auto_screen_on_load = false in [ui]
  • Background sync — inbox re-fetched and screened every 5 minutes while neomd is open. Configure with bg_sync_interval in [ui]; 0 disables it
  • n / m rebindn toggles read/unread (was N); m marks for batch ops (was space)

2026-03-25

  • Signature — auto-appended to new compose buffers; configure in [ui] with signature
  • Compose abort — closing the editor with ZQ / :q! cancels the email; only ZZ / :wq sends
  • Browser image workflowO opens email as HTML in $BROWSER; ctrl+o opens the canonical web/newsletter URL (extracted from List-Post header); o opens in w3m
  • :create-folders / :cf — creates any missing IMAP folders defined in config (idempotent)