diff --git a/backend/backend.go b/backend/backend.go index ca051efc745e06f464f7ee66dcb87cd1ace74234..2a016c5b9818ad96e6fb77b27a3bb534df79f52e 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -36,6 +36,7 @@ type EmailReader interface { // EmailWriter modifies email state. type EmailWriter interface { MarkAsRead(ctx context.Context, folder string, uid uint32) error + MarkAsUnread(ctx context.Context, folder string, uid uint32) error DeleteEmail(ctx context.Context, folder string, uid uint32) error ArchiveEmail(ctx context.Context, folder string, uid uint32) error MoveEmail(ctx context.Context, uid uint32, srcFolder, dstFolder string) error diff --git a/backend/imap/imap.go b/backend/imap/imap.go index f0f1d8cd45afbd267646b7df39468fc1a02fb5d0..a21ba00362488848e5199b238ecc3b26669cb973 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -60,6 +60,10 @@ func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) erro return fetcher.MarkEmailAsReadInMailbox(p.account, folder, uid) } +func (p *Provider) MarkAsUnread(_ context.Context, folder string, uid uint32) error { + return fetcher.MarkEmailAsUnreadInMailbox(p.account, folder, uid) +} + func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error { return fetcher.DeleteEmailFromMailbox(p.account, folder, uid) } diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index ec6d5ed93272b42beb6143edcc040559a703435c..0f6d0d1dbc3385898d63ef7f71c71f2109ba18ea 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -378,6 +378,24 @@ func (p *Provider) MarkAsRead(_ context.Context, _ string, uid uint32) error { return err } +func (p *Provider) MarkAsUnread(_ context.Context, _ string, uid uint32) error { + jmapID, err := p.lookupJMAPID(uid) + if err != nil { + return err + } + + req := &jmapclient.Request{} + req.Invoke(&email.Set{ + Account: p.accountID, + Update: map[jmapclient.ID]jmapclient.Patch{ + jmapID: {"keywords/$seen": nil}, + }, + }) + + _, err = p.client.Do(req) + return err +} + func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error { jmapID, err := p.lookupJMAPID(uid) if err != nil { diff --git a/backend/maildir/maildir.go b/backend/maildir/maildir.go index cda11c5ed74b9d0c2c3346a91e2da01caca5ee3b..ee0b49c15e2564c5189dbe7750609294d062fba3 100644 --- a/backend/maildir/maildir.go +++ b/backend/maildir/maildir.go @@ -229,6 +229,25 @@ func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) erro return msg.SetFlags(append(flags, emaildir.FlagSeen)) } +// MarkAsUnread removes the Seen flag while preserving the others. +func (p *Provider) MarkAsUnread(_ context.Context, folder string, uid uint32) error { + msg, err := p.findMessageByUID(folder, uid) + if err != nil { + return err + } + flags := msg.Flags() + filtered := flags[:0] + for _, fl := range flags { + if fl != emaildir.FlagSeen { + filtered = append(filtered, fl) + } + } + if len(filtered) == len(flags) { + return nil // already unread + } + return msg.SetFlags(filtered) +} + // DeleteEmail removes the message file from disk. func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error { msg, err := p.findMessageByUID(folder, uid) diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index b9c47544dd811220874dcc0afa88aa16b13b88a8..c9f4ca6ab7ce05cbefa1c716b0cb124ed1943fa9 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -183,6 +183,11 @@ func (p *Provider) MarkAsRead(_ context.Context, _ string, _ uint32) error { return nil } +func (p *Provider) MarkAsUnread(_ context.Context, _ string, _ uint32) error { + // POP3 has no concept of read/unread flags — this is a no-op + return nil +} + func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error { conn, err := p.connect() if err != nil { diff --git a/daemon/handler.go b/daemon/handler.go index e16597d10155937fd5d8a25cba82e8ec6ebb07cf..8ee6c6734ed975959cc5b3282495f8a042322df9 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -263,10 +263,15 @@ func (d *Daemon) handleMarkRead(conn *daemonrpc.Conn, req *daemonrpc.Request) { ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout) defer cancel() - // MarkAsRead only supports one UID at a time in the Provider interface. for _, uid := range params.UIDs { - if err := p.MarkAsRead(ctx, params.Folder, uid); err != nil { - log.Printf("daemon: mark read %d failed: %v", uid, err) + var err error + if params.Read { + err = p.MarkAsRead(ctx, params.Folder, uid) + } else { + err = p.MarkAsUnread(ctx, params.Folder, uid) + } + if err != nil { + log.Printf("daemon: mark read=%v %d failed: %v", params.Read, uid, err) } } conn.SendResponse(req.ID, true) diff --git a/daemonclient/service.go b/daemonclient/service.go index d18ed4f7b00f6ab8966fd9ccd184d1d66ea86a2d..ab10d3ba632457af46386293a21d3b72777145ca 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -23,6 +23,7 @@ type Service interface { ArchiveEmails(accountID, folder string, uids []uint32) error MoveEmails(accountID string, uids []uint32, src, dst string) error MarkRead(accountID, folder string, uids []uint32) error + MarkUnread(accountID, folder string, uids []uint32) error FetchFolders(accountID string) ([]backend.Folder, error) RefreshFolder(accountID, folder string) error Subscribe(accountID, folder string) error @@ -161,6 +162,15 @@ func (s *daemonService) MarkRead(accountID, folder string, uids []uint32) error }, nil) } +func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) error { + return s.client.Call(daemonrpc.MethodMarkRead, daemonrpc.MarkReadParams{ + AccountID: accountID, + Folder: folder, + UIDs: uids, + Read: false, + }, nil) +} + func (s *daemonService) FetchFolders(accountID string) ([]backend.Folder, error) { var folders []backend.Folder err := s.client.Call(daemonrpc.MethodFetchFolders, daemonrpc.FetchFoldersParams{ @@ -298,6 +308,19 @@ func (s *directService) MarkRead(accountID, folder string, uids []uint32) error return nil } +func (s *directService) MarkUnread(accountID, folder string, uids []uint32) error { + p, err := s.getProvider(accountID) + if err != nil { + return err + } + for _, uid := range uids { + if err := p.MarkAsUnread(context.Background(), folder, uid); err != nil { + return err + } + } + return nil +} + func (s *directService) FetchFolders(accountID string) ([]backend.Folder, error) { p, err := s.getProvider(accountID) if err != nil { diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md index a7da009bdd8a5bb48388ee9c9e094a2b93fbe99f..ca23302c654ff1616e2b16ebf7c7a95aa7fd6bbc 100644 --- a/docs/docs/Features/Plugins.md +++ b/docs/docs/Features/Plugins.md @@ -190,6 +190,43 @@ matcha.bind_key("ctrl+r", "composer", "rewrite", function(state) end) ``` +### matcha.mark_read(uid, account_id, folder) + +Mark an email as read. The change is applied after the hook or keybinding callback returns — both the local UI and the server (IMAP/JMAP/Maildir) are updated. + +```lua +matcha.bind_key("r", "inbox", "Mark read", function(email) + if email then + matcha.mark_read(email.uid, email.account_id, email.folder) + end +end) +``` + +### matcha.mark_unread(uid, account_id, folder) + +Mark an email as unread. Same dispatch behaviour as `mark_read`. + +```lua +matcha.bind_key("U", "inbox", "Mark unread", function(email) + if email then + matcha.mark_unread(email.uid, email.account_id, email.folder) + end +end) +``` + +### matcha.suppress_auto_read() + +Prevent the currently viewed email from being automatically marked as read. Must be called inside an `email_viewed` callback; calling it elsewhere is a no-op. + +```lua +-- Keep newsletter emails unread after opening them +matcha.on("email_viewed", function(email) + if email.from:find("newsletter@") then + matcha.suppress_auto_read() + end +end) +``` + ### matcha.notify(message [, seconds]) Show a temporary notification in the Matcha UI. The optional second argument sets how long the notification is displayed (default 2 seconds). @@ -287,10 +324,17 @@ end) Fired when you open an email to read it. Receives the same email table as `email_received`. +Call `matcha.suppress_auto_read()` inside this callback to prevent Matcha from automatically marking the email as read. + ```lua matcha.on("email_viewed", function(email) matcha.log("Reading: " .. email.subject) end) + +-- Keep all emails unread after viewing +matcha.on("email_viewed", function(email) + matcha.suppress_auto_read() +end) ``` ### email_send_before @@ -481,6 +525,8 @@ The repository includes 35+ example plugins. Here are a few to get started: | `webhook_notify.lua` | Posts to a webhook when emails arrive | | `weather_status.lua` | Shows current weather in the inbox status bar | | `ai_rewrite.lua` | AI-powered email rewriting in the composer | +| `toggle_read.lua` | Toggle read/unread on the selected email (configurable keybind) | +| `prevent_auto_read.lua` | Prevent emails from being auto-marked as read when opened | Browse the full list in the [Plugin Marketplace](/marketplace) or run `matcha marketplace`. diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 006998c55bb4904c111b86245b0dd545dfbdcdc7..0b83c48918e031af6e190f6ac9096963ae26fef6 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1283,6 +1283,25 @@ func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint3 }, nil).Close() } +func MarkEmailAsUnreadInMailbox(account *config.Account, mailbox string, uid uint32) error { + c, err := connect(account) + if err != nil { + return err + } + defer c.Close() + + if _, err := c.Select(mailbox, nil).Wait(); err != nil { + return err + } + + uidSet := imap.UIDSetNum(imap.UID(uid)) + return c.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsDel, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil).Close() +} + func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error { c, err := connect(account) if err != nil { diff --git a/main.go b/main.go index 528802ee42e88efe0cd347c0d1720a30392987e1..7c316bc96863b21d7cf7063d1d6e799e790dcadf 100644 --- a/main.go +++ b/main.go @@ -261,7 +261,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Check plugin key bindings for the current view if m.plugins != nil { - m.handlePluginKeyBinding(keyMsg) + if bindingCmd := m.handlePluginKeyBinding(keyMsg); bindingCmd != nil { + cmds = append(cmds, bindingCmd) + } } } @@ -726,7 +728,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.folderInbox.SetLoadingEmails(false) m.syncPluginStatus() m.syncPluginKeyBindings() - return m, m.pluginNotifyCmd() + return m, tea.Batch(append(m.pluginFlagCmds(), m.pluginNotifyCmd())...) case tui.FetchFolderMoreEmailsMsg: if msg.AccountID == "" || m.config == nil { @@ -1300,16 +1302,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } + suppressRead := false if m.plugins != nil { t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName) m.plugins.CallHook(plugin.HookEmailViewed, t) + suppressRead = m.plugins.TakeAutoReadSuppressed() } // Split pane mode: open in split view instead of full screen if m.config.EnableSplitPane && m.folderInbox != nil { m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email) m.current = m.folderInbox // Mark as read - if !email.IsRead { + if !email.IsRead && !suppressRead { m.markEmailAsReadInStores(msg.UID, msg.AccountID) account := m.config.GetAccountByID(msg.AccountID) if account != nil { @@ -1317,9 +1321,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } // Fetch body - return m, tea.Batch(cmd, func() tea.Msg { + return m, tea.Batch(append(m.pluginFlagCmds(), cmd, func() tea.Msg { return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID} - }) + })...) } // Check body cache first if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil { @@ -1355,7 +1359,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.current = tui.NewStatus("Fetching email content...") - return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd()) + return m, tea.Batch(append(m.pluginFlagCmds(), m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())...) case tui.EmailBodyFetchedMsg: if msg.Err != nil { @@ -1413,9 +1417,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // Mark as read in UI immediately and on the server + // Mark as read in UI immediately and on the server (unless plugin suppressed it) var markReadCmd tea.Cmd - if !email.IsRead { + pluginSuppressed := m.plugins != nil && m.plugins.TakeAutoReadSuppressed() + if !email.IsRead && !pluginSuppressed { m.markEmailAsReadInStores(msg.UID, msg.AccountID) folderName := "INBOX" @@ -1438,6 +1443,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if markReadCmd != nil { cmds = append(cmds, markReadCmd) } + cmds = append(cmds, m.pluginFlagCmds()...) return m, tea.Batch(cmds...) case tui.ReplyToEmailMsg: @@ -1720,6 +1726,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncUnreadBadge() return m, nil + case tui.EmailMarkedUnreadMsg: + if msg.Err != nil { + log.Printf("Error marking email as unread: %v", msg.Err) + } + m.syncUnreadBadge() + return m, nil + case tui.EmailActionDoneMsg: if msg.Err != nil { log.Printf("Action failed: %v", msg.Err) @@ -1990,6 +2003,36 @@ func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) { } } +func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) { + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + m.emails[i].IsRead = false + break + } + } + if emails, ok := m.emailsByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].IsRead = false + break + } + } + } + for folderName, folderEmails := range m.folderEmails { + for i := range folderEmails { + if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID { + folderEmails[i].IsRead = false + m.folderEmails[folderName] = folderEmails + go saveFolderEmailsToCache(folderName, folderEmails) + break + } + } + } + if m.folderInbox != nil { + m.folderInbox.GetInbox().MarkEmailAsUnread(uid, accountID) + } +} + func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) { var filtered []fetcher.Email for _, e := range m.emails { @@ -2009,6 +2052,33 @@ func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) { } } +// pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds. +func (m *mainModel) pluginFlagCmds() []tea.Cmd { + if m.plugins == nil { + return nil + } + ops := m.plugins.TakePendingFlagOps() + if len(ops) == 0 { + return nil + } + var cmds []tea.Cmd + for _, op := range ops { + op := op + account := m.config.GetAccountByID(op.AccountID) + if account == nil { + continue + } + if op.Read { + m.markEmailAsReadInStores(op.UID, op.AccountID) + cmds = append(cmds, markEmailAsReadCmd(account, op.UID, op.AccountID, op.Folder)) + } else { + m.markEmailAsUnreadInStores(op.UID, op.AccountID) + cmds = append(cmds, markEmailAsUnreadCmd(account, op.UID, op.AccountID, op.Folder)) + } + } + return cmds +} + // pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists. func (m *mainModel) pluginNotifyCmd() tea.Cmd { if m.plugins == nil { @@ -2037,7 +2107,7 @@ func (m *mainModel) syncPluginStatus() { } } -func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) { +func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) tea.Cmd { keyStr := msg.String() var area string @@ -2051,7 +2121,7 @@ func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) { case *tui.Composer: area = plugin.StatusComposer default: - return + return nil } bindings := m.plugins.Bindings(area) @@ -2100,8 +2170,9 @@ func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) { } m.syncPluginStatus() - return + return tea.Batch(m.pluginFlagCmds()...) } + return nil } func (m *mainModel) syncPluginKeyBindings() { @@ -2904,6 +2975,13 @@ func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, f } } +func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd { + return func() tea.Msg { + err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid) + return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err} + } +} + func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { err := fetcher.DeleteFolderEmail(account, folderName, uid) diff --git a/plugin/README.md b/plugin/README.md index c08b1ae5c93d9e95a066dc1baac5984881a706f9..8e76c859789d3852153ca3ecc7e617540f6159e6 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -36,6 +36,9 @@ end) | `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) | | `matcha.settings(spec)` | Declare configurable settings; returns a read-only proxy table for live values (see below) | | `matcha.get_setting(key [, plugin])` | Look up a setting value by key (defaults to current plugin) | +| `matcha.mark_read(uid, account_id, folder)` | Queue a mark-as-read operation; dispatched after the hook or keybinding returns | +| `matcha.mark_unread(uid, account_id, folder)` | Queue a mark-as-unread operation; dispatched after the hook or keybinding returns | +| `matcha.suppress_auto_read()` | Prevent the viewed email from being auto-marked as read; only effective inside an `email_viewed` callback | ## Hook events @@ -44,7 +47,7 @@ end) | `startup` | — | Matcha has started | | `shutdown` | — | Matcha is exiting | | `email_received` | Lua table with `uid`, `from`, `to`, `subject`, `date`, `is_read`, `account_id`, `folder` | New email arrived | -| `email_viewed` | Same as `email_received` | User opened an email | +| `email_viewed` | Same as `email_received` | User opened an email. Call `matcha.suppress_auto_read()` here to prevent automatic mark-as-read. | | `email_send_before` | Table with `to`, `cc`, `subject`, `account_id` | About to send an email | | `email_send_after` | Same as `email_send_before` | Email sent successfully | | `folder_changed` | Folder name (string) | User switched folders | @@ -202,19 +205,50 @@ Values are persisted in `~/.config/matcha/config.json` under `plugin_settings`. Edit them in **Settings → Plugins** in the TUI; booleans toggle with `enter`/`space`, numbers and strings open a text editor. +## Flag management + +Plugins can programmatically change the read/unread state of any email. The operations are queued and dispatched by the orchestrator after the hook or keybinding callback returns, so the UI and IMAP/backend stay in sync. + +```lua +local matcha = require("matcha") + +-- Mark as read from a keybinding +matcha.bind_key("r", "inbox", "Mark read", function(email) + if not email then return end + matcha.mark_read(email.uid, email.account_id, email.folder) +end) + +-- Mark as unread from a keybinding +matcha.bind_key("U", "inbox", "Mark unread", function(email) + if not email then return end + matcha.mark_unread(email.uid, email.account_id, email.folder) +end) + +-- Suppress auto-read for a specific sender +matcha.on("email_viewed", function(email) + if email.from:find("newsletter@") then + matcha.suppress_auto_read() + end +end) +``` + +`matcha.suppress_auto_read()` must be called inside an `email_viewed` callback. Any other call site is a no-op. + ## Available plugins The following example plugins ship in `~/.config/matcha/plugins/`: - `email_age.lua` - `recipient_counter.lua` +- `toggle_read.lua` — toggle read/unread on a selected email (configurable keybind) +- `prevent_auto_read.lua` — suppress auto-mark-as-read when opening emails (on/off setting) ## Files | File | Description | |------|-------------| -| `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state | +| `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state; `FlagOp` type and pending flag-ops queue | | `hooks.go` | Hook definitions, callback registration, and hook invocation helpers | -| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`, `prompt`, `style`) | +| `api.go` | `matcha` Lua module registration — all API functions including `mark_read`, `mark_unread`, `suppress_auto_read` | | `http.go` | `matcha.http()` implementation — HTTP client with timeout and body size limits | | `prompt.go` | `matcha.prompt()` implementation — user input overlay for the composer | diff --git a/plugin/api.go b/plugin/api.go index 6b2049cc8107cb782aa3dcd532da8b47ae656862..c9da983231df559590e99c95daa3fc18fe0e27ca 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -12,21 +12,24 @@ func (m *Manager) registerAPI() { L := m.state mod := L.RegisterModule("matcha", map[string]lua.LGFunction{ - "on": m.luaOn, - "log": m.luaLog, - "notify": m.luaNotify, - "set_status": m.luaSetStatus, - "set_compose_field": m.luaSetComposeField, - "bind_key": m.luaBindKey, - "http": m.luaHTTP, - "prompt": m.luaPrompt, - "store_set": m.luaStoreSet, - "store_get": m.luaStoreGet, - "store_delete": m.luaStoreDelete, - "store_keys": m.luaStoreKeys, - "style": m.luaStyle, - "settings": m.luaSettings, - "get_setting": m.luaGetSetting, + "on": m.luaOn, + "log": m.luaLog, + "notify": m.luaNotify, + "set_status": m.luaSetStatus, + "set_compose_field": m.luaSetComposeField, + "bind_key": m.luaBindKey, + "http": m.luaHTTP, + "prompt": m.luaPrompt, + "store_set": m.luaStoreSet, + "store_get": m.luaStoreGet, + "store_delete": m.luaStoreDelete, + "store_keys": m.luaStoreKeys, + "style": m.luaStyle, + "settings": m.luaSettings, + "get_setting": m.luaGetSetting, + "mark_read": m.luaMarkRead, + "mark_unread": m.luaMarkUnread, + "suppress_auto_read": m.luaSuppressAutoRead, }) L.SetField(mod, "_VERSION", lua.LString("0.1.0")) @@ -154,6 +157,32 @@ func (m *Manager) luaGetSetting(L *lua.LState) int { return m.getSetting(L) } +// matcha.mark_read(uid, account_id, folder) — queue a mark-as-read op for the given email. +// The orchestrator dispatches the IMAP/backend call after the hook or keybinding returns. +func (m *Manager) luaMarkRead(L *lua.LState) int { + uid := uint32(L.CheckInt(1)) + accountID := L.CheckString(2) + folder := L.CheckString(3) + m.pendingFlagOps = append(m.pendingFlagOps, FlagOp{UID: uid, AccountID: accountID, Folder: folder, Read: true}) + return 0 +} + +// matcha.mark_unread(uid, account_id, folder) — queue a mark-as-unread op for the given email. +func (m *Manager) luaMarkUnread(L *lua.LState) int { + uid := uint32(L.CheckInt(1)) + accountID := L.CheckString(2) + folder := L.CheckString(3) + m.pendingFlagOps = append(m.pendingFlagOps, FlagOp{UID: uid, AccountID: accountID, Folder: folder, Read: false}) + return 0 +} + +// matcha.suppress_auto_read() — prevent the currently viewed email from being +// automatically marked as read. Must be called inside an email_viewed callback. +func (m *Manager) luaSuppressAutoRead(L *lua.LState) int { + m.suppressAutoRead = true + return 0 +} + // matcha.set_compose_field(field, value) — set a compose field value. // Valid fields: "to", "cc", "bcc", "subject", "body". func (m *Manager) luaSetComposeField(L *lua.LState) int { diff --git a/plugin/plugin.go b/plugin/plugin.go index 648e16cca9b23fcb05e6fe6c70fdc1a1809c7c9f..1e4dd08ac19f76a81357aaa809dbd52234cd3227 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -18,6 +18,14 @@ type KeyBinding struct { Plugin string } +// FlagOp is a pending flag change queued by a plugin via matcha.mark_read / matcha.mark_unread. +type FlagOp struct { + UID uint32 + AccountID string + Folder string + Read bool // true = mark read, false = mark unread +} + // Manager manages the Lua VM and loaded plugins. // // Manager is not safe for concurrent use. The Lua VM itself is single- @@ -44,6 +52,10 @@ type Manager struct { bindings []KeyBinding // pendingPrompt is set by matcha.prompt() and consumed by the orchestrator. pendingPrompt *PendingPrompt + // pendingFlagOps queues flag changes (read/unread) requested by plugins. + pendingFlagOps []FlagOp + // suppressAutoRead is set by matcha.suppress_auto_read() inside email_viewed callbacks. + suppressAutoRead bool // pluginSchemas holds settings declarations per plugin. pluginSchemas map[string][]SettingDef @@ -183,6 +195,24 @@ func (m *Manager) StatusText(area string) string { return m.statuses[area] } +// TakePendingFlagOps returns and clears all pending flag operations. +func (m *Manager) TakePendingFlagOps() []FlagOp { + if len(m.pendingFlagOps) == 0 { + return nil + } + ops := m.pendingFlagOps + m.pendingFlagOps = nil + return ops +} + +// TakeAutoReadSuppressed returns true (and resets the flag) if a plugin +// called matcha.suppress_auto_read() during the current email_viewed callback. +func (m *Manager) TakeAutoReadSuppressed() bool { + v := m.suppressAutoRead + m.suppressAutoRead = false + return v +} + // LuaState returns the Lua VM state for building tables. func (m *Manager) LuaState() *lua.LState { return m.state diff --git a/plugins/prevent_auto_read.lua b/plugins/prevent_auto_read.lua new file mode 100644 index 0000000000000000000000000000000000000000..32624425c2873ca9651519256f113caaf22b2807 --- /dev/null +++ b/plugins/prevent_auto_read.lua @@ -0,0 +1,20 @@ +-- prevent_auto_read.lua +-- Prevents emails from being automatically marked as read when opened. +-- When enabled, emails stay unread until explicitly marked (e.g. via toggle_read). + +local matcha = require("matcha") + +local cfg = matcha.settings({ + enabled = { + type = "boolean", + default = true, + label = "Prevent auto-read", + description = "When on, opening an email does not mark it as read.", + }, +}) + +matcha.on("email_viewed", function(email) + if cfg.enabled then + matcha.suppress_auto_read() + end +end) diff --git a/plugins/registry.json b/plugins/registry.json index 669a1d65259ed1e344e58e91fcccc978ca4317b6..a0c5f079b9bbc3898dd5bb04adf22aeeee5cb2ee 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -113,12 +113,24 @@ "description": "Estimates reading time based on word count while composing.", "file": "reading_time.lua" }, + { + "name": "prevent_auto_read", + "title": "Prevent Auto Read", + "description": "Prevents emails from being automatically marked as read when opened. Toggle on/off in Settings → Plugins.", + "file": "prevent_auto_read.lua" + }, { "name": "read_tracker", "title": "Read Tracker", "description": "Displays a running count of emails you've read this session.", "file": "read_tracker.lua" }, + { + "name": "toggle_read", + "title": "Toggle Read", + "description": "Press a configurable key (default: u) in the inbox or email view to toggle read/unread on the selected email.", + "file": "toggle_read.lua" + }, { "name": "recipient_counter", "title": "Recipient Counter", diff --git a/plugins/toggle_read.lua b/plugins/toggle_read.lua new file mode 100644 index 0000000000000000000000000000000000000000..6a119c6ed1096611560e9b3e4b5aba1cf9f7afff --- /dev/null +++ b/plugins/toggle_read.lua @@ -0,0 +1,28 @@ +-- toggle_read.lua +-- Toggle read/unread on the selected email. Keybind is configurable. + +local matcha = require("matcha") + +local cfg = matcha.settings({ + key = { + type = "string", + default = "u", + label = "Toggle key", + description = "Key to press in inbox and email_view to toggle read/unread. Takes effect after restart.", + }, +}) + +local function toggle(email) + if not email then return end + local folder = email.folder ~= "" and email.folder or "INBOX" + if email.is_read then + matcha.mark_unread(email.uid, email.account_id, folder) + matcha.notify("Marked as unread") + else + matcha.mark_read(email.uid, email.account_id, folder) + matcha.notify("Marked as read") + end +end + +matcha.bind_key(cfg.key, "email_view", "Toggle read/unread", toggle) +matcha.bind_key(cfg.key, "inbox", "Toggle read/unread", toggle) diff --git a/tui/inbox.go b/tui/inbox.go index 9fe1854d55e8e6da8c8bdb61703bc462940af887..8d63edd53e7e40c1ca29a30efaefb90eff36a012 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -1294,6 +1294,25 @@ func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) { m.updateList() } +// MarkEmailAsUnread marks an email as unread by UID and account ID, updating it in all stores. +func (m *Inbox) MarkEmailAsUnread(uid uint32, accountID string) { + for i := range m.allEmails { + if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID { + m.allEmails[i].IsRead = false + break + } + } + if emails, ok := m.emailsByAccount[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].IsRead = false + break + } + } + } + m.updateList() +} + // updateVisualSelection updates the selected UIDs based on anchor and current index func (m *Inbox) updateVisualSelection() { if !m.visualMode { diff --git a/tui/messages.go b/tui/messages.go index c0dc2e9b6359ace4837a944767d20a809a3e5ade..ff048e4a9fdd688db8f919e7cb9d480e1fa15752 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -489,6 +489,13 @@ type EmailMarkedReadMsg struct { Err error } +// EmailMarkedUnreadMsg signals that an email was marked as unread. +type EmailMarkedUnreadMsg struct { + UID uint32 + AccountID string + Err error +} + // FetchFolderMoreEmailsMsg signals a request to fetch more emails from a folder (pagination). type FetchFolderMoreEmailsMsg struct { Offset uint32