feat(plugins): initial commit (#380)

Drew Smirnoff , Steve Evans , and Lea created

Co-authored-by: Steve Evans <steve@floatpane.com>
Co-authored-by: Lea <lea@floatpane.com>

Change summary

docs/docs/Features/Plugins.md         | 235 +++++++++++++++++++++++++++++
examples/plugins/char_counter.lua     |   8 
examples/plugins/folder_announcer.lua |   8 
examples/plugins/hello.lua            |  12 +
examples/plugins/notify_github.lua    |  10 +
examples/plugins/send_logger.lua      |  12 +
examples/plugins/unread_counter.lua   |  18 ++
go.mod                                |   1 
go.sum                                |   2 
main.go                               |  99 +++++++++++
plugin/api.go                         |  53 ++++++
plugin/hooks.go                       | 140 +++++++++++++++++
plugin/plugin.go                      | 131 ++++++++++++++++
tui/composer.go                       |  14 +
tui/email_view.go                     |  15 +
tui/inbox.go                          |  10 +
tui/messages.go                       |   8 
17 files changed, 768 insertions(+), 8 deletions(-)

Detailed changes

docs/docs/Features/Plugins.md 🔗

@@ -0,0 +1,235 @@
+# Plugins
+
+Matcha supports Lua plugins for extending functionality. Plugins can react to events like receiving emails, sending messages, switching folders, and more.
+
+## Getting Started
+
+### Plugin Location
+
+Place your plugins in `~/.config/matcha/plugins/`. Matcha loads them automatically on startup.
+
+A plugin can be either:
+
+- A single `.lua` file (e.g. `my_plugin.lua`)
+- A directory with an `init.lua` entry point (e.g. `my_plugin/init.lua`)
+
+```
+~/.config/matcha/plugins/
+├── hello.lua
+├── notify_github.lua
+└── my_plugin/
+    └── init.lua
+```
+
+### Your First Plugin
+
+Create `~/.config/matcha/plugins/hello.lua`:
+
+```lua
+local matcha = require("matcha")
+
+matcha.on("startup", function()
+  matcha.log("hello plugin loaded")
+end)
+```
+
+Restart Matcha and check the log output. You should see `hello plugin loaded`.
+
+## API Reference
+
+All plugin functions are accessed through the `matcha` module:
+
+```lua
+local matcha = require("matcha")
+```
+
+### matcha.on(event, callback)
+
+Register a function to be called when an event occurs.
+
+```lua
+matcha.on("email_received", function(email)
+  matcha.log("New email from: " .. email.from)
+end)
+```
+
+### matcha.log(message)
+
+Write a message to Matcha's log output (stderr). Useful for debugging.
+
+```lua
+matcha.log("something happened")
+```
+
+### matcha.set_status(area, text)
+
+Set a persistent status string displayed in a specific part of the UI. Pass an empty string to clear it.
+
+**Available areas:**
+
+| Area           | Where it appears                          |
+| -------------- | ----------------------------------------- |
+| `"inbox"`      | Inbox title bar, next to the folder name  |
+| `"composer"`   | Composer help bar at the bottom           |
+| `"email_view"` | Email viewer help bar at the bottom       |
+
+```lua
+matcha.set_status("inbox", "5 unread")      -- shows as "INBOX (5 unread)"
+matcha.set_status("composer", "420 chars")  -- shows in composer help bar
+matcha.set_status("inbox", "")              -- clears the inbox status
+```
+
+### 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).
+
+```lua
+matcha.notify("You have new mail!")       -- shows for 2 seconds
+matcha.notify("Important!", 5)            -- shows for 5 seconds
+matcha.notify("Quick flash", 0.5)         -- shows for half a second
+```
+
+## Events
+
+### startup
+
+Fired once when Matcha starts, after all plugins are loaded.
+
+```lua
+matcha.on("startup", function()
+  matcha.log("plugin ready")
+end)
+```
+
+### shutdown
+
+Fired when Matcha exits.
+
+```lua
+matcha.on("shutdown", function()
+  matcha.log("goodbye")
+end)
+```
+
+### email_received
+
+Fired for each email when a folder's email list is fetched. Receives an email table.
+
+```lua
+matcha.on("email_received", function(email)
+  matcha.log(email.from .. ": " .. email.subject)
+end)
+```
+
+**Email table fields:**
+
+| Field        | Type    | Description                    |
+| ------------ | ------- | ------------------------------ |
+| `uid`        | number  | Unique email ID                |
+| `from`       | string  | Sender address                 |
+| `to`         | table   | List of recipient addresses    |
+| `subject`    | string  | Email subject line             |
+| `date`       | string  | ISO 8601 date string           |
+| `is_read`    | boolean | Whether the email has been read |
+| `account_id` | string  | ID of the account              |
+| `folder`     | string  | Folder name (e.g. "INBOX")     |
+
+### email_viewed
+
+Fired when you open an email to read it. Receives the same email table as `email_received`.
+
+```lua
+matcha.on("email_viewed", function(email)
+  matcha.log("Reading: " .. email.subject)
+end)
+```
+
+### email_send_before
+
+Fired just before an email is sent. Receives a send table.
+
+```lua
+matcha.on("email_send_before", function(email)
+  matcha.log("Sending to: " .. email.to)
+end)
+```
+
+**Send table fields:**
+
+| Field        | Type   | Description            |
+| ------------ | ------ | ---------------------- |
+| `to`         | string | Recipient(s)           |
+| `cc`         | string | CC recipient(s)        |
+| `subject`    | string | Email subject line     |
+| `account_id` | string | Sending account ID     |
+
+### email_send_after
+
+Fired after an email is sent successfully. No arguments.
+
+```lua
+matcha.on("email_send_after", function()
+  matcha.notify("Email sent!")
+end)
+```
+
+### folder_changed
+
+Fired when you switch to a different folder. Receives the folder name as a string.
+
+```lua
+matcha.on("folder_changed", function(folder)
+  matcha.log("Now viewing: " .. folder)
+end)
+```
+
+### composer_updated
+
+Fired on every keystroke while the composer is active. Receives a state table with the current composer content.
+
+```lua
+matcha.on("composer_updated", function(state)
+  matcha.set_status("composer", state.body_len .. " chars")
+end)
+```
+
+**State table fields:**
+
+| Field      | Type   | Description                          |
+| ---------- | ------ | ------------------------------------ |
+| `body`     | string | Current body text                    |
+| `body_len` | number | Length of the body in bytes           |
+| `subject`  | string | Current subject line                 |
+| `to`       | string | Current recipient(s)                 |
+
+## Example Plugins
+
+Example plugins are included in the repository under `examples/plugins/`:
+
+| Plugin               | Description                                  |
+| -------------------- | -------------------------------------------- |
+| `hello.lua`          | Minimal example that logs startup/shutdown   |
+| `notify_github.lua`  | Notifies when GitHub emails arrive           |
+| `send_logger.lua`    | Logs outgoing email details                  |
+| `folder_announcer.lua` | Shows a notification on folder switch      |
+| `unread_counter.lua` | Displays unread count in the inbox title     |
+| `char_counter.lua`   | Live character count in the composer         |
+
+To try one, copy it to your plugins directory:
+
+```bash
+mkdir -p ~/.config/matcha/plugins
+cp examples/plugins/hello.lua ~/.config/matcha/plugins/
+```
+
+## Security
+
+Plugins run in a sandboxed Lua 5.1 environment. The following standard libraries are available:
+
+- `base` (print, type, tostring, pairs, ipairs, etc.)
+- `string`
+- `table`
+- `math`
+- `package` (for `require`)
+
+The `os`, `io`, and `debug` libraries are **not** available. Plugins cannot access the filesystem or execute system commands.

examples/plugins/char_counter.lua 🔗

@@ -0,0 +1,8 @@
+-- char_counter.lua
+-- Shows a live character count in the composer help bar.
+
+local matcha = require("matcha")
+
+matcha.on("composer_updated", function(state)
+    matcha.set_status("composer", state.body_len .. " chars")
+end)

examples/plugins/folder_announcer.lua 🔗

@@ -0,0 +1,8 @@
+-- folder_announcer.lua
+-- Shows a brief notification when you switch folders.
+
+local matcha = require("matcha")
+
+matcha.on("folder_changed", function(folder)
+    matcha.notify("Switched to " .. folder, 1)
+end)

examples/plugins/hello.lua 🔗

@@ -0,0 +1,12 @@
+-- hello.lua
+-- A minimal example plugin that logs lifecycle events.
+
+local matcha = require("matcha")
+
+matcha.on("startup", function()
+  matcha.log("hello plugin loaded")
+end)
+
+matcha.on("shutdown", function()
+  matcha.log("hello plugin shutting down, goodbye!")
+end)

examples/plugins/notify_github.lua 🔗

@@ -0,0 +1,10 @@
+-- notify_github.lua
+-- Shows a notification when emails from GitHub arrive.
+
+local matcha = require("matcha")
+
+matcha.on("email_received", function(email)
+    if email.from:match("github%.com") then
+        matcha.notify("GitHub: " .. email.subject, 3)
+    end
+end)

examples/plugins/send_logger.lua 🔗

@@ -0,0 +1,12 @@
+-- send_logger.lua
+-- Logs every email you send for personal record-keeping.
+
+local matcha = require("matcha")
+
+matcha.on("email_send_before", function(email)
+    matcha.log("Sending to: " .. email.to .. " | Subject: " .. email.subject)
+end)
+
+matcha.on("email_send_after", function()
+    matcha.notify("Email sent successfully!", 3)
+end)

examples/plugins/unread_counter.lua 🔗

@@ -0,0 +1,18 @@
+-- unread_counter.lua
+-- Displays unread count in the inbox title bar.
+
+local matcha = require("matcha")
+
+local unread = 0
+
+matcha.on("email_received", function(email)
+    if not email.is_read then
+        unread = unread + 1
+    end
+    matcha.set_status("inbox", unread .. " unread")
+end)
+
+matcha.on("folder_changed", function(folder)
+    unread = 0
+    matcha.set_status("inbox", "")
+end)

go.mod 🔗

@@ -37,6 +37,7 @@ require (
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sahilm/fuzzy v0.1.1 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/yuin/gopher-lua v1.1.1 // indirect
 	golang.org/x/net v0.52.0 // indirect
 	golang.org/x/sync v0.20.0 // indirect
 )

go.sum 🔗

@@ -73,6 +73,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
 github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
 github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
 github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
 go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI=

main.go 🔗

@@ -23,6 +23,7 @@ import (
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/plugin"
 	"github.com/floatpane/matcha/sender"
 	"github.com/floatpane/matcha/theme"
 	"github.com/floatpane/matcha/tui"
@@ -62,6 +63,7 @@ type mainModel struct {
 	current       tea.Model
 	previousModel tea.Model
 	config        *config.Config
+	plugins       *plugin.Manager
 	// Folder-based email storage
 	folderEmails map[string][]fetcher.Email // key: folderName
 	folderInbox  *tui.FolderInbox
@@ -103,6 +105,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	m.current, cmd = m.current.Update(msg)
 	cmds = append(cmds, cmd)
 
+	// Fire composer_updated hook on key presses when the composer is active
+	if _, isKey := msg.(tea.KeyPressMsg); isKey {
+		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
+			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
+			m.syncPluginStatus()
+		}
+	}
+
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
@@ -289,6 +299,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.config == nil {
 			return m, nil
 		}
+		if m.plugins != nil {
+			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
+			m.syncPluginStatus()
+		}
 		// Use in-memory cache if available
 		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 			m.emails = cached
@@ -301,7 +315,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 				m.folderInbox.SetLoadingEmails(false)
 			}
-			return m, nil
+			return m, m.pluginNotifyCmd()
 		}
 		// Fall back to disk cache for instant display, then fetch fresh in background
 		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
