diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md new file mode 100644 index 0000000000000000000000000000000000000000..0552ca7a6061899cfd2f4fd4151c4ce51b3a7cc3 --- /dev/null +++ b/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. diff --git a/examples/plugins/char_counter.lua b/examples/plugins/char_counter.lua new file mode 100644 index 0000000000000000000000000000000000000000..a2a5751da913cff7fc5fd69fa68d0b278a034c1d --- /dev/null +++ b/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) diff --git a/examples/plugins/folder_announcer.lua b/examples/plugins/folder_announcer.lua new file mode 100644 index 0000000000000000000000000000000000000000..a935f70899ee9164197c2d9f8d4a5c3ed328b2c0 --- /dev/null +++ b/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) diff --git a/examples/plugins/hello.lua b/examples/plugins/hello.lua new file mode 100644 index 0000000000000000000000000000000000000000..667e2f5dbcf3a3c93791997bab15f3d68f5b0f43 --- /dev/null +++ b/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) diff --git a/examples/plugins/notify_github.lua b/examples/plugins/notify_github.lua new file mode 100644 index 0000000000000000000000000000000000000000..d271956d83f034837e35550057f750996a3b7e66 --- /dev/null +++ b/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) diff --git a/examples/plugins/send_logger.lua b/examples/plugins/send_logger.lua new file mode 100644 index 0000000000000000000000000000000000000000..4bbe053121be492fa66a33edd26d92d9d23a68a2 --- /dev/null +++ b/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) diff --git a/examples/plugins/unread_counter.lua b/examples/plugins/unread_counter.lua new file mode 100644 index 0000000000000000000000000000000000000000..b06774d282f5901b43c7a945368fd1ed46db9ae6 --- /dev/null +++ b/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) diff --git a/go.mod b/go.mod index 42827ee8b52909e8c3b63b84e9bac0dddeca9058..a9233819615af2bcba9281d4d11e11c74bcd300e 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index ed8fff1ae7c1fbf3dcd2f485d852bb2c41cb73e9..2f15c04fbab216772061ec21e7a0e5bd894803d3 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 2090cb2e4bf4786ca989c9e88cc7f30f5b03ca87..0cd7f988bba6b9c2a0d9187ba23346077f8ab2d8 100644 --- a/main.go +++ b/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() } diff --git a/plugin/api.go b/plugin/api.go new file mode 100644 index 0000000000000000000000000000000000000000..31d36994767bff5c4f231b73d27824f9a182ef63 --- /dev/null +++ b/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 +} diff --git a/plugin/hooks.go b/plugin/hooks.go new file mode 100644 index 0000000000000000000000000000000000000000..dc843c16e015d473a92eb83de2c7abe3a671df66 --- /dev/null +++ b/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 +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000000000000000000000000000000000000..120f171c2bc81f096378adfd06c5182f1f4dcda1 --- /dev/null +++ b/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() + } +} diff --git a/tui/composer.go b/tui/composer.go index f169520e50518ed95f5b3ca126b899515265adb7..a970f0feda0ce64f605d8a618bc1a652a5e4394a 100644 --- a/tui/composer.go +++ b/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{ diff --git a/tui/email_view.go b/tui/email_view.go index 898605e41f65df648c3212ad0eff7e4ee14ae3e5..dfbd9ea35b07972dc6075c7d3f13ea00e36d2541 100644 --- a/tui/email_view.go +++ b/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 { diff --git a/tui/inbox.go b/tui/inbox.go index 71acb3a9fc1aadbbcb9188feff5926341e7f2ada..379a468c4a989aad1fa8d6e628f21412810c2ef7 100644 --- a/tui/inbox.go +++ b/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 diff --git a/tui/messages.go b/tui/messages.go index c05aee344cc7fa155333c4f2117600ed3f4107c7..99adaa693248a24f7f769c2b5ce94700e2f50e39 100644 --- a/tui/messages.go +++ b/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) +}