A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

read timer and fix update UI when mark as read on server

sspaeti 3b08db86 6259ba83

+362 -9
+5
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-17 4 + - **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 5 + - **`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 6 + - **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 7 + 3 8 # 2026-04-16 4 9 - **`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 5 10 - **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
··· 50 50 Inbox --> Process{Process Email<br/>GTD Decision} 51 51 52 52 Process -->|< 2 min?<br/>Do it now| Action[Reply/Handle<br/>Immediately] 53 - Process -->|Waiting for others<br/>Press mW| Waiting[⏳ Waiting] 53 + Process -->|Waiting for others<br/>Press Mw| Waiting[⏳ Waiting] 54 54 Process -->|Not now, later<br/>Press Mm| Someday[📅 Someday] 55 55 Process -->|Time-specific<br/>Press Mc then c| Scheduled[🗓️ Scheduled] 56 56 Process -->|Delete<br/>Press x| Trash[🗑️ Trash] ··· 72 72 classDef folderStyle fill:#54546d,stroke:#7fb4ca,stroke-width:2px,color:#dcd7ba 73 73 class ToScreen,Inbox,ScreenedOut,Feed,PaperTrail,Archive,Waiting,Someday,Scheduled,Trash folderStyle 74 74 ``` 75 - *Styled with Kanagawa colors - all boxes represent neomd folders* 75 + *all colored boxes represent neomd folders* 76 76 77 77 **Key principles:** 78 78 - **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification
+59 -1
docs/content/docs/_index.md
··· 25 25 26 26 But 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. 27 27 28 - 29 28 {{< callout type="info" >}} 30 29 neomd'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. 31 30 {{< /callout >}} 31 + 32 + 33 + ### Email Processing Workflow 34 + 35 + Here's how neomd combines HEY-Screener + GTD + Feed/Papertrail to process your email: 36 + 37 + ```mermaid 38 + flowchart TD 39 + Start([New Email Arrives]) --> AutoScreen{Auto-Screener<br/>Known Sender?} 40 + 41 + AutoScreen -->|screened_in.txt| Inbox["📥 Inbox (Next)"] 42 + AutoScreen -->|screened_out.txt| ScreenedOut[🚫 ScreenedOut] 43 + AutoScreen -->|feed.txt| Feed[📰 Feed] 44 + AutoScreen -->|papertrail.txt| PaperTrail[🧾 PaperTrail] 45 + AutoScreen -->|Unknown| ToScreen[❓ ToScreen] 46 + 47 + ToScreen -->|Press I| ClassifyIn[Add to screened_in.txt] 48 + ToScreen -->|Press O| ClassifyOut[Add to screened_out.txt] 49 + ToScreen -->|Press F| ClassifyFeed[Add to feed.txt] 50 + ToScreen -->|Press P| ClassifyPaper[Add to papertrail.txt] 51 + 52 + ClassifyIn --> Inbox 53 + ClassifyOut --> ScreenedOut 54 + ClassifyFeed --> Feed 55 + ClassifyPaper --> PaperTrail 56 + 57 + Inbox --> Process{Process Email<br/>GTD Decision} 58 + 59 + Process -->|< 2 min?<br/>Do it now| Action[Reply/Handle<br/>Immediately] 60 + Process -->|Waiting for others<br/>Press Mw| Waiting[⏳ Waiting] 61 + Process -->|Not now, later<br/>Press Mm| Someday[📅 Someday] 62 + Process -->|Time-specific<br/>Press Mc then c| Scheduled[🗓️ Scheduled] 63 + Process -->|Delete<br/>Press x| Trash[🗑️ Trash] 64 + Process -->|Reference only<br/>Press Mp or P| PaperTrail 65 + Process -->|Newsletter<br/>Press F or Mf| Feed 66 + 67 + Action --> Done{Done?} 68 + Waiting --> Review[Review Later] 69 + Someday --> Review 70 + Scheduled --> Review 71 + Feed --> ReadLater[Read in<br/>Feed Tab] 72 + PaperTrail --> SearchLater[Search when<br/>needed] 73 + 74 + Done -->|Yes| Archive[📦 Archive] 75 + Done -->|Not actionable| Archive 76 + Review --> Archive 77 + ReadLater --> Archive 78 + 79 + classDef folderStyle fill:#54546d,stroke:#7fb4ca,stroke-width:2px,color:#dcd7ba 80 + class ToScreen,Inbox,ScreenedOut,Feed,PaperTrail,Archive,Waiting,Someday,Scheduled,Trash folderStyle 81 + ``` 82 + *all colored boxes represent neomd folders* 83 + 84 + **Key principles:** 85 + - **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification 86 + - **One-time decision**: Once you classify a sender (`I/O/F/P`), all future emails from them are automatically routed 87 + - **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 88 + - **Minimal filing**: Only Archive when done; no complex folder hierarchies — use search to find old emails 89 + - **Separate contexts**: Feed for newsletters (read when you want), PaperTrail for receipts (search when needed) 32 90 33 91 34 92 ## Screenshots
+1
docs/content/docs/configuration.md
··· 76 76 bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5) 77 77 bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10) 78 78 draft_backup_count = 20 # rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 79 + mark_as_read_after_secs = 7 # seconds in reader before marking as read; 0 = immediate (default 7) 79 80 signature = """**Your Name** 80 81 Your Title, Your Company 81 82
+28
docs/content/docs/reading.md
··· 103 103 104 104 Also available as `:thread` (alias `:t`) from the command line. 105 105 106 + ## Mark as Read Behavior 107 + 108 + 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. 109 + 110 + **How it works:** 111 + 112 + - When you open an email (press `enter` or `l`), neomd fetches the full body from IMAP 113 + - Once the body loads, a **timer starts** (default: 7 seconds) 114 + - If you stay in the reader for the full duration, the email is marked as `\Seen` on the server 115 + - If you exit early (press `h`, `q`, `esc`, or `T`), the email **stays unread** 116 + 117 + **Configuration:** 118 + 119 + ```toml 120 + [ui] 121 + mark_as_read_after_secs = 7 # wait 7 seconds (default) 122 + # mark_as_read_after_secs = 0 # immediate marking (no timer) 123 + # mark_as_read_after_secs = 10 # 10 seconds 124 + ``` 125 + 126 + Set to `0` for immediate marking (as soon as the body finishes loading). Set to any value in seconds to customize the delay. 127 + 128 + **UI behavior:** 129 + 130 + - The local inbox list updates immediately when an email is marked as read — no need to manually refresh 131 + - The unread indicator (`N`) disappears as soon as marking completes 132 + - Manual toggle with `n` still works to mark/unmark emails at any time 133 + 106 134 ## Reply Indicator 107 135 108 136 Emails 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
··· 134 134 BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 135 135 BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 136 136 DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 137 + MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7) 137 138 } 138 139 139 140 // TextSignature returns the text/markdown signature for editor and text/plain part. ··· 360 361 Spam: "Spam", 361 362 }, 362 363 UI: UIConfig{ 363 - Theme: "dark", 364 - InboxCount: 200, 365 - BgSyncInterval: 5, 366 - Signature: "*sent from [neomd](https://neomd.ssp.sh)*", 364 + Theme: "dark", 365 + InboxCount: 200, 366 + BgSyncInterval: 5, 367 + MarkAsReadAfterSecs: 7, 368 + Signature: "*sent from [neomd](https://neomd.ssp.sh)*", 367 369 }, 368 370 } 369 371 }
+83
internal/integration_test.go
··· 664 664 t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC) 665 665 } 666 666 667 + func TestIntegration_MarkAsRead(t *testing.T) { 668 + env := loadEnv(t) 669 + cli := env.imapClient() 670 + defer cli.Close() 671 + 672 + subject := uniqueSubject("mark-as-read") 673 + body := "Testing mark-as-read functionality." 674 + 675 + // Send test email to self 676 + err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) 677 + if err != nil { 678 + t.Fatalf("Send: %v", err) 679 + } 680 + 681 + // Wait for delivery 682 + email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) 683 + defer cleanupEmail(t, cli, "INBOX", email.UID) 684 + 685 + // Initially unread 686 + if email.Seen { 687 + t.Error("newly delivered email should be unread (Seen=false)") 688 + } 689 + 690 + // Mark as seen 691 + ctx := context.Background() 692 + err = cli.MarkSeen(ctx, "INBOX", email.UID) 693 + if err != nil { 694 + t.Fatalf("MarkSeen: %v", err) 695 + } 696 + 697 + // Re-fetch to verify flag changed 698 + emails, err := cli.FetchHeaders(ctx, "INBOX", 20) 699 + if err != nil { 700 + t.Fatalf("FetchHeaders after MarkSeen: %v", err) 701 + } 702 + 703 + var found *goIMAP.Email 704 + for i := range emails { 705 + if emails[i].UID == email.UID { 706 + found = &emails[i] 707 + break 708 + } 709 + } 710 + 711 + if found == nil { 712 + t.Fatal("email not found after MarkSeen") 713 + } 714 + 715 + if !found.Seen { 716 + t.Error("email still unread after MarkSeen call") 717 + } 718 + 719 + // Test MarkUnseen 720 + err = cli.MarkUnseen(ctx, "INBOX", email.UID) 721 + if err != nil { 722 + t.Fatalf("MarkUnseen: %v", err) 723 + } 724 + 725 + // Re-fetch to verify flag cleared 726 + emails, err = cli.FetchHeaders(ctx, "INBOX", 20) 727 + if err != nil { 728 + t.Fatalf("FetchHeaders after MarkUnseen: %v", err) 729 + } 730 + 731 + found = nil 732 + for i := range emails { 733 + if emails[i].UID == email.UID { 734 + found = &emails[i] 735 + break 736 + } 737 + } 738 + 739 + if found == nil { 740 + t.Fatal("email not found after MarkUnseen") 741 + } 742 + 743 + if found.Seen { 744 + t.Error("email still marked as read after MarkUnseen call") 745 + } 746 + 747 + t.Logf("Mark-as-read round-trip successful: UID=%d", email.UID) 748 + } 749 + 667 750 // --- Helpers --- 668 751 669 752 func extractUser(from string) string {
+66 -2
internal/ui/model.go
··· 112 112 bgSyncTickMsg struct{} 113 113 bgInboxFetchedMsg struct{ emails []imap.Email } 114 114 bgScreenDoneMsg struct{ moved, total int } 115 + // mark-as-read timer (fires after N seconds in reader) 116 + markAsReadTimerMsg struct { 117 + uid uint32 118 + folder string 119 + } 115 120 // attachPickDoneMsg carries paths selected via the file picker (yazi etc.) 116 121 attachPickDoneMsg struct{ paths []string } 117 122 // bulkProgressMsg is sent during long-running batch operations to update the status bar. ··· 450 455 openAttachments []imap.Attachment // attachments of the currently open email 451 456 openLinks []emailLink // extracted links from the email body 452 457 readerPending string // chord prefix in reader (space for link open) 458 + // Mark-as-read timer tracking 459 + markAsReadUID uint32 // UID of email with pending mark-as-read timer 460 + markAsReadFolder string // folder of email with pending mark-as-read timer 453 461 454 462 // Compose / pre-send 455 463 compose composeModel ··· 1434 1442 return tea.Tick(time.Duration(mins)*time.Minute, func(time.Time) tea.Msg { return bgSyncTickMsg{} }) 1435 1443 } 1436 1444 1445 + // scheduleMarkAsReadTimer returns a Cmd that fires markAsReadTimerMsg after the configured 1446 + // delay. Returns nil (no-op) when mark_as_read_after_secs = 0 (immediate marking). 1447 + func (m Model) scheduleMarkAsReadTimer(uid uint32, folder string) tea.Cmd { 1448 + secs := m.cfg.UI.MarkAsReadAfterSecs 1449 + if secs <= 0 { 1450 + return nil // immediate marking handled elsewhere 1451 + } 1452 + return tea.Tick(time.Duration(secs)*time.Second, func(time.Time) tea.Msg { 1453 + return markAsReadTimerMsg{uid: uid, folder: folder} 1454 + }) 1455 + } 1456 + 1437 1457 // bgFetchInboxCmd silently fetches inbox headers for background screening. 1438 1458 // Errors are swallowed — a transient network hiccup shouldn't disrupt the UI. 1439 1459 func (m Model) bgFetchInboxCmd() tea.Cmd { ··· 1641 1661 if msg.email != nil { 1642 1662 msg.email.References = msg.references 1643 1663 } 1644 - // Mark as seen in background (best-effort) 1664 + // Mark as seen: either immediately (if config = 0) or after timer 1645 1665 uid := msg.email.UID 1646 1666 folder := msg.email.Folder 1647 - go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }() 1667 + if m.cfg.UI.MarkAsReadAfterSecs <= 0 { 1668 + // Immediate marking (config = 0) 1669 + go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }() 1670 + // Update local state immediately 1671 + for i := range m.emails { 1672 + if m.emails[i].UID == uid && m.emails[i].Folder == folder { 1673 + m.emails[i].Seen = true 1674 + break 1675 + } 1676 + } 1677 + } else { 1678 + // Schedule timer-based marking 1679 + m.markAsReadUID = uid 1680 + m.markAsReadFolder = folder 1681 + } 1648 1682 if m.pendingForward { 1649 1683 m.pendingForward = false 1650 1684 return m.launchForwardCmd() ··· 1664 1698 m.openLinks = extractLinks(msg.body) 1665 1699 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) 1666 1700 m.state = stateReading 1701 + // Start mark-as-read timer if configured 1702 + if m.cfg.UI.MarkAsReadAfterSecs > 0 { 1703 + return m, m.scheduleMarkAsReadTimer(uid, folder) 1704 + } 1667 1705 return m, nil 1668 1706 1669 1707 case sendDoneMsg: ··· 1743 1781 } 1744 1782 } 1745 1783 return m, m.applyFilter() 1784 + 1785 + case markAsReadTimerMsg: 1786 + // Timer fired - mark email as read if user is still viewing it 1787 + if m.state == stateReading && m.markAsReadUID == msg.uid && m.markAsReadFolder == msg.folder { 1788 + // Still viewing the same email - mark it as read 1789 + go func() { _ = m.imapCli().MarkSeen(nil, msg.folder, msg.uid) }() 1790 + // Update local state immediately 1791 + for i := range m.emails { 1792 + if m.emails[i].UID == msg.uid && m.emails[i].Folder == msg.folder { 1793 + m.emails[i].Seen = true 1794 + break 1795 + } 1796 + } 1797 + // Clear timer state 1798 + m.markAsReadUID = 0 1799 + m.markAsReadFolder = "" 1800 + return m, m.applyFilter() 1801 + } 1802 + // User navigated away - ignore timer 1803 + return m, nil 1746 1804 1747 1805 case imapSearchResultMsg: 1748 1806 return m.handleIMAPSearchResult(msg) ··· 2879 2937 case "q", "esc", "h": 2880 2938 m.state = stateInbox 2881 2939 m.readerPending = "" 2940 + // Clear mark-as-read timer state when exiting reader 2941 + m.markAsReadUID = 0 2942 + m.markAsReadFolder = "" 2882 2943 return m, nil 2883 2944 case "e": 2884 2945 return m.openInNeovim() ··· 2914 2975 if m.openEmail != nil { 2915 2976 m.loading = true 2916 2977 m.state = stateInbox 2978 + // Clear mark-as-read timer state when switching to conversation view 2979 + m.markAsReadUID = 0 2980 + m.markAsReadFolder = "" 2917 2981 return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(m.openEmail)) 2918 2982 } 2919 2983 case "1", "2", "3", "4", "5", "6", "7", "8", "9":
+112
internal/ui/model_test.go
··· 322 322 } 323 323 } 324 324 325 + func TestMarkAsReadTimer(t *testing.T) { 326 + t.Run("config determines marking behavior", func(t *testing.T) { 327 + tests := []struct { 328 + name string 329 + configSec int 330 + wantTimer bool 331 + }{ 332 + {"immediate when 0", 0, false}, 333 + {"timer when > 0", 7, true}, 334 + {"timer when custom", 15, true}, 335 + } 336 + 337 + for _, tt := range tests { 338 + t.Run(tt.name, func(t *testing.T) { 339 + cfg := &config.Config{ 340 + UI: config.UIConfig{ 341 + MarkAsReadAfterSecs: tt.configSec, 342 + }, 343 + } 344 + 345 + if (cfg.UI.MarkAsReadAfterSecs > 0) != tt.wantTimer { 346 + t.Errorf("MarkAsReadAfterSecs=%d should trigger timer=%v", tt.configSec, tt.wantTimer) 347 + } 348 + }) 349 + } 350 + }) 351 + 352 + t.Run("timer state management", func(t *testing.T) { 353 + m := Model{ 354 + cfg: &config.Config{ 355 + UI: config.UIConfig{ 356 + MarkAsReadAfterSecs: 7, 357 + }, 358 + }, 359 + } 360 + 361 + // Initially empty 362 + if m.markAsReadUID != 0 || m.markAsReadFolder != "" { 363 + t.Errorf("initial timer state should be empty") 364 + } 365 + 366 + // Set timer state (simulates bodyLoadedMsg behavior) 367 + m.markAsReadUID = 123 368 + m.markAsReadFolder = "INBOX" 369 + 370 + if m.markAsReadUID != 123 || m.markAsReadFolder != "INBOX" { 371 + t.Errorf("timer state not set correctly: uid=%d folder=%q", m.markAsReadUID, m.markAsReadFolder) 372 + } 373 + 374 + // Clear timer state (simulates exit reader or timer completion) 375 + m.markAsReadUID = 0 376 + m.markAsReadFolder = "" 377 + 378 + if m.markAsReadUID != 0 || m.markAsReadFolder != "" { 379 + t.Errorf("timer state not cleared: uid=%d folder=%q", m.markAsReadUID, m.markAsReadFolder) 380 + } 381 + }) 382 + 383 + t.Run("timer ignored when user exits reader early", func(t *testing.T) { 384 + m := Model{ 385 + cfg: &config.Config{ 386 + UI: config.UIConfig{ 387 + MarkAsReadAfterSecs: 7, 388 + }, 389 + }, 390 + state: stateInbox, // user exited reader 391 + emails: []imap.Email{ 392 + {UID: 123, Folder: "INBOX", Seen: false}, 393 + }, 394 + markAsReadUID: 0, // cleared when exiting reader 395 + markAsReadFolder: "", 396 + } 397 + 398 + // Timer fires but user already left reader 399 + msg := markAsReadTimerMsg{uid: 123, folder: "INBOX"} 400 + _, _ = m.Update(msg) 401 + 402 + // Email should still be unread 403 + if m.emails[0].Seen { 404 + t.Errorf("email marked as seen even though user exited reader") 405 + } 406 + }) 407 + 408 + t.Run("timer state cleared when exiting reader", func(t *testing.T) { 409 + m := Model{ 410 + cfg: &config.Config{ 411 + UI: config.UIConfig{ 412 + MarkAsReadAfterSecs: 7, 413 + }, 414 + }, 415 + state: stateReading, 416 + markAsReadUID: 123, 417 + markAsReadFolder: "INBOX", 418 + } 419 + 420 + // User presses 'q' to exit reader 421 + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}} 422 + updated, _ := m.updateReader(msg) 423 + m = updated.(Model) 424 + 425 + // Timer state should be cleared 426 + if m.markAsReadUID != 0 || m.markAsReadFolder != "" { 427 + t.Errorf("timer state not cleared when exiting reader") 428 + } 429 + 430 + // State should be back to inbox 431 + if m.state != stateInbox { 432 + t.Errorf("state not returned to inbox") 433 + } 434 + }) 435 + } 436 + 325 437 func TestHandleEverythingResultKeepsRealSubject(t *testing.T) { 326 438 m := Model{ 327 439 inbox: newInboxList(80, 20, "", ""),