feat: prevent auto read and mark unread (#1298)

Drew Smirnoff created

## What?


Extends plugin API and adds 2 plugins for preventing auto read, and for
marking read/unread

## Why?

Closes #1240
Closes #1239

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

backend/backend.go            |   1 
backend/imap/imap.go          |   4 +
backend/jmap/jmap.go          |  18 ++++++
backend/maildir/maildir.go    |  19 +++++++
backend/pop3/pop3.go          |   5 +
daemon/handler.go             |  11 ++-
daemonclient/service.go       |  23 ++++++++
docs/docs/Features/Plugins.md |  46 +++++++++++++++++
fetcher/fetcher.go            |  19 +++++++
main.go                       | 100 ++++++++++++++++++++++++++++++++----
plugin/README.md              |  40 +++++++++++++-
plugin/api.go                 |  59 ++++++++++++++++-----
plugin/plugin.go              |  30 +++++++++++
plugins/prevent_auto_read.lua |  20 +++++++
plugins/registry.json         |  12 ++++
plugins/toggle_read.lua       |  28 ++++++++++
tui/inbox.go                  |  19 +++++++
tui/messages.go               |   7 ++
18 files changed, 429 insertions(+), 32 deletions(-)

Detailed changes

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

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)
 }

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 {

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)

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 {

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)

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 {

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

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 {

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)

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 |

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 {

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

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)

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",

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)

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 {

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