Detailed changes
@@ -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.
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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
)
@@ -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=
@@ -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()
}
@@ -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
+}
@@ -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
+}
@@ -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()
+ }
+}
@@ -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{
@@ -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 {
@@ -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
@@ -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)
+}