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.

update bulk handling (more than 10 emails) to show progress. Make more safe to restart

sspaeti 69ea7e43 1662d502

+113 -17
+13
internal/imap/client.go
··· 126 126 _ = c.conn.Close() 127 127 c.conn = nil 128 128 c.selectedMailbox = "" 129 + // Retry once after reconnect for transient network failures. 130 + if reconnErr := c.connect(ctx); reconnErr != nil { 131 + return err // return original error 132 + } 133 + if retryErr := fn(c.conn); retryErr != nil { 134 + if isNetErr(retryErr) { 135 + _ = c.conn.Close() 136 + c.conn = nil 137 + c.selectedMailbox = "" 138 + } 139 + return retryErr 140 + } 141 + return nil // retry succeeded 129 142 } 130 143 return err 131 144 }
+100 -17
internal/ui/model.go
··· 9 9 "regexp" 10 10 "sort" 11 11 "strings" 12 + "sync/atomic" 12 13 "time" 13 14 14 15 "github.com/charmbracelet/bubbles/list" ··· 49 50 webURL string // canonical "view online" URL (List-Post header or plain-text preamble) 50 51 attachments []imap.Attachment 51 52 } 52 - sendDoneMsg struct{ err error } 53 + sendDoneMsg struct{ err error; warning string } 53 54 screenDoneMsg struct{ err error } 54 55 autoScreenDoneMsg struct{ moved int; err error } 55 56 deepScreenReadyMsg struct { ··· 83 84 // background sync (runs every bgSyncInterval while neomd is open) 84 85 bgSyncTickMsg struct{} 85 86 bgInboxFetchedMsg struct{ emails []imap.Email } 86 - bgScreenDoneMsg struct{ moved int } 87 + bgScreenDoneMsg struct{ moved, total int } 87 88 // attachPickDoneMsg carries paths selected via the file picker (yazi etc.) 88 89 attachPickDoneMsg struct{ paths []string } 90 + // bulkProgressMsg is sent during long-running batch operations to update the status bar. 91 + bulkProgressMsg struct{ moved, total int; label string } 89 92 saveDraftDoneMsg struct{ err error } 90 93 attachOpenDoneMsg struct{ path string; err error } 91 94 editorDoneMsg struct { ··· 95 98 } 96 99 ) 97 100 101 + // bulkOp tracks progress of long-running batch operations. 102 + // Shared by pointer between the model (reader) and goroutines (writer). 103 + type bulkOp struct { 104 + moved atomic.Int64 105 + total int64 106 + label string // "Screening", "Moving", etc. 107 + } 108 + 109 + const maxUndoStack = 20 110 + 111 + // bulkProgressThreshold: only show progress for batches larger than this. 112 + const bulkProgressThreshold = 10 113 + 114 + func newBulkOp(label string, total int) *bulkOp { 115 + if total <= bulkProgressThreshold { 116 + return nil // small batches don't need progress tracking 117 + } 118 + return &bulkOp{label: label, total: int64(total)} 119 + } 120 + 121 + func (b *bulkOp) String() string { 122 + if b == nil { 123 + return "" 124 + } 125 + return fmt.Sprintf("%s: %d/%d…", b.label, b.moved.Load(), b.total) 126 + } 127 + 128 + // startBulk initializes a bulk progress tracker. Call before launching batch commands. 129 + func (m *Model) startBulk(label string, total int) { 130 + m.bulkProgress = newBulkOp(label, total) 131 + } 132 + 98 133 // Version is set by main.go at startup (from build-time ldflags). 99 134 var Version = "dev" 100 135 ··· 287 322 width int 288 323 height int 289 324 loading bool 325 + 326 + // Bulk operation progress — shared pointer, written by goroutines, read by view. 327 + bulkProgress *bulkOp 290 328 291 329 // Folder switcher 292 330 folders []string ··· 547 585 // BCC is intentionally excluded from headers but included in RCPT TO. 548 586 raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments) 549 587 if err != nil { 550 - return sendDoneMsg{fmt.Errorf("build message: %w", err)} 588 + return sendDoneMsg{err: fmt.Errorf("build message: %w", err)} 551 589 } 552 590 toAddrs := collectRcptTo(to, cc, bcc) 553 591 if err := smtp.SendRaw(cfg, toAddrs, raw); err != nil { 554 - return sendDoneMsg{err} 592 + return sendDoneMsg{err: err} 555 593 } 556 - // Save copy to Sent; non-fatal if it fails. 594 + // Save copy to Sent; non-fatal if it fails, but warn user. 557 595 if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil { 558 - // Log to status on next tick — for now swallow so send still reports success. 559 - _ = saveErr 596 + return sendDoneMsg{warning: "Sent, but failed to save to Sent folder: " + saveErr.Error()} 560 597 } 561 - return sendDoneMsg{nil} 598 + return sendDoneMsg{} 562 599 } 563 600 } 564 601 ··· 627 664 for i, e := range emails { 628 665 moves[i] = mv{e.Folder, e.UID} 629 666 } 667 + bp := m.bulkProgress 630 668 return func() tea.Msg { 631 669 undos := make([]undoMove, 0, len(moves)) 632 670 for i, mv := range moves { 633 671 destUID, err := m.imapCli().MoveMessage(nil, mv.folder, mv.uid, dst) 634 672 if err != nil { 635 - return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(moves), err)} 673 + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(moves), err), undo: undos} 636 674 } 637 675 undos = append(undos, undoMove{uid: destUID, fromFolder: mv.folder, toFolder: dst}) 676 + if bp != nil { 677 + bp.moved.Add(1) 678 + } 638 679 } 639 680 return batchDoneMsg{undo: undos} 640 681 } ··· 676 717 } 677 718 ops = append(ops, op{e.From, e.Folder, e.UID, dst}) 678 719 } 720 + bp := m.bulkProgress 679 721 return func() tea.Msg { 680 722 for i, o := range ops { 723 + // Move first, classify after — if move fails, screener file stays unchanged. 724 + if o.dst != "" && o.dst != o.srcFolder { 725 + if _, err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst); err != nil { 726 + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 727 + } 728 + } 681 729 var err error 682 730 switch action { 683 731 case "I": ··· 694 742 if err != nil { 695 743 return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 696 744 } 697 - if o.dst != "" && o.dst != o.srcFolder { 698 - if _, err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst); err != nil { 699 - return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 700 - } 745 + if bp != nil { 746 + bp.moved.Add(1) 701 747 } 702 748 } 703 749 return batchDoneMsg{} ··· 932 978 // bgExecAutoScreenCmd silently moves emails and returns bgScreenDoneMsg. 933 979 func (m Model) bgExecAutoScreenCmd(moves []autoScreenMove) tea.Cmd { 934 980 src := m.cfg.Folders.Inbox 981 + total := len(moves) 935 982 return func() tea.Msg { 936 983 moved := 0 937 984 for _, mv := range moves { ··· 940 987 } 941 988 moved++ 942 989 } 943 - return bgScreenDoneMsg{moved: moved} 990 + return bgScreenDoneMsg{moved: moved, total: total} 944 991 } 945 992 } 946 993 947 994 // execAutoScreenCmd performs the IMAP moves for a pre-approved list of moves. 948 995 func (m Model) execAutoScreenCmd(moves []autoScreenMove) tea.Cmd { 949 996 src := m.cfg.Folders.Inbox 997 + bp := m.bulkProgress 950 998 return func() tea.Msg { 951 999 for i, mv := range moves { 952 1000 if _, err := m.imapCli().MoveMessage(nil, src, mv.email.UID, mv.dst); err != nil { 953 1001 return autoScreenDoneMsg{moved: i, err: err} 1002 + } 1003 + if bp != nil { 1004 + bp.moved.Add(1) 954 1005 } 955 1006 } 956 1007 return autoScreenDoneMsg{moved: len(moves)} ··· 1063 1114 if msg.folder == m.cfg.Folders.Inbox && m.cfg.UI.AutoScreen() && !m.screener.IsEmpty() { 1064 1115 if moves := m.previewAutoScreen(); len(moves) > 0 { 1065 1116 m.loading = true 1066 - m.status = fmt.Sprintf("Screening %d email(s)…", len(moves)) 1117 + m.bulkProgress = newBulkOp("Screening", len(moves)) 1067 1118 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd(), m.spinner.Tick, m.execAutoScreenCmd(moves)) 1068 1119 } 1069 1120 } ··· 1130 1181 if msg.err != nil { 1131 1182 m.status = msg.err.Error() 1132 1183 m.isError = true 1184 + } else if msg.warning != "" { 1185 + m.status = msg.warning 1186 + m.isError = true // show in red so user notices 1187 + m.state = stateInbox 1133 1188 } else { 1134 1189 m.status = "Sent!" 1135 1190 m.isError = false ··· 1195 1250 1196 1251 case batchDoneMsg: 1197 1252 m.loading = false 1253 + m.bulkProgress = nil 1198 1254 m.markedUIDs = make(map[uint32]bool) 1199 1255 if msg.err != nil { 1256 + // Include partial undo info so user can reverse already-moved emails. 1257 + if len(msg.undo) > 0 { 1258 + m.undoStack = append(m.undoStack, msg.undo) 1259 + if len(m.undoStack) > maxUndoStack { 1260 + m.undoStack = m.undoStack[len(m.undoStack)-maxUndoStack:] 1261 + } 1262 + } 1200 1263 m.status = msg.err.Error() 1201 1264 m.isError = true 1202 1265 return m, nil 1203 1266 } 1204 1267 if len(msg.undo) > 0 { 1205 1268 m.undoStack = append(m.undoStack, msg.undo) 1269 + if len(m.undoStack) > maxUndoStack { 1270 + m.undoStack = m.undoStack[len(m.undoStack)-maxUndoStack:] 1271 + } 1206 1272 } 1207 1273 m.status = "Done." 1208 1274 m.loading = true ··· 1217 1283 } 1218 1284 if len(msg.undo) > 0 { 1219 1285 m.undoStack = append(m.undoStack, msg.undo) 1286 + if len(m.undoStack) > maxUndoStack { 1287 + m.undoStack = m.undoStack[len(m.undoStack)-maxUndoStack:] 1288 + } 1220 1289 } 1221 1290 m.status = "Moved." 1222 1291 m.isError = false ··· 1315 1384 1316 1385 case autoScreenDoneMsg: 1317 1386 m.loading = false 1387 + m.bulkProgress = nil 1318 1388 if msg.err != nil { 1319 - m.status = msg.err.Error() 1389 + m.status = fmt.Sprintf("Screening stopped after %d: %s", msg.moved, msg.err) 1320 1390 m.isError = true 1321 1391 return m, nil 1322 1392 } ··· 1338 1408 1339 1409 case bgScreenDoneMsg: 1340 1410 if msg.moved > 0 { 1411 + if msg.moved < msg.total { 1412 + m.status = fmt.Sprintf("Background sync: screened %d/%d — press R to retry", msg.moved, msg.total) 1413 + m.isError = true 1414 + } 1341 1415 // Refresh the visible folder so the user sees the clean result. 1342 1416 m.loading = true 1343 1417 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) ··· 1591 1665 return m, nil 1592 1666 } 1593 1667 m.loading = true 1668 + m.bulkProgress = newBulkOp("Deleting", len(targets)) 1594 1669 return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Trash)) 1595 1670 1596 1671 case "X": // permanent delete (marked or cursor) — only in Trash ··· 1631 1706 return m, nil 1632 1707 } 1633 1708 m.loading = true 1709 + m.bulkProgress = newBulkOp("Screening", len(targets)) 1634 1710 return m, tea.Batch(m.spinner.Tick, m.batchScreenerCmd(targets, key)) 1635 1711 1636 1712 // A = archive (pure move, no screener update) ··· 1640 1716 return m, nil 1641 1717 } 1642 1718 m.loading = true 1719 + m.bulkProgress = newBulkOp("Archiving", len(targets)) 1643 1720 return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Archive)) 1644 1721 1645 1722 // ── Auto-screen dry-run (Inbox only) ──────────────────────────── ··· 1691 1768 moves := m.pendingMoves 1692 1769 m.pendingMoves = nil 1693 1770 m.loading = true 1771 + m.bulkProgress = newBulkOp("Screening", len(moves)) 1694 1772 return m, tea.Batch(m.spinner.Tick, m.execAutoScreenCmd(moves)) 1695 1773 1696 1774 case "n": ··· 1999 2077 } 2000 2078 if dst, ok := dstMap[key]; ok { 2001 2079 m.loading = true 2080 + m.bulkProgress = newBulkOp("Moving", len(targets)) 2002 2081 return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, dst)) 2003 2082 } 2004 2083 m.status = fmt.Sprintf("unknown: M%s", key) ··· 3059 3138 b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n") 3060 3139 3061 3140 if m.loading { 3062 - b.WriteString(fmt.Sprintf(" %s Loading…\n", m.spinner.View())) 3141 + loadingText := "Loading…" 3142 + if bp := m.bulkProgress; bp != nil && bp.total > 0 { 3143 + loadingText = bp.String() 3144 + } 3145 + b.WriteString(fmt.Sprintf(" %s %s\n", m.spinner.View(), loadingText)) 3063 3146 } else if len(m.emails) == 0 { 3064 3147 b.WriteString(styleStatus.Render(" No messages.") + "\n") 3065 3148 } else {