@@ -317,17 +331,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.folderInbox.SetLoadingEmails(false)
 			}
 			// Still fetch fresh emails in background
-			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
+			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 		}
 		if m.folderInbox != nil {
 			m.folderInbox.SetLoadingEmails(true)
 		}
-		return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
+		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
+
+	case tui.PluginNotifyMsg:
+		m.previousModel = m.current
+		m.current = tui.NewStatus(msg.Message)
+		dur := time.Duration(msg.Duration * float64(time.Second))
+		if dur <= 0 {
+			dur = 2 * time.Second
+		}
+		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
+			return tui.RestoreViewMsg{}
+		})
 
 	case tui.FolderEmailsFetchedMsg:
 		if m.folderInbox == nil {
 			return m, nil
 		}
+		// Call plugin hooks for received emails
+		if m.plugins != nil {
+			for _, email := range msg.Emails {
+				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
+				m.plugins.CallHook(plugin.HookEmailReceived, t)
+			}
+		}
 		// Always cache in memory and to disk
 		m.folderEmails[msg.FolderName] = msg.Emails
 		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
@@ -343,7 +375,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 		m.folderInbox.SetLoadingEmails(false)
-		return m, nil
+		m.syncPluginStatus()
+		return m, m.pluginNotifyCmd()
 
 	case tui.FetchFolderMoreEmailsMsg:
 		if msg.AccountID == "" || m.config == nil {
@@ -647,15 +680,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, m.current.Init()
 
 	case tui.ViewEmailMsg:
-		if m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) == nil {
+		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
+		if email == nil {
 			return m, nil
 		}
 		folderName := "INBOX"
 		if m.folderInbox != nil {
 			folderName = m.folderInbox.GetCurrentFolder()
 		}
+		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)
+		}
 		m.current = tui.NewStatus("Fetching email content...")
