Detailed changes
@@ -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
@@ -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)
}
@@ -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 {
@@ -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)
@@ -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 {
@@ -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)
@@ -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 {
@@ -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`.
@@ -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 {
@@ -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)
@@ -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 |
@@ -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 {
@@ -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
@@ -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)
@@ -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",
@@ -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)
@@ -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 {
@@ -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