Skip to content

Commit 3b08db8

Browse files
committed
read timer and fix update UI when mark as read on server
1 parent 6259ba8 commit 3b08db8

File tree

9 files changed

+362
-9
lines changed

9 files changed

+362
-9
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

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

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ flowchart TD
5050
Inbox --> Process{Process Email<br/>GTD Decision}
5151
5252
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]
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]
@@ -72,7 +72,7 @@ flowchart TD
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
```
75-
*Styled with Kanagawa colors - all boxes represent neomd folders*
75+
*all colored boxes represent neomd folders*
7676

7777
**Key principles:**
7878
- **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification

docs/content/docs/_index.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,70 @@ Also, we intentionally don't add more folders to the archive or file emails too,
2525

2626
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.
2727

28-
2928
{{< callout type="info" >}}
3029
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.
3130
{{< /callout >}}
3231

3332

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)
90+
91+
3492
## Screenshots
3593

3694
### Overview: List-View

docs/content/docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ auto_screen_on_load = true # screen inbox automatically on every load (defa
7676
bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5)
7777
bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10)
7878
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)
7980
signature = """**Your Name**
8081
Your Title, Your Company
8182

docs/content/docs/reading.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,34 @@ Results display in a temporary "Thread" tab:
103103

104104
Also available as `:thread` (alias `:t`) from the command line.
105105

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+
106134
## Reply Indicator
107135

108136
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.

internal/config/config.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ type UIConfig struct {
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)
137+
MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7)
137138
}
138139

139140
// TextSignature returns the text/markdown signature for editor and text/plain part.
@@ -360,10 +361,11 @@ func defaults() *Config {
360361
Spam: "Spam",
361362
},
362363
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)*",
367369
},
368370
}
369371
}

internal/integration_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,89 @@ func TestIntegration_ReplyAllPreservesRecipients(t *testing.T) {
664664
t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC)
665665
}
666666

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+
667750
// --- Helpers ---
668751

669752
func extractUser(from string) string {

internal/ui/model.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ type (
112112
bgSyncTickMsg struct{}
113113
bgInboxFetchedMsg struct{ emails []imap.Email }
114114
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+
}
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.
@@ -450,6 +455,9 @@ type Model struct {
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)
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
453461

454462
// Compose / pre-send
455463
compose composeModel
@@ -1434,6 +1442,18 @@ func (m Model) scheduleBgSync() tea.Cmd {
14341442
return tea.Tick(time.Duration(mins)*time.Minute, func(time.Time) tea.Msg { return bgSyncTickMsg{} })
14351443
}
14361444

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+
14371457
// bgFetchInboxCmd silently fetches inbox headers for background screening.
14381458
// Errors are swallowed — a transient network hiccup shouldn't disrupt the UI.
14391459
func (m Model) bgFetchInboxCmd() tea.Cmd {
@@ -1641,10 +1661,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
16411661
if msg.email != nil {
16421662
msg.email.References = msg.references
16431663
}
1644-
// Mark as seen in background (best-effort)
1664+
// Mark as seen: either immediately (if config = 0) or after timer
16451665
uid := msg.email.UID
16461666
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+
}
16481682
if m.pendingForward {
16491683
m.pendingForward = false
16501684
return m.launchForwardCmd()
@@ -1664,6 +1698,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
1701+
// Start mark-as-read timer if configured
1702+
if m.cfg.UI.MarkAsReadAfterSecs > 0 {
1703+
return m, m.scheduleMarkAsReadTimer(uid, folder)
1704+
}
16671705
return m, nil
16681706

16691707
case sendDoneMsg:
@@ -1744,6 +1782,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
17441782
}
17451783
return m, m.applyFilter()
17461784

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
1804+
17471805
case imapSearchResultMsg:
17481806
return m.handleIMAPSearchResult(msg)
17491807

@@ -2879,6 +2937,9 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
28792937
case "q", "esc", "h":
28802938
m.state = stateInbox
28812939
m.readerPending = ""
2940+
// Clear mark-as-read timer state when exiting reader
2941+
m.markAsReadUID = 0
2942+
m.markAsReadFolder = ""
28822943
return m, nil
28832944
case "e":
28842945
return m.openInNeovim()
@@ -2914,6 +2975,9 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
29142975
if m.openEmail != nil {
29152976
m.loading = true
29162977
m.state = stateInbox
2978+
// Clear mark-as-read timer state when switching to conversation view
2979+
m.markAsReadUID = 0
2980+
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":

0 commit comments

Comments
 (0)