- Sender view (
V) — from the inbox list, hitVon 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 existingfrom:IMAP SEARCH infra (SearchAllFolders) that already powersspace+/search — no new IMAP capability needed. Results open in aSenderoff-tab exactly likeSearch/Everything/Thread;esccloses it.Vwas picked overE(already bound to "continue draft" in the reader) andF(already bound to "mark as Feed"). NewsenderAddr,fetchSenderCmd,handleSenderResultininternal/ui/search.go. Tests:TestSenderAddr,TestHandleSenderResultSetsOffTabAndEmails,TestHandleSenderResultNoMatches neomd listcaps sender-controlled header fields — From/Subject in the JSON output are now truncated UTF-8-safely at 500 bytes (listHeaderMaxBytes, via the existingtruncateUTF8), 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.readbodies were already bounded by--max-bytes(default 64 KB).cmd/neomd/list.go. Test:TestRunList_TruncatesHostileHeaders
-
neomd list+neomd screensubcommands — 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 15prints one JSON object ({ok, account, folders:[{name, emails:[{uid, from, subject, date, unread}]}]}, dates RFC 3339 UTC, first IMAP-enabled account) — strictly read-only viaFetchHeaders.neomd screen --from <addr> --action in|out|feed|paperclassifies a sender exactly like the TUI'sI/O/F/Pkeys: screener list update first (atomic cross-list cleanup viaApprove/Block/MarkFeed/MarkPaperTrail), then the sender-level move of ALL queued ToScreen mail from that sender, gated by the sameValidateScreenerSafetyTrash 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 incmd/neomd/main.go. Tests:TestRunList_JSONShape,TestRunList_ResolvesConfiguredIMAPName,TestRunList_FetchErrorJSON,TestRunScreen_ApproveMovesAllFromSender,TestRunScreen_ActionDestinations,TestRunScreen_RefusesTrashDestination -
neomd readsubcommand — 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 throughFetchBody, which fetches withBODY.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 aslist/screen.cmd/neomd/read.go. Tests:TestRunRead_JSONShape,TestRunRead_TruncatesLongBody,TestRunRead_FetchErrorJSON,TestRunRead_UnknownFolderErrorJSON
-
Re-saving a draft replaces the previous version; send-later gets a watchdog — two gaps closed: (1) continuing a saved draft (
E) and pressingdagain 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 withEare 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) OVERDUEstatus 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 inprocessScheduled) — 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, extendedTestHardening_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-typedName <addr>recipients are harvested into~/.cache/neomd/contactsat 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 theX-Neomd-Send-Atheader 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, extendedTestIntegration_Hardening_ScheduledQueueRoundTrip -
Workflow hardening: end-to-end send-pipeline tests against a fake SMTP server — new
internal/ui/workflow_hardening_test.gocloses the "every function correct, composition wrong" hole: instead of testing helpers, it drives the real bubbletea Update handlers through the whole user flow —launchReplyWithCCwrites the actualneomd-*.mdcompose file, the test "edits" it like a user in nvim,editorDoneMsgand the pre-sendenterkey 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_ReplyAllUsesReceivingAccountpins 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_bccreaches RCPT TO but never a header, threading headers point at the original, andsendDoneMsgkeeps the·-indicator data.TestHardening_Workflow_MarkdownFileToWirepins 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 thecc/bccarguments insendEmailCmd(the exact Bcc-leak bug class) fails both tests immediately. Also new inAGENTS.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 ownparseBody(_InlineImagePlusAttachment); strict wire format — CRLF-only line endings, ≤998-char lines, parseable Date, unique Message-IDs across builds (TestHardening_WireFormat); andctrl+eemoji 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), andauto_bccdedupes case-insensitively against decoratedName <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 deliveredIn-Reply-To/Referencesmust 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 ofAGENTS.md). Unit side (internal/imap/roundtrip_hardening_test.go, no network): every built message is parsed back with go-message and neomd's ownparseBody, 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 (sanitizeHeaderValuestrips CR/LF from From/To/Cc/threading values — a harvested contact name or forwarded header could otherwise smuggle aBcc:), 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:Referenceschains are never duplicated when a broken sender includes its own Message-ID (buildRefChain). Tests:TestHardening_*acrossinternal/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/kmove,ycopies the address to the clipboard (wl-copy/xclip/xsel/pbcopyfallback chain — first clipboard integration in neomd),YcopiesName <addr>,enterstarts a compose with the contact as To. Newinternal/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/contactscache is ever written; delete it anytime — it rebuilds from harvesting + the file). Test:TestContactsPickerFilterAndSelect -
Send later — new
lkey 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) andX-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\Flaggedclaim 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). Newinternal/schedulepackage. 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,TestMergeFileGoogleCSVRealExportpins the format;TestRealImportManualis an opt-in smoke test viaNEOMD_CONTACTS_FILE). Additionally,first.last@domainshaped 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+"-*"), soOffer.pdfbecamedraft-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 freshdraft-attachments-*directory per draft and writes each attachment under its original basename (duplicates deduped asname-2.ext, path traversal and empty names still sanitized). Regression testTestWriteAttachmentsTempPreservesFilename -
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. NewformatEnvelopeAddrrendersName <addr>when a display name is present; names containing,/</>/"fall back to the bare address so naive comma-splitting (SplitAddrs, RCPT extraction) never breaks. TestTestFormatEnvelopeAddr -
Contacts cache: search by name, names on outgoing mail — neomd sends carried only bare addresses (you type
l@domain.io, notLouise 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. Newinternal/contactspackage harvests everyName <addr>pair from loaded headers (From/To/CC on each folder load) into~/.cache/neomd/contacts(addr\tnamelines, atomic 0600 writes, saved viasafeGooff 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 toName <addr>in the headers only (RFC 2047 Q-encoding for non-ASCII names) —collectRcptTokeeps 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
- 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), ori(AI handoff) — fired a secondeditorDoneMsgwith the flag already false and rebuiltpendingSendfrom scratch, silently droppingreplyToUID/replyToFolderand theIn-Reply-To/Referencesthreading 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.pendingIsReplyis 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 testTestEditorDoneReplyTrackingSurvivesReEditsimulates two consecutive editor exits and assertsreplyToUID+In-Reply-Tosurvive the second one AGENTS.mdfeature-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.mdnow points to it and requires aCHANGELOG.mdentry at the end of every user-visible change
- Fix: replies to
AW:/SV:/VS:/ uppercase-RE:subjects now get\Answeredtracking — reply detection used to sniff the composed subject for a lowercase-normalizedre:prefix at editor exit; replying to a GermanAW:, Swedish/DanishSV:, or FinnishVS:thread produced a subject that never matched, so the original was not marked\Answeredand the threading headers were skipped. Replaced subject sniffing with an explicitpendingIsReplyflag set whenr/ctrl+rlaunches 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
- 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 ascid:inline MIME parts, so Gmail and every other client that blocks external images still renders the signature image. New second rewrite passimgSrcHTTPReinbuildMessageWithBCC(internal/smtp/sender.go) after the existing local-path pass;fetchRemoteImageuses a 10-second HTTP timeout so a slow CDN can't hang the send, MIME type comes fromContent-Typewithhttp.DetectContentTypefallback, and any fetch failure leaves the original URL in place instead of failing the send - Fix:
ctrl+bCC/BCC edit from pre-send no longer drops a composed body — toggling Cc/Bcc withctrl+bon the pre-send screen switched to the compose form, and advancing through the fields would fall through tostepSubjectand relaunch the editor from the compose fields — discarding the already-written body. NewfromPresendflag on the compose model: To/Subject are pre-filled for context, advancing past Bcc now patchescc/bccdirectly intopendingSendand returns straight to pre-send, andesccancels back to pre-send unchanged
make syncthing-tunnel— starts Syncthing on the headless server (if not already running) and opens an SSH tunnel to its web UI athttp://localhost:8385; companionrun-syncthingtarget inscripts/headless-server/Makefilefor starting Syncthing in the background on the server itself
- Docs: tagline refresh — README and docs-site landing page reworded around "write in Neovim, render as Markdown, screen senders first, organize emails once"
- Per-account signatures (thanks @notthatjesus) — each
[[accounts]]entry can now carry its own[accounts.signature_block]table withtextandhtmlfields, overriding the global[ui.signature_block]for that account. Personal account → casual Markdown blurb (textonly, goldmark renders it into the HTML part); business account →text = """[html-signature]"""placeholder + a styledhtmltable signature; send-only aliases → no block, falls back to the global block (or legacy[ui].signaturefortext). Resolution lives inConfig.Signature(account)(internal/config/config.go); every signature consumer inmodel.go— compose prelude inlaunchEditorCmd, draft re-open, AI handoff, pre-send HTML preview, SMTP send path — now passes the active account so the resolved signature follows whichever accountctrl+flands 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 onlyhtmlleaves the editor without an[html-signature]placeholder and the HTML never gets injected at send time. Unit testTestSignaturepins the resolution order; docs atdocs/content/docs/configuration/_index.md(Per-Account Signatures section with both shapes worked) anddocs/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
- 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 andrunewidthagree 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 — thedisplaySafetransform still collapses Bengali / Arabic / Thai / emoji runs to a single·placeholder, so the inbox-list width invariant (verified by the pre-existingTestRowFitsTerminalWidth, 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).TestDisplaySafeflipped from one"cjk collapses to single dot"case to three separate"passes through"cases for Japanese, Korean and Chinese. New integration testTestIntegration_SendMultiScriptDisplaysends 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
- Fix: reply from Sent folder now picks the From address you actually sent from — pressing
ron a message in the Sent tab used to default to the first configuredFrom(e.g.simu@sspaeti.com) instead of the address that sent the message (e.g.simon@ssp.sh). Root cause:matchFromIndexonly 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 inFrom(To is the original recipient). Added a thin folder-aware wrappermatchFromForReply(e)(internal/ui/model.go:5206) that swaps toe.FromwhenactiveFolder() == cfg.Folders.Sent; replaced the four call sites (r/ctrl+r/ctrl+epre-fetch in inbox + authoritative call inlaunchReplyWithCC).matchFromIndexsignature 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-filewrites the selected files, not the hovered (seeyazi-actor/src/mgr/open.rs:23-31) — so a directory likeaurfrom the repo root could end up as the attachment. SMTP then failed withis 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 inm.attachments(theeditorDoneMsgpost-extraction step and theattachPickDoneMsgpicker callback). Skipped paths are surfaced via the status bar in red:⚠ Skipped N invalid attachment path(s) (not a regular file): <path>. Regression testTestFilterValidAttachmentscovers directories, missing paths, and a real file - Nvim-side (dotfiles) —
custom.lua's<leader>ahelper nowfs_stats each path returned by yazi andvim.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", ...)ininit.luacallsya.emit("escape", { select = true })to clear any leftover selection on everycd, but only whenrt.args.chooser_fileis set (i.e. yazi was launched as a picker — neomd's<leader>aflow). Normal interactive yazi usage is untouched.escape --select(which runstab.selected.clear(), seeyazi-actor/src/mgr/escape.rs) is correct here;toggle_all --state=offwould 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
- Go-side — new
- Docs —
README.mdanddocs/content/docs/sending.md#attachmentsnow flag the yaziinit.luasnippet as required for users whose nvim<leader>is<Space>(lazyvim's default)
- 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 theFromcolumn) 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+FE05trailing onwerden︅-style subjects) are stripped. Same transform is applied to theSubject:line in the reader header so the rounded-border box no longer wraps and pushesFrom/Tooff the top of the viewport. Originale.Subjectis preserved on the email object — sanitisation is purely a rendering layer. Regression testTestDisplaySafecovers the script ranges andTestRowFitsTerminalWidthenforces the no-overflow invariant for a Bengali subject. NewdisplaySafe/safeForDisplayhelpers ininternal/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
- 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 rawmarkdown 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 ininternal/smtp/sender.gotherefore never fired — the image was dropped from the MIMEmultipart/relatedenvelope and the recipient just saw the source markdown. Two-line fix: (1)extractInlineAttachmentsnow emits the angle-bracket CommonMark formso 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 beforeos.ReadFilereads 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 testTestBuildMessage_InlineImagePathWithSpacescovers the full pipeline
- Per-trigger Listmonk template override —
[[listmonk.triggers]]entries now accept an optionaltemplate_id = Nfield 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 throughListmonkTrigger(config),listmonk.Trigger+ newResolveTemplateID(hook),campaignRequest.TemplateID(Listmonk REST payload,omitemptyso default triggers send no field at all), and the UI send path. Regression test covers the override/default/no-match cases
Msmove to Sent — addedMschord to move the cursor or marked email(s) to the configured Sent folder, mirroring thegs(go to Sent) shortcut. Previously every other major folder had a matchingM*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 sameM-prefix dstMap as the rest, no behaviour change for other letters/filter searches recipients in Sent folder — the in-memory inbox filter (/) previously matched onFrom + Subjectin every folder. In Sent that meant typing/hussainafter sending Hussain an email returned nothing, becauseFromis always you. The filter now matches onTo + CC + BCC + Subjectwhenever 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 eliminated —
bodyLoadedMsgandspyScanProgressMsgpreviously launchedsafeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) }), evaluatingcopyMapinside 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, socopyMapcannot race with subsequent map writes. The// Takes copied maps to avoid concurrent accesscomment onsaveSpyPixelCachewas true of the helper signature but the call sites violated the contract; both are now correct - Concurrency:
Screeneris now safe for concurrent use — addedsync.RWMutexguarding 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, andSnapshottake a read lock;Approve,Block,MarkSpam,MarkFeed,MarkPaperTrail,AddNotify,RemoveNotify, andRestoretake a write lock. RefactoredSnapshot/Restoreinto public-locked + privatesnapshotLocked/restoreLockedvariants so the existing transactional rollback insideApprove/Block/etc. doesn't double-lock. RWMutex keeps the hot read path parallel;make test -raceclean - Security: path traversal in
.icscalendar attachment open (<space> v o) —filepath.Base("..")returns"..", andfilepath.Join("~/.cache/neomd/ical", "..")escapes the cache dir. The previous filter rejected"",".", and"/"but not"..", so a hostileContent-Disposition: filenameof..would letos.WriteFile(att.Data)overwrite~/.cache/neomd/. Now also rejects".."and any embedded path separator, falling back toinvite-<unix-ts>.icswhen the sender-supplied name is unusable - Security: path traversal in attachment download (
xdg-openflow) — same root cause indownloadOpenAttachmentCmd: a malicious.emlpart withContent-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 toattachment-<unix-ts> - Reliability:
?no longer trapped in compose textinputs — the global?→ help binding fired from any state, includingstateCompose. Compose feeds keys into abubbles/textinput(To/CC/BCC/Subject), which never received?because the global handler intercepted first. Now gated to all states exceptstateCompose, so?types verbatim while composing and still toggles help everywhere else - Reliability:
extractAddrno longer issues blocking DNS lookups on send — the helper callednet.LookupHostfor 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/=09in quoted-printable. The previouswriteQPpassed trailing whitespace through verbatim, which many SMTP relays silently strip — mangling Markdown's two-trailing-spaces hard-break syntax (precisely the syntaxnormalizePlainTextadds when extracting plain text from HTML). The encoder now peeks ahead; trailing whitespace before\nor end-of-input is encoded - Reliability: bare
gocalls promoted tosafeGo—saveCmdHistory(:command-line history persistence) was launched as a barego func(), bypassing the project'ssafeGopanic-recovery contract documented inCLAUDE.md. A panic inside the writer would have torn down the TUI without a stack trace. Now wrapped insafeGo, 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 sends —
runAuthFlowlaunched the callback HTTP server as a barego func(). A panic insidenet/http's request handling would have crashed the process during the OAuth2 dance. Goroutine now has adefer recover()that surfaces the panic as anerrChsend. Additionally,errChandcodeChare 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 aselect/defaultnon-blocking pattern - Reliability: IMAP retry catches more transient failures —
isNetErrpreviously 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 useserrors.Is(err, net.ErrClosed | io.EOF | io.ErrUnexpectedEOF)andnet.Error.Timeout()typed checks plus a widened substring list covering TLS, syscall, and DNS-layer error formats - Reliability:
--headlessno longer panics when account 0 isimap_disabled = true—daemon.New(*cfg, imapClients[0], sc)blindly passed the first IMAP client to the daemon. Accounts withimap_disabled = trueproduce anilclient by design; if account 0 happened to be send-only,--headlesswould crash on the first IMAP call. The launcher now scansimapClientsfor 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
- Concurrency: data race in spy pixel cache save eliminated —
- Fix: attachments invisible after pre-send round-trip — re-opening the editor from pre-send via
e(re-edit),s(spell-check), ori(AI handoff), or continuing a saved draft, now re-injects tracked attachments as# [attach] /pathlines right under the existing# [neomd: ...]headers instead of silently keeping them only inm.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.extractInlineAttachmentsnow accepts both forms —# [attach] /path(header form, used on re-injection, visually grouped with the metadata headers) and[attach] /path(the form<leader>acontinues to insert at cursor position for inline image placement). The editor body is now the source of truth:editorDoneMsgreplacesm.attachmentswith 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 bywriteAttachmentsTempfrom 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#F2EFE9background),rose-pine,gruvbox, orosaka-jade. Optional top-level[theme]block overrides individual colour slots (primary,unread,error,bg, …) on top of any built-in.ApplyThemeruns inui.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].commandconfig (defaultclaude) wires any LLM CLI (claude,codex,aichat,sgpt, …) to the pre-sendikey. neomd writes the current draft to/tmp/neomd/neomd-ai-*.mdwith 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)).nvimis intentionally not a useful choice here — the compose buffer is already in nvim before pre-send, so spawning nvim oniwould just re-edit. Setcommand = ""to disable the binding - iCalendar RSVP + local calendar handoff — emails with a
text/calendarpart or.icsattachment 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.icsin[calendar].open_command(defaultxdg-open, set tomorgen/khal//usr/bin/gnome-calendarto force a specific app). MIME envelope:multipart/mixed > [multipart/alternative > (text/plain + text/calendar;method=REPLY)] + .ics attachmentwithSubject: Accepted: <event>(matches Gmail's native button format) and bracketed RFC 5322In-Reply-Toheaders. RSVPs save to theSentfolder 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-containedinternal/calendar/package on top ofarran4/golang-ical; newinternal/smtp/rsvp.gofor the iMIP MIME envelope. Only the firstVEVENTis 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 insideconfig.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. Newinternal/keyring/package with mock-backed tests; storage keys areneomd/account/<name>/{password|oauth2}
- Fix: send from
imap_disabled = trueaccount no longer panics — sending an email from an account configured as send-only (typically Gmail withimap_disabled = true) crashed the TUI withnil pointer dereferenceintokenSourceForbecause the helper called.TokenSource()on the intentionally-nil IMAP client. The same nil-deref existed inimapCli,imapCliForAccount, andprimaryIMAPClient— 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);sendEmailCmdalso guardscli.SaveSentandreplyCli.MarkAnsweredso a fully send-only configuration silently skips the Sent-folder copy instead of panicking. Four regression tests added ininternal/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|INBOXtoPersonal|Inbox, which would have re-baselined every existing user on upgrade and silently swallowed one round of notifications.MaybeNotifynow takes both the IMAP name (used for the state key) and the UI label (used only for the allowlist comparison) so existingnotify_state.jsonfiles keep working untouched. Regression test added - Fix:
[notifications].foldersallowlist 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 newFoldersConfig.LabelFor()helper before the allowlist check, so users with non-default IMAP folder names get notifications correctly. Regression test added ininternal/config/config_test.go - Fix: notifier can no longer freeze the TUI —
Sendnow runsnotify-send(or whatever[notifications].commandpoints to) under a 2-secondcontext.WithTimeoutplus a 500 mscmd.WaitDelay. A hung notification daemon (broken DBus, mako restarting, …) returns a clearnotify-send: timed out after 2sstatus 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
# comments—loadListpreviously 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, …)
- Desktop notifications for VIP senders — new opt-in
[notifications]config block firesnotify-send(or anynotify-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.jsonso 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 (everybg_sync_intervalminutes) 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 indocs/content/docs/notifications.mdincluding a wrapper-script recipe forhyprctl notifymore - Whole-domain screening — any screener list line beginning with
@(e.g.@ssp.sh) now matches every address at that domain; works inscreened_in.txt,screened_out.txt,feed.txt,papertrail.txt,spam.txt, andnotify.txt. Per-address entries always win over a@domainentry across all categories so a single blocked address inside an otherwise-approved domain stays blocked (priority order preserved: spam > out > feed > papertrail > in). NewDi/Dochord (works in inbox and reader) appends@<domain>of the cursor or open email's sender toscreened_in.txt/screened_out.txtafter ay/nconfirmation; complements the existingI/Oper-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 verifynotify-send, the icon theme, and the notification daemon (mako/dunst/swaync) are all working without waiting for a real VIP email to arrive:debugreports notifications — diagnostic report now includes the resolved[notifications]config, the contents ofnotify_state.json(per-folder baseline UIDs), the path tonotify.txt, and the list of non-Inbox folders being polled in the background for VIP mail- Visible
notify-senderrors — 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@domaintonotify.txtdirectly 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:
:debugpanicked on accounts withimap_disabled = true—writeDebugReportdereferenced the nil IMAP client thatimap_disabledaccounts 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_disabledaccount 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 screener —
cmd/neomd/main.goandinternal/daemon/daemon.goconstructedscreener.Configwithout the newNotifypath field, so the in-memory notify set stayed empty even whennotify.txthad entries;ShouldNotifyalways returned false and no notification ever fired. Both call sites now passNotify: cfg.Screener.Notify. Regression test added so this can't recur
- Fix: emoji reaction sent through wrong SMTP account —
ctrl+ereactions already auto-selected the correct From address (matching whichever of your addresses received the email), butsendReaction()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 tosimon@ssp.sh(Work, account index 1) while in the Personal inbox would send the reaction header assimon@ssp.shbut authenticate via Personal's SMTP and grabSenders[0]; reactions now usepresendSMTPAccount()(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
- Send-only accounts (
imap_disabled = true) — accounts can be marked as send-only by settingimap_disabled = true; neomd skips IMAP connection, folder fetching, and screening for that account; the account remains available as a From address viactrl+fin compose/pre-send;ctrl+aaccount cycling skips disabled accounts; useful for adding Gmail or other providers purely for sending without fetching thousands of emails;:debugshows "(imap disabled)" label
- 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_pixelsand persist across restarts; both positive and negative scan results are cached so repeat scans are instant - URL scheme whitelist — email links opened via
space+digitare now validated; onlyhttp://,https://, andmailto: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'snet/http.DetectContentType()catches disguised files (e.g. a script renamed to.pngis detected astext/plainand blocked); status bar warns about dangerous or suspicious file types - Browser view sanitization — pressing
Oto 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
spacein the reader now shows all available actions (1-0 links,d download .eml,l11-99 links 11+) instead of only link info;space+dfor 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.logwith 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
Rrefresh 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:portformat 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) andTestIntegration_BrowserSanitization(CSP script/iframe blocking) send real test emails for live inspection
- Mailto handler (
--mailto/ positional URI) — neomd can now be used as the system defaultmailto:handler; clicking amailto: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 bothneomd --mailto "mailto:user@example.com?subject=Hello"andneomd "mailto:..."(positional, for.desktopintegration); registered viaxdg-mimewith aneomd-mailto.desktopfile; after sending or cancelling, neomd continues as normal
- 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
- Download raw email source (
space+din reader) — saves the full raw MIME source as.emlto~/Downloads/with filenameneomd-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 toscheduledstatus with configurable delay (default 30 minutes); authentication via HTTP Basic Auth with environment variable expansion for API token; new self-containedinternal/listmonk/package with full test coverage (httptest mocks); documented indocs/integrations/listmonk.md
- 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; usesnet/mail.ParseAddress()for robust RFC 5322 address parsing; validates From address before sending and rejects invalid addresses that would result in@localhostMessage-IDs; added comprehensive test coverage for BuildMessage, BuildDraftMessage, and BuildReactionMessage paths; documented email standards compliance indocs/email-standards.md - Fix: From validation allows local-only addresses —
extractDomain()now returns(domain, ok bool)to distinguish between parsing failures (invalid address) and validuser@localhostaddresses; 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 guard —
TestBuildMessage_InvalidFromnow usest.Fatalf()when validation incorrectly succeeds, preventing nil pointer panic onerr.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 ofbgSyncTickMsg{}, preventing tight loop that consumes large amounts of RAM; addedbgSyncInProgressflag that covers the entire fetch-and-screen cycle (kept set untilbgScreenDoneMsg), preventing concurrent background syncs from piling up during slow network or long screening operations - Fix: reply-all excludes all own addresses —
ctrl+rreply-all now excludes both IMAP login addresses (account.User) and send-as addresses (account.From,sender.From) from the CC field; fixes edge cases whereuser != from(e.g., login asuser123@provider.combut send assimon@domain.com) would still leak the login address into CC; previously only excludedFromaddresses. Added test suite added covering single/multi-account, sender aliases, case sensitivity, and named addresses
- 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 everybg_sync_intervalminutes (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 withlog/slog; use case: run daemon on NAS withbg_sync_interval = 5, disable background sync on laptop/Android withbg_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 indocs/configurations/headless.mdwith Syncthing setup guide, systemd service example, and multi-device workflow;make daemontarget 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 bothfeed.txtandscreened_out.txt, causing duplicates in screener state and sync conflicts across devices; previously onlyApprove,Block, andMarkSpamhad partial cleanup logic, whileMarkFeedandMarkPaperTrailonly 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 build —
make buildnow automatically createsneomd-freebsdstatic binary alongside Linux binary; FreeBSD binary can be copied to FreeBSD/OpenBSD servers without needing Go installed;make sync-headlesstarget copies to remote server via scp
- 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_secsconfig — new[ui]option to control mark-as-read delay in seconds (default 7); set to0for 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
Bmove to Work/business — pressBto move marked or cursor email(s) to Work folder (similar toAfor 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 existinggb(go to Work) andMb(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+wwelcome shortcut — pressspacethenwto reopen the welcome screen anytime; useful for reviewing keybindings and getting started guide; documented in help overlay and keybindings referenceNjump to next unread — pressNto 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 existztoggle unread-only view — presszto filter the inbox to show only unread emails (mnemonic: "zero in on unread"); presszagain to show all emails; works alongside text filter (/) and can be cleared withesc; 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
- Scheduled folder keybindings — added
gc(go to Scheduled, mnemonic: "calendar") andMc(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
- Extended link support (99 links) — link opener now supports up to 99 links per email (previously limited to 10);
space+1-0opens links 1-10,space+l11-99opens links 11-99 using intuitive numeric shortcuts (e.g.space+l26for 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
- Emoji reactions (
ctrl+e) — fast, keyboard-driven emoji reactions from inbox or reader; pressctrl+eto open emoji picker overlay, select with1-8for instant send or navigate withj/kand pressenter; sends minimal reaction email (emoji + italic footer + quoted original message) with proper threading headers; available reactions: 👍 ❤️ 😂 🎉 🙏 💯 👀 ✅; original email marked with\Answeredflag; reaction saved to Sent folder; auto-selects From address matching recipient (same logic as regular replies) - Email threading headers — all replies (regular
r/Rand emoji reactionsctrl+e) now include properIn-Reply-ToandReferencesheaders for conversation threading; ensures replies appear correctly grouped in Gmail, Outlook, and Apple Mail conversation views;Referencesheader extracted from IMAP message body and preserved in reply chain - Fix: refresh not showing new emails immediately — pressing
Rnow correctly displays new emails on first refresh; previously the IMAP client cached the selected mailbox state, so the firstRwould skip re-SELECT and use stale UID SEARCH results (showing the old unread count but no new messages in the list); required a secondRor 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)
- HTML signature support — new
[ui.signature_block]config with separatetextandhtmlfields 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 legacysignaturefield - 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 = truefor 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_filesupport for trusting Bridge’s exported self-signed certificate, and added a narrow localhost-only TLS retry fallback for Bridge connections on127.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
ToScreenscreening 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 withesc— pressingescnow reliably clears the in-memory inbox filter even after the filter was already applied - Help overlay improvements —
?help is now scrollable withj/k, arrow keys, andd/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
yaziand the externalcustom.luaintegration as optional requirements for<leader>a, while clarifying that pre-sendastill works independently - UX hints — inbox footer now exposes
, sort; pre-send footer clarifiessas spell-check-and-edit versus plaineedit; compose/pre-sendctrl+fnow 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 inToScreennow 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, orSpamare configured to the same IMAP folder as Trash - Inbox paging clarity — the inbox header now shows the current fetch limit (
loaded/limit) andd/upage movement directly, so the “only 50 emails” behavior is visible without guessing - Discard confirmation for unsent mail —
escin compose andesc/xin 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-allscans the full Inbox on the IMAP server - Compose/draft round-trip preservation — editor/pre-send/draft/recover flows now preserve
Bccand selectedFrom; 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 theBccheader 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
:searchhelp text fixed — the command description now correctly says it searches across configured folders, not just the current folder
- Security: path traversal vulnerability fixed — inline image handling (
Obrowser preview) now sanitizesContentIDandFilenamefrom email MIME headers to prevent attackers from writing files outside/tmp/neomd/via maliciouscid:references (e.g.../../etc/cron.d/evil); all attachment paths now usefilepath.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;imapSearchResultsflag is cleared immediately so the status bar appears instead of the search bar; added general Esc handler foroffTabFolderviews that preserves search context: pressing Esc from thread view returns to IMAP search results if that's where you came from (checked viaimapSearchText), otherwise returns to active folder;imapSearchTextis 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 becauseimapSearchResultsis only set by the handler on success - Fix: Work folder move guard —
Mb(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 help —
gb(go to Work) andMb(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")
- Fix: non-standard IMAP/SMTP ports — neomd now correctly handles non-standard ports (e.g., Proton Mail Bridge on
127.0.0.1:1143and127.0.0.1:1025); previously hardcoded port-based logic ignored the user'sstarttlsconfig and refused unencrypted connections to any port other than 993/143 (IMAP) or 465/587 (SMTP); new behavior: user's explicitstarttls = truealways 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 setstarttls = trueif 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
- Fix: pre-send
elosing email body — pressingein 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 viadraft_backup_countin[ui], set to-1to disable); no more lost emails after crashes or accidental closes :recover/:reccommand — 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-toscreento 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 veton 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 withfrom:/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 somake teststays fast and offline - Fix: multiple To recipients —
Send()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+r—R(Shift+R) is now consistently reload/refresh in all views; reply-all moved toctrl+rwhich works from both inbox list and reader (previouslyRconflicted 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\Answeredflag so it works across clients (reply from webmail → neomd shows it) \Answeredflag on reply — after sending a reply, the original email is automatically marked as\Answeredon the IMAP server- Conversation thread view (
T/:thread) — pressTfrom 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) — optionalwork = "Work"in[folders]config; add"work"totab_orderto show as a tab;gbto go,Mbto move; auto-created on first run if configured; included in Everything, Search, and conversation views - Inline images in browser preview — pressing
Oto 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_editorconfig option — optionalcompose_editorin[ui]to use a different editor for compose/reply/forward (e.g."nvim --appname nvim-wp"); defaults to$EDITOR/nvim
- OAuth2 authentication (#3, thanks @notthatjesus) — accounts can set
auth_type = "oauth2"withoauth2_client_id,oauth2_client_secret,oauth2_issuer_url, andoauth2_scopesinstead 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_bccconfig — root-levelauto_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 entryshift+tabin 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
- 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.comreplies fromsimon@domain.com);rnow works from inbox list view;# [neomd: from: ...]shown in editor;xin 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
.txtfiles 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-resetnever touches production data - Added benchmark to readme as Gmail was considerly slower than my IMAP provider here from Switzerland.
- Threaded inbox — related emails are automatically grouped in the inbox list with a Twitter-style vertical connector line (
│/╰); threads detected viaIn-Reply-To/Message-IDIMAP envelope headers with a reply-prefix subject fallback (only emails withRe:,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/[sto navigate errors,z=for suggestions,zgto add to dictionary; corrected body flows back to pre-send :debug/:dbgcommand — 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.logfor sharing; no sensitive data (passwords, full emails) included- Drafts show recipient — Drafts folder now shows
→ recipientinstead of From (same as Sent tab), since all drafts are from you ctrl+bin pre-send — toggle CC/BCC fields from the pre-send review screen (previously only available during compose)u/Urebind —uis now free for page-up (vim-style half-page scroll);Uis undo last move/delete;ctrl+uclears 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 (alongsideL/Handtab/shift+tab)
- 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; pressescto close results - Filter preserves across actions — the local
/filter no longer clears when pressingn(toggle read),m(mark),U(clear marks), or sorting; filter stays active untilesc - Address autocomplete in compose — To, Cc, and Bcc fields show autocomplete suggestions from screener lists (
screened_in.txt,feed.txt,papertrail.txt); navigate withctrl+n/ctrl+p/arrows, accept withtab; supports multi-address fields (autocomplete applies after the last comma) - Everything view (
geor: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-9in reader) — links are extracted from the email body, numbered[1]-[0]in the header; pressspacethen 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 footer —
E draftnow 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 indocs/android.mdwith 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
- 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.shas an alias through an existing account's SMTP); cycle through all accounts + senders withctrl+fin both compose and pre-send screens; theaccount =field matches by accountname =(not email address) - Sent folder — after sending, neomd APPENDs a copy to the configured Sent IMAP folder with
\Seenflag; 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; press1–9to download attachment N to~/Downloads/and open it withxdg-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 with1–9; previously onlyContent-Disposition: attachmentparts 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 / delete —
ureverses 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.txtlist 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 calleditor.ParseHeaderson the saved file content ctrl+ffor cycling From — changed fromf(which conflicts with typing in text fields) toctrl+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 emptyTo: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-shownensures it only appears once - Auto-create IMAP folders on startup —
ensureFoldersCmdruns duringInit()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/Pon 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 Makefile —
make build/make installnow prints clear Go installation instructions instead of a cryptic error whengois not found - Pre-send preview (
p) — presspin 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 tofile://URLs so inline images from[attach]lines display correctly
- 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+bin compose; BCC recipients receive the email but are not visible in the message headers (standard BCC privacy) - Reply-all —
Rin the reader replies to the original sender + all CC recipients; your own address is excluded automatically; usesReply-Toheader when present - Pre-send review screen — after closing the editor, neomd shows a summary (To, Subject, body preview) before sending; press
enterto send,ato attach files via yazi (auto-detected, no config needed; override with$NEOMD_FILE_PICKER),Dto remove last attachment,dto save to Drafts,eto re-open the editor,escto cancel; avoids tmux/terminal key-capture issues sinceaneeds no modifier - Save to Drafts —
din the pre-send screen APPENDs the composed message to the configured Drafts IMAP folder with\Draft+\Seenflags; navigate to it withgd - Attachments from neovim —
<leader>ain aneomd-*.mdbuffer opens yazi in a floating terminal; selected files are inserted as[attach] /path/to/filelines (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)
gdDrafts navigation — jump to Drafts folder withgdeven 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 tohttp/httpsonly before opening in browser (case-insensitive, RFC 3986);SECURITY.mdadded documenting credential storage, TLS guarantees, screener list handling, and temp file lifecycle with links to source - Spam folder —
$marks a sender as spam (writes tospam.txt, moves to Spam IMAP folder). Separate from ScreenedOut so you never have to look at it again. Navigate withgSor: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.txtediting needed :command history —↑/↓cycles through the last 5 distinct commands;→accepts the ghost completion;ctrl+n/ctrl+pcycle forward/backward through completions. Persists across restarts in~/.cache/neomd/cmd_history(outside dotfiles version control)- Leader key —
spaceis the leader;<space>1–<space>9jumps to a folder tab by number - Auto-screen on inbox load — screener applies automatically on every Inbox load (startup,
R). Disable withauto_screen_on_load = falsein[ui] - Background sync — inbox re-fetched and screened every 5 minutes while neomd is open. Configure with
bg_sync_intervalin[ui];0disables it n/mrebind —ntoggles read/unread (wasN);mmarks for batch ops (wasspace)
- Signature — auto-appended to new compose buffers; configure in
[ui]withsignature - Compose abort — closing the editor with
ZQ/:q!cancels the email; onlyZZ/:wqsends - Browser image workflow —
Oopens email as HTML in$BROWSER;ctrl+oopens the canonical web/newsletter URL (extracted fromList-Postheader);oopens in w3m :create-folders/:cf— creates any missing IMAP folders defined in config (idempotent)