-		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox))
+		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
 
 	case tui.EmailBodyFetchedMsg:
 		if msg.Err != nil {
@@ -696,6 +734,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 		m.current = emailView
+		m.syncPluginStatus()
 		cmds := []tea.Cmd{m.current.Init()}
 		if markReadCmd != nil {
 			cmds = append(cmds, markReadCmd)
@@ -788,6 +827,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 
 	case tui.SendEmailMsg:
+		if m.plugins != nil {
+			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
+		}
 		// Get draft ID before clearing composer (if it's a composer)
 		var draftID string
 		if composer, ok := m.current.(*tui.Composer); ok {
@@ -840,6 +882,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return tui.RestoreViewMsg{}
 			})
 		}
+		if m.plugins != nil {
+			m.plugins.CallHook(plugin.HookEmailSendAfter)
+		}
 		m.current = tui.NewChoice()
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
@@ -968,6 +1013,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	}
 
+	if cmd := m.pluginNotifyCmd(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
 	return m, tea.Batch(cmds...)
 }
 
@@ -1072,6 +1121,34 @@ func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
 	}
 }
 
+// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
+func (m *mainModel) pluginNotifyCmd() tea.Cmd {
+	if m.plugins == nil {
+		return nil
+	}
+	if n, ok := m.plugins.TakePendingNotification(); ok {
+		return func() tea.Msg {
+			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
+		}
+	}
+	return nil
+}
+
+func (m *mainModel) syncPluginStatus() {
+	if m.plugins == nil {
+		return
+	}
+	if m.folderInbox != nil {
+		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
+	}
+	switch v := m.current.(type) {
+	case *tui.Composer:
+		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
+	case *tui.EmailView:
+		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
+	}
+}
+
 func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
 	var allEmails []fetcher.Email
 	for _, emails := range emailsByAccount {
@@ -2132,10 +2209,20 @@ func main() {
 		initialModel = newInitialModel(cfg)
 	}
 
+	// Initialize plugin system
+	plugins := plugin.NewManager()
+	plugins.LoadPlugins()
+	initialModel.plugins = plugins
+	plugins.CallHook(plugin.HookStartup)
+
 	p := tea.NewProgram(initialModel)
 
 	if _, err := p.Run(); err != nil {
+		plugins.Close()
 		fmt.Printf("Alas, there's been an error: %v", err)
 		os.Exit(1)
 	}
+
+	plugins.CallHook(plugin.HookShutdown)
+	plugins.Close()
 }

plugin/api.go 🔗

@@ -0,0 +1,53 @@
+package plugin
+
+import (
+	"log"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// registerAPI registers the "matcha" module into the Lua VM.
+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,
+	})
+
+	L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
+}
+
+// matcha.on(event, callback) — register a hook callback.
+func (m *Manager) luaOn(L *lua.LState) int {
+	event := L.CheckString(1)
+	fn := L.CheckFunction(2)
+	m.registerHook(event, fn)
+	return 0
+}
+
+// matcha.log(msg) — log a message to stderr.
+func (m *Manager) luaLog(L *lua.LState) int {
+	msg := L.CheckString(1)
+	log.Printf("[plugin] %s", msg)
+	return 0
+}
+
+// matcha.set_status(area, text) — set a persistent status string for a view area.
+// Valid areas: "inbox", "composer", "email_view".
+func (m *Manager) luaSetStatus(L *lua.LState) int {
+	area := L.CheckString(1)
+	text := L.CheckString(2)
+	m.statuses[area] = text
+	return 0
+}
+
+// matcha.notify(msg [, seconds]) — show a temporary notification in the TUI.
+// The optional second argument sets the display duration in seconds (default 2).
+func (m *Manager) luaNotify(L *lua.LState) int {
+	m.pendingNotification = L.CheckString(1)
+	m.pendingDuration = float64(L.OptNumber(2, 2))
+	return 0
+}

plugin/hooks.go 🔗

@@ -0,0 +1,140 @@
+package plugin
+
+import (
+	"log"
+	"time"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// Hook event names.
+const (
+	HookStartup         = "startup"
+	HookShutdown        = "shutdown"
+	HookEmailReceived   = "email_received"
+	HookEmailSendBefore = "email_send_before"
+	HookEmailSendAfter  = "email_send_after"
+	HookEmailViewed     = "email_viewed"
+	HookFolderChanged   = "folder_changed"
+	HookComposerUpdated = "composer_updated"
+)
+
+// Status area names.
+const (
+	StatusInbox     = "inbox"
+	StatusComposer  = "composer"
+	StatusEmailView = "email_view"
+)
+
+// registerHook adds a callback for the given event.
+func (m *Manager) registerHook(event string, fn *lua.LFunction) {
+	m.hooks[event] = append(m.hooks[event], fn)
+}
+
+// CallHook invokes all callbacks registered for the given event.
+func (m *Manager) CallHook(event string, args ...lua.LValue) {
+	callbacks, ok := m.hooks[event]
+	if !ok {
+		return
+	}
+
+	for _, fn := range callbacks {
+		if err := m.state.CallByParam(lua.P{
+			Fn:      fn,
+			NRet:    0,
+			Protect: true,
+		}, args...); err != nil {
+			log.Printf("plugin hook %q error: %v", event, err)
+		}
+	}
+}
+
+// CallSendHook calls a hook with email send metadata.
+func (m *Manager) CallSendHook(event string, to, cc, subject, accountID string) {
+	callbacks, ok := m.hooks[event]
+	if !ok {
+		return
+	}
+
+	L := m.state
+	t := L.NewTable()
+	t.RawSetString("to", lua.LString(to))
+	t.RawSetString("cc", lua.LString(cc))
+	t.RawSetString("subject", lua.LString(subject))
+	t.RawSetString("account_id", lua.LString(accountID))
+
+	for _, fn := range callbacks {
+		if err := L.CallByParam(lua.P{
+			Fn:      fn,
+			NRet:    0,
+			Protect: true,
+		}, t); err != nil {
+			log.Printf("plugin hook %q error: %v", event, err)
+		}
+	}
+}
+
+// CallFolderHook calls a hook with a folder name.
+func (m *Manager) CallFolderHook(event string, folderName string) {
+	callbacks, ok := m.hooks[event]
+	if !ok {
+		return
+	}
+
+	for _, fn := range callbacks {
+		if err := m.state.CallByParam(lua.P{
+			Fn:      fn,
+			NRet:    0,
+			Protect: true,
+		}, lua.LString(folderName)); err != nil {
+			log.Printf("plugin hook %q error: %v", event, err)
+		}
+	}
+}
+
+// CallComposerHook calls a hook with composer state info.
+func (m *Manager) CallComposerHook(event string, body, subject, to string) {
+	callbacks, ok := m.hooks[event]
+	if !ok {
+		return
+	}
+
+	L := m.state
+	t := L.NewTable()
+	t.RawSetString("body_len", lua.LNumber(len(body)))
+	t.RawSetString("body", lua.LString(body))
+	t.RawSetString("subject", lua.LString(subject))
+	t.RawSetString("to", lua.LString(to))
+
+	for _, fn := range callbacks {
+		if err := L.CallByParam(lua.P{
+			Fn:      fn,
+			NRet:    0,
+			Protect: true,
+		}, t); err != nil {
+			log.Printf("plugin hook %q error: %v", event, err)
+		}
+	}
+}
+
+// EmailToTable converts email fields into a Lua table.
+func (m *Manager) EmailToTable(uid uint32, from string, to []string, subject string, date time.Time, isRead bool, accountID string, folder string) *lua.LTable {
+	L := m.state
+
+	t := L.NewTable()
+	t.RawSetString("uid", lua.LNumber(uid))
+	t.RawSetString("from", lua.LString(from))
+	t.RawSetString("subject", lua.LString(subject))
+	t.RawSetString("date", lua.LString(date.Format(time.RFC3339)))
+	t.RawSetString("is_read", lua.LBool(isRead))
+	t.RawSetString("account_id", lua.LString(accountID))
+	t.RawSetString("folder", lua.LString(folder))
+
+	toTable := L.NewTable()
+	for i, addr := range to {
+		toTable.RawSetInt(i+1, lua.LString(addr))
+	}
+	t.RawSetString("to", toTable)
+
+	return t
+}

plugin/plugin.go 🔗

@@ -0,0 +1,131 @@
+package plugin
+
+import (
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// Manager manages the Lua VM and loaded plugins.
+type Manager struct {
+	state   *lua.LState
+	hooks   map[string][]*lua.LFunction
+	plugins []string
+	// statuses holds persistent status strings per view area, shown in the UI.
+	statuses map[string]string
+	// pendingNotification is set by matcha.notify() and consumed by the orchestrator.
+	pendingNotification string
+	pendingDuration     float64 // seconds, 0 means default (2s)
+}
+
+// NewManager creates a new plugin manager with a Lua VM.
+func NewManager() *Manager {
+	m := &Manager{
+		hooks:    make(map[string][]*lua.LFunction),
+		statuses: make(map[string]string),
+	}
+
+	L := lua.NewState(lua.Options{
+		SkipOpenLibs: true,
+	})
+
+	// Open only safe standard libraries (no os, io, debug)
+	for _, lib := range []struct {
+		name string
+		fn   lua.LGFunction
+	}{
+		{lua.LoadLibName, lua.OpenPackage},
+		{lua.BaseLibName, lua.OpenBase},
+		{lua.TabLibName, lua.OpenTable},
+		{lua.StringLibName, lua.OpenString},
+		{lua.MathLibName, lua.OpenMath},
+	} {
+		L.Push(L.NewFunction(lib.fn))
+		L.Push(lua.LString(lib.name))
+		L.Call(1, 0)
+	}
+
+	m.state = L
+	m.registerAPI()
+
+	return m
+}
+
+// LoadPlugins discovers and loads plugins from ~/.config/matcha/plugins/.
+func (m *Manager) LoadPlugins() {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return
+	}
+
+	pluginsDir := filepath.Join(home, ".config", "matcha", "plugins")
+	entries, err := os.ReadDir(pluginsDir)
+	if err != nil {
+		return
+	}
+
+	for _, entry := range entries {
+		path := filepath.Join(pluginsDir, entry.Name())
+
+		if entry.IsDir() {
+			// Directory plugin: look for init.lua
+			initPath := filepath.Join(path, "init.lua")
+			if _, err := os.Stat(initPath); err == nil {
+				m.loadPlugin(entry.Name(), initPath)
+			}
+		} else if strings.HasSuffix(entry.Name(), ".lua") {
+			// Single-file plugin
+			name := strings.TrimSuffix(entry.Name(), ".lua")
+			m.loadPlugin(name, path)
+		}
+	}
+}
+
+func (m *Manager) loadPlugin(name, path string) {
+	if err := m.state.DoFile(path); err != nil {
+		log.Printf("plugin %q: load error: %v", name, err)
+		return
+	}
+	m.plugins = append(m.plugins, name)
+	log.Printf("plugin %q: loaded", name)
+}
+
+// Plugins returns the names of all loaded plugins.
+func (m *Manager) Plugins() []string {
+	return m.plugins
+}
+
+// PendingNotification holds a notification message and its display duration.
+type PendingNotification struct {
+	Message  string
+	Duration float64 // seconds, 0 means default
+}
+
+// TakePendingNotification returns and clears any pending notification.
+func (m *Manager) TakePendingNotification() (PendingNotification, bool) {
+	if m.pendingNotification == "" {
+		return PendingNotification{}, false
+	}
+	n := PendingNotification{
+		Message:  m.pendingNotification,
+		Duration: m.pendingDuration,
+	}
+	m.pendingNotification = ""
+	m.pendingDuration = 0
+	return n, true
+}
+
+// StatusText returns the plugin status string for the given view area.
+func (m *Manager) StatusText(area string) string {
+	return m.statuses[area]
+}
+
+// Close shuts down the Lua VM.
+func (m *Manager) Close() {
+	if m.state != nil {
+		m.state.Close()
+	}
+}

tui/composer.go 🔗

@@ -82,6 +82,9 @@ type Composer struct {
 
 	// Hidden quoted text (appended to body when sending, but not shown in editor)
 	quotedText string
+
+	// Plugin status text shown in the help bar
+	pluginStatus string
 }
 
 // NewComposer initializes a new composer model.
@@ -595,7 +598,11 @@ func (m *Composer) View() tea.View {
 	}
 
 	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
-	helpView := helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit")
+	helpText := "Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"
+	if m.pluginStatus != "" {
+		helpText += " • " + m.pluginStatus
+	}
+	helpView := helpStyle.Render(helpText)
 
 	if m.height > 0 {
 		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
@@ -735,6 +742,11 @@ func (m *Composer) GetReferences() []string {
 	return m.references
 }
 
+// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
+func (m *Composer) SetPluginStatus(status string) {
+	m.pluginStatus = status
+}
+
 // ToDraft converts the composer state to a Draft for saving.
 func (m *Composer) ToDraft() config.Draft {
 	return config.Draft{

tui/email_view.go 🔗

@@ -40,6 +40,7 @@ type EmailView struct {
 	smimeTrusted       bool
 	isEncrypted        bool
 	imagePlacements    []view.ImagePlacement
+	pluginStatus       string
 }
 
 func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
@@ -258,12 +259,19 @@ func (m *EmailView) View() tea.View {
 
 	var help string
 	if m.focusOnAttachments {
-		help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
+		helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
+		if m.pluginStatus != "" {
+			helpText += " • " + m.pluginStatus
+		}
+		help = helpStyle.Render(helpText)
 	} else {
 		shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
 		if view.ImageProtocolSupported() {
 			shortcuts = shortcuts + "• \uf03e i: toggle images"
 		}
+		if m.pluginStatus != "" {
+			shortcuts += " • " + m.pluginStatus
+		}
 		help = helpStyle.Render(shortcuts)
 	}
 
@@ -314,6 +322,11 @@ func (m *EmailView) GetAccountID() string {
 	return m.accountID
 }
 
+// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
+func (m *EmailView) SetPluginStatus(status string) {
+	m.pluginStatus = status
+}
+
 func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
 	var imgs []view.InlineImage
 	for _, att := range atts {

tui/inbox.go 🔗

@@ -217,6 +217,7 @@ type Inbox struct {
 	folderName         string          // Custom folder name override for title
 	noMoreByAccount    map[string]bool // Per-account: true when pagination returns 0 results
 	extraShortHelpKeys []key.Binding
+	pluginStatus       string // Persistent status text set by plugins
 }
 
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
@@ -400,6 +401,9 @@ func (m *Inbox) getTitle() string {
 	if m.isFetching {
 		title += " (loading more...)"
 	}
+	if m.pluginStatus != "" {
+		title += " (" + m.pluginStatus + ")"
+	}
 	return title
 }
 
@@ -761,6 +765,12 @@ func (m *Inbox) SetFolderName(name string) {
 	m.list.Title = m.getTitle()
 }
 
+// SetPluginStatus sets a persistent status string from plugins, shown in the title.
+func (m *Inbox) SetPluginStatus(status string) {
+	m.pluginStatus = status
+	m.list.Title = m.getTitle()
+}
+
 // SetEmails updates all emails (used after fetch)
 func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
 	m.accounts = accounts

tui/messages.go 🔗

@@ -390,3 +390,11 @@ type FetchFolderMoreEmailsMsg struct {
 	FolderName string
 	Limit      uint32
 }
+
+// --- Plugin Messages ---
+
+// PluginNotifyMsg signals that a plugin wants to show a notification.
+type PluginNotifyMsg struct {
+	Message  string
+	Duration float64 // Duration in seconds (default 2)
+}