···11# Changelog
2233+# 2026-04-17
44+- **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
55+- **`mark_as_read_after_secs` config** — new `[ui]` option to control mark-as-read delay in seconds (default 7); set to `0` for immediate marking (old behavior); set to any value to customize the delay
66+- **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
77+38# 2026-04-16
49- **`B` move to Work/business** — press `B` to move marked or cursor email(s) to Work folder (similar to `A` for Archive); quick single-key action without screener list updates; shows friendly error if Work folder not configured; useful for rapid GTD-style email processing; complements existing `gb` (go to Work) and `Mb` (move to Work) shortcuts
510- **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
+2-2
README.md
···5050 Inbox --> Process{Process Email<br/>GTD Decision}
51515252 Process -->|< 2 min?<br/>Do it now| Action[Reply/Handle<br/>Immediately]
5353- Process -->|Waiting for others<br/>Press mW| Waiting[⏳ Waiting]
5353+ Process -->|Waiting for others<br/>Press Mw| Waiting[⏳ Waiting]
5454 Process -->|Not now, later<br/>Press Mm| Someday[📅 Someday]
5555 Process -->|Time-specific<br/>Press Mc then c| Scheduled[🗓️ Scheduled]
5656 Process -->|Delete<br/>Press x| Trash[🗑️ Trash]
···7272 classDef folderStyle fill:#54546d,stroke:#7fb4ca,stroke-width:2px,color:#dcd7ba
7373 class ToScreen,Inbox,ScreenedOut,Feed,PaperTrail,Archive,Waiting,Someday,Scheduled,Trash folderStyle
7474```
7575-*Styled with Kanagawa colors - all boxes represent neomd folders*
7575+*all colored boxes represent neomd folders*
76767777**Key principles:**
7878- **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification
+59-1
docs/content/docs/_index.md
···25252626But we have two additional **Feed** and **Papertrail**, two dedicated folders from HEY where you can read newsletters (just hit F) on them automatically in their separate tab, or move all your receipts into the Papertrail. Once you mark them as feed or papertrail, they will moved there automatically going forward. So you decide whether to read emails or news by jumping to different tabs.
27272828-2928{{< callout type="info" >}}
3029neomd's **speed** depends entirely on your IMAP provider. On Hostpoint (the provider I use), a folder switch takes **~33ms** which feels instant. On Gmail, the same operation takes **~570ms** which is noticeably slow. See [Benchmark](#benchmark) for full details and how to test your provider.
3130{{< /callout >}}
3131+3232+3333+### Email Processing Workflow
3434+3535+Here's how neomd combines HEY-Screener + GTD + Feed/Papertrail to process your email:
3636+3737+```mermaid
3838+flowchart TD
3939+ Start([New Email Arrives]) --> AutoScreen{Auto-Screener<br/>Known Sender?}
4040+4141+ AutoScreen -->|screened_in.txt| Inbox["📥 Inbox (Next)"]
4242+ AutoScreen -->|screened_out.txt| ScreenedOut[🚫 ScreenedOut]
4343+ AutoScreen -->|feed.txt| Feed[📰 Feed]
4444+ AutoScreen -->|papertrail.txt| PaperTrail[🧾 PaperTrail]
4545+ AutoScreen -->|Unknown| ToScreen[❓ ToScreen]
4646+4747+ ToScreen -->|Press I| ClassifyIn[Add to screened_in.txt]
4848+ ToScreen -->|Press O| ClassifyOut[Add to screened_out.txt]
4949+ ToScreen -->|Press F| ClassifyFeed[Add to feed.txt]
5050+ ToScreen -->|Press P| ClassifyPaper[Add to papertrail.txt]
5151+5252+ ClassifyIn --> Inbox
5353+ ClassifyOut --> ScreenedOut
5454+ ClassifyFeed --> Feed
5555+ ClassifyPaper --> PaperTrail
5656+5757+ Inbox --> Process{Process Email<br/>GTD Decision}
5858+5959+ Process -->|< 2 min?<br/>Do it now| Action[Reply/Handle<br/>Immediately]
6060+ Process -->|Waiting for others<br/>Press Mw| Waiting[⏳ Waiting]
6161+ Process -->|Not now, later<br/>Press Mm| Someday[📅 Someday]
6262+ Process -->|Time-specific<br/>Press Mc then c| Scheduled[🗓️ Scheduled]
6363+ Process -->|Delete<br/>Press x| Trash[🗑️ Trash]
6464+ Process -->|Reference only<br/>Press Mp or P| PaperTrail
6565+ Process -->|Newsletter<br/>Press F or Mf| Feed
6666+6767+ Action --> Done{Done?}
6868+ Waiting --> Review[Review Later]
6969+ Someday --> Review
7070+ Scheduled --> Review
7171+ Feed --> ReadLater[Read in<br/>Feed Tab]
7272+ PaperTrail --> SearchLater[Search when<br/>needed]
7373+7474+ Done -->|Yes| Archive[📦 Archive]
7575+ Done -->|Not actionable| Archive
7676+ Review --> Archive
7777+ ReadLater --> Archive
7878+7979+ classDef folderStyle fill:#54546d,stroke:#7fb4ca,stroke-width:2px,color:#dcd7ba
8080+ class ToScreen,Inbox,ScreenedOut,Feed,PaperTrail,Archive,Waiting,Someday,Scheduled,Trash folderStyle
8181+```
8282+*all colored boxes represent neomd folders*
8383+8484+**Key principles:**
8585+- **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification
8686+- **One-time decision**: Once you classify a sender (`I/O/F/P`), all future emails from them are automatically routed
8787+- **GTD processing**: Emails in Inbox are processed once — if < 2 min, do it or keep it in inbox as doing *Next* otherwise move to Waiting, Someday, or Scheduled
8888+- **Minimal filing**: Only Archive when done; no complex folder hierarchies — use search to find old emails
8989+- **Separate contexts**: Feed for newsletters (read when you want), PaperTrail for receipts (search when needed)
329033913492## Screenshots
+1
docs/content/docs/configuration.md
···7676bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5)
7777bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10)
7878draft_backup_count = 20 # rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
7979+mark_as_read_after_secs = 7 # seconds in reader before marking as read; 0 = immediate (default 7)
7980signature = """**Your Name**
8081Your Title, Your Company
8182
+28
docs/content/docs/reading.md
···103103104104Also available as `:thread` (alias `:t`) from the command line.
105105106106+## Mark as Read Behavior
107107+108108+Neomd marks emails as read **after you've spent time viewing them**, not immediately when opened. This prevents accidental marking when quickly peeking at emails.
109109+110110+**How it works:**
111111+112112+- When you open an email (press `enter` or `l`), neomd fetches the full body from IMAP
113113+- Once the body loads, a **timer starts** (default: 7 seconds)
114114+- If you stay in the reader for the full duration, the email is marked as `\Seen` on the server
115115+- If you exit early (press `h`, `q`, `esc`, or `T`), the email **stays unread**
116116+117117+**Configuration:**
118118+119119+```toml
120120+[ui]
121121+mark_as_read_after_secs = 7 # wait 7 seconds (default)
122122+# mark_as_read_after_secs = 0 # immediate marking (no timer)
123123+# mark_as_read_after_secs = 10 # 10 seconds
124124+```
125125+126126+Set to `0` for immediate marking (as soon as the body finishes loading). Set to any value in seconds to customize the delay.
127127+128128+**UI behavior:**
129129+130130+- The local inbox list updates immediately when an email is marked as read — no need to manually refresh
131131+- The unread indicator (`N`) disappears as soon as marking completes
132132+- Manual toggle with `n` still works to mark/unmark emails at any time
133133+106134## Reply Indicator
107135108136Emails you've replied to show a `·` dot in the inbox list. This uses the standard IMAP `\Answered` flag, so it works across clients — if you reply from webmail, neomd shows it too.
+6-4
internal/config/config.go
···134134 BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5)
135135 BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10)
136136 DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
137137+ MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7)
137138}
138139139140// TextSignature returns the text/markdown signature for editor and text/plain part.
···360361 Spam: "Spam",
361362 },
362363 UI: UIConfig{
363363- Theme: "dark",
364364- InboxCount: 200,
365365- BgSyncInterval: 5,
366366- Signature: "*sent from [neomd](https://neomd.ssp.sh)*",
364364+ Theme: "dark",
365365+ InboxCount: 200,
366366+ BgSyncInterval: 5,
367367+ MarkAsReadAfterSecs: 7,
368368+ Signature: "*sent from [neomd](https://neomd.ssp.sh)*",
367369 },
368370 }
369371}
+83
internal/integration_test.go
···664664 t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC)
665665}
666666667667+func TestIntegration_MarkAsRead(t *testing.T) {
668668+ env := loadEnv(t)
669669+ cli := env.imapClient()
670670+ defer cli.Close()
671671+672672+ subject := uniqueSubject("mark-as-read")
673673+ body := "Testing mark-as-read functionality."
674674+675675+ // Send test email to self
676676+ err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
677677+ if err != nil {
678678+ t.Fatalf("Send: %v", err)
679679+ }
680680+681681+ // Wait for delivery
682682+ email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
683683+ defer cleanupEmail(t, cli, "INBOX", email.UID)
684684+685685+ // Initially unread
686686+ if email.Seen {
687687+ t.Error("newly delivered email should be unread (Seen=false)")
688688+ }
689689+690690+ // Mark as seen
691691+ ctx := context.Background()
692692+ err = cli.MarkSeen(ctx, "INBOX", email.UID)
693693+ if err != nil {
694694+ t.Fatalf("MarkSeen: %v", err)
695695+ }
696696+697697+ // Re-fetch to verify flag changed
698698+ emails, err := cli.FetchHeaders(ctx, "INBOX", 20)
699699+ if err != nil {
700700+ t.Fatalf("FetchHeaders after MarkSeen: %v", err)
701701+ }
702702+703703+ var found *goIMAP.Email
704704+ for i := range emails {
705705+ if emails[i].UID == email.UID {
706706+ found = &emails[i]
707707+ break
708708+ }
709709+ }
710710+711711+ if found == nil {
712712+ t.Fatal("email not found after MarkSeen")
713713+ }
714714+715715+ if !found.Seen {
716716+ t.Error("email still unread after MarkSeen call")
717717+ }
718718+719719+ // Test MarkUnseen
720720+ err = cli.MarkUnseen(ctx, "INBOX", email.UID)
721721+ if err != nil {
722722+ t.Fatalf("MarkUnseen: %v", err)
723723+ }
724724+725725+ // Re-fetch to verify flag cleared
726726+ emails, err = cli.FetchHeaders(ctx, "INBOX", 20)
727727+ if err != nil {
728728+ t.Fatalf("FetchHeaders after MarkUnseen: %v", err)
729729+ }
730730+731731+ found = nil
732732+ for i := range emails {
733733+ if emails[i].UID == email.UID {
734734+ found = &emails[i]
735735+ break
736736+ }
737737+ }
738738+739739+ if found == nil {
740740+ t.Fatal("email not found after MarkUnseen")
741741+ }
742742+743743+ if found.Seen {
744744+ t.Error("email still marked as read after MarkUnseen call")
745745+ }
746746+747747+ t.Logf("Mark-as-read round-trip successful: UID=%d", email.UID)
748748+}
749749+667750// --- Helpers ---
668751669752func extractUser(from string) string {
+66-2
internal/ui/model.go
···112112 bgSyncTickMsg struct{}
113113 bgInboxFetchedMsg struct{ emails []imap.Email }
114114 bgScreenDoneMsg struct{ moved, total int }
115115+ // mark-as-read timer (fires after N seconds in reader)
116116+ markAsReadTimerMsg struct {
117117+ uid uint32
118118+ folder string
119119+ }
115120 // attachPickDoneMsg carries paths selected via the file picker (yazi etc.)
116121 attachPickDoneMsg struct{ paths []string }
117122 // bulkProgressMsg is sent during long-running batch operations to update the status bar.
···450455 openAttachments []imap.Attachment // attachments of the currently open email
451456 openLinks []emailLink // extracted links from the email body
452457 readerPending string // chord prefix in reader (space for link open)
458458+ // Mark-as-read timer tracking
459459+ markAsReadUID uint32 // UID of email with pending mark-as-read timer
460460+ markAsReadFolder string // folder of email with pending mark-as-read timer
453461454462 // Compose / pre-send
455463 compose composeModel
···14341442 return tea.Tick(time.Duration(mins)*time.Minute, func(time.Time) tea.Msg { return bgSyncTickMsg{} })
14351443}
1436144414451445+// scheduleMarkAsReadTimer returns a Cmd that fires markAsReadTimerMsg after the configured
14461446+// delay. Returns nil (no-op) when mark_as_read_after_secs = 0 (immediate marking).
14471447+func (m Model) scheduleMarkAsReadTimer(uid uint32, folder string) tea.Cmd {
14481448+ secs := m.cfg.UI.MarkAsReadAfterSecs
14491449+ if secs <= 0 {
14501450+ return nil // immediate marking handled elsewhere
14511451+ }
14521452+ return tea.Tick(time.Duration(secs)*time.Second, func(time.Time) tea.Msg {
14531453+ return markAsReadTimerMsg{uid: uid, folder: folder}
14541454+ })
14551455+}
14561456+14371457// bgFetchInboxCmd silently fetches inbox headers for background screening.
14381458// Errors are swallowed — a transient network hiccup shouldn't disrupt the UI.
14391459func (m Model) bgFetchInboxCmd() tea.Cmd {
···16411661 if msg.email != nil {
16421662 msg.email.References = msg.references
16431663 }
16441644- // Mark as seen in background (best-effort)
16641664+ // Mark as seen: either immediately (if config = 0) or after timer
16451665 uid := msg.email.UID
16461666 folder := msg.email.Folder
16471647- go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }()
16671667+ if m.cfg.UI.MarkAsReadAfterSecs <= 0 {
16681668+ // Immediate marking (config = 0)
16691669+ go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }()
16701670+ // Update local state immediately
16711671+ for i := range m.emails {
16721672+ if m.emails[i].UID == uid && m.emails[i].Folder == folder {
16731673+ m.emails[i].Seen = true
16741674+ break
16751675+ }
16761676+ }
16771677+ } else {
16781678+ // Schedule timer-based marking
16791679+ m.markAsReadUID = uid
16801680+ m.markAsReadFolder = folder
16811681+ }
16481682 if m.pendingForward {
16491683 m.pendingForward = false
16501684 return m.launchForwardCmd()
···16641698 m.openLinks = extractLinks(msg.body)
16651699 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width)
16661700 m.state = stateReading
17011701+ // Start mark-as-read timer if configured
17021702+ if m.cfg.UI.MarkAsReadAfterSecs > 0 {
17031703+ return m, m.scheduleMarkAsReadTimer(uid, folder)
17041704+ }
16671705 return m, nil
1668170616691707 case sendDoneMsg:
···17431781 }
17441782 }
17451783 return m, m.applyFilter()
17841784+17851785+ case markAsReadTimerMsg:
17861786+ // Timer fired - mark email as read if user is still viewing it
17871787+ if m.state == stateReading && m.markAsReadUID == msg.uid && m.markAsReadFolder == msg.folder {
17881788+ // Still viewing the same email - mark it as read
17891789+ go func() { _ = m.imapCli().MarkSeen(nil, msg.folder, msg.uid) }()
17901790+ // Update local state immediately
17911791+ for i := range m.emails {
17921792+ if m.emails[i].UID == msg.uid && m.emails[i].Folder == msg.folder {
17931793+ m.emails[i].Seen = true
17941794+ break
17951795+ }
17961796+ }
17971797+ // Clear timer state
17981798+ m.markAsReadUID = 0
17991799+ m.markAsReadFolder = ""
18001800+ return m, m.applyFilter()
18011801+ }
18021802+ // User navigated away - ignore timer
18031803+ return m, nil
1746180417471805 case imapSearchResultMsg:
17481806 return m.handleIMAPSearchResult(msg)
···28792937 case "q", "esc", "h":
28802938 m.state = stateInbox
28812939 m.readerPending = ""
29402940+ // Clear mark-as-read timer state when exiting reader
29412941+ m.markAsReadUID = 0
29422942+ m.markAsReadFolder = ""
28822943 return m, nil
28832944 case "e":
28842945 return m.openInNeovim()
···29142975 if m.openEmail != nil {
29152976 m.loading = true
29162977 m.state = stateInbox
29782978+ // Clear mark-as-read timer state when switching to conversation view
29792979+ m.markAsReadUID = 0
29802980+ m.markAsReadFolder = ""
29172981 return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(m.openEmail))
29182982 }
29192983 case "1", "2", "3", "4", "5", "6", "7", "8", "9":
+112
internal/ui/model_test.go
···322322 }
323323}
324324325325+func TestMarkAsReadTimer(t *testing.T) {
326326+ t.Run("config determines marking behavior", func(t *testing.T) {
327327+ tests := []struct {
328328+ name string
329329+ configSec int
330330+ wantTimer bool
331331+ }{
332332+ {"immediate when 0", 0, false},
333333+ {"timer when > 0", 7, true},
334334+ {"timer when custom", 15, true},
335335+ }
336336+337337+ for _, tt := range tests {
338338+ t.Run(tt.name, func(t *testing.T) {
339339+ cfg := &config.Config{
340340+ UI: config.UIConfig{
341341+ MarkAsReadAfterSecs: tt.configSec,
342342+ },
343343+ }
344344+345345+ if (cfg.UI.MarkAsReadAfterSecs > 0) != tt.wantTimer {
346346+ t.Errorf("MarkAsReadAfterSecs=%d should trigger timer=%v", tt.configSec, tt.wantTimer)
347347+ }
348348+ })
349349+ }
350350+ })
351351+352352+ t.Run("timer state management", func(t *testing.T) {
353353+ m := Model{
354354+ cfg: &config.Config{
355355+ UI: config.UIConfig{
356356+ MarkAsReadAfterSecs: 7,
357357+ },
358358+ },
359359+ }
360360+361361+ // Initially empty
362362+ if m.markAsReadUID != 0 || m.markAsReadFolder != "" {
363363+ t.Errorf("initial timer state should be empty")
364364+ }
365365+366366+ // Set timer state (simulates bodyLoadedMsg behavior)
367367+ m.markAsReadUID = 123
368368+ m.markAsReadFolder = "INBOX"
369369+370370+ if m.markAsReadUID != 123 || m.markAsReadFolder != "INBOX" {
371371+ t.Errorf("timer state not set correctly: uid=%d folder=%q", m.markAsReadUID, m.markAsReadFolder)
372372+ }
373373+374374+ // Clear timer state (simulates exit reader or timer completion)
375375+ m.markAsReadUID = 0
376376+ m.markAsReadFolder = ""
377377+378378+ if m.markAsReadUID != 0 || m.markAsReadFolder != "" {
379379+ t.Errorf("timer state not cleared: uid=%d folder=%q", m.markAsReadUID, m.markAsReadFolder)
380380+ }
381381+ })
382382+383383+ t.Run("timer ignored when user exits reader early", func(t *testing.T) {
384384+ m := Model{
385385+ cfg: &config.Config{
386386+ UI: config.UIConfig{
387387+ MarkAsReadAfterSecs: 7,
388388+ },
389389+ },
390390+ state: stateInbox, // user exited reader
391391+ emails: []imap.Email{
392392+ {UID: 123, Folder: "INBOX", Seen: false},
393393+ },
394394+ markAsReadUID: 0, // cleared when exiting reader
395395+ markAsReadFolder: "",
396396+ }
397397+398398+ // Timer fires but user already left reader
399399+ msg := markAsReadTimerMsg{uid: 123, folder: "INBOX"}
400400+ _, _ = m.Update(msg)
401401+402402+ // Email should still be unread
403403+ if m.emails[0].Seen {
404404+ t.Errorf("email marked as seen even though user exited reader")
405405+ }
406406+ })
407407+408408+ t.Run("timer state cleared when exiting reader", func(t *testing.T) {
409409+ m := Model{
410410+ cfg: &config.Config{
411411+ UI: config.UIConfig{
412412+ MarkAsReadAfterSecs: 7,
413413+ },
414414+ },
415415+ state: stateReading,
416416+ markAsReadUID: 123,
417417+ markAsReadFolder: "INBOX",
418418+ }
419419+420420+ // User presses 'q' to exit reader
421421+ msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
422422+ updated, _ := m.updateReader(msg)
423423+ m = updated.(Model)
424424+425425+ // Timer state should be cleared
426426+ if m.markAsReadUID != 0 || m.markAsReadFolder != "" {
427427+ t.Errorf("timer state not cleared when exiting reader")
428428+ }
429429+430430+ // State should be back to inbox
431431+ if m.state != stateInbox {
432432+ t.Errorf("state not returned to inbox")
433433+ }
434434+ })
435435+}
436436+325437func TestHandleEverythingResultKeepsRealSubject(t *testing.T) {
326438 m := Model{
327439 inbox: newInboxList(80, 20, "", ""),