README.md

  1# plugin
  2
  3Lua-based plugin system for extending Matcha. Plugins are loaded from `~/.config/matcha/plugins/` and run inside a sandboxed Lua VM (no `os`, `io`, or `debug` libraries).
  4
  5## How it works
  6
  7The `Manager` creates a Lua VM at startup, registers the `matcha` module, and loads all plugins from the user's plugins directory. Plugins can be either a single `.lua` file or a directory with an `init.lua` entry point.
  8
  9Plugins interact with Matcha by registering callbacks on hooks:
 10
 11```lua
 12local matcha = require("matcha")
 13
 14matcha.on("email_received", function(email)
 15    matcha.log("New email from: " .. email.from)
 16    matcha.notify("New mail!", 3)
 17end)
 18```
 19
 20## Lua API (`matcha` module)
 21
 22| Function | Description |
 23|----------|-------------|
 24| `matcha.on(event, callback)` | Register a callback for a hook event |
 25| `matcha.log(msg)` | Log a message to stderr |
 26| `matcha.notify(msg [, seconds])` | Show a temporary notification in the TUI (default 2s) |
 27| `matcha.set_status(area, text)` | Set a persistent status string for a view area (`"inbox"`, `"composer"`, `"email_view"`) |
 28| `matcha.set_compose_field(field, value)` | Set a compose field value (`"to"`, `"cc"`, `"bcc"`, `"subject"`, `"body"`) |
 29| `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) |
 30| `matcha.http(options)` | Make an HTTP request (see below) |
 31| `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) |
 32| `matcha.store_set(key, value)` | Store a string value for this plugin |
 33| `matcha.store_get(key)` | Retrieve a stored string value, or `nil` |
 34| `matcha.store_delete(key)` | Delete a stored key for this plugin |
 35| `matcha.store_keys()` | Return a table of stored keys for this plugin |
 36| `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) |
 37| `matcha.settings(spec)` | Declare configurable settings; returns a read-only proxy table for live values (see below) |
 38| `matcha.get_setting(key [, plugin])` | Look up a setting value by key (defaults to current plugin) |
 39| `matcha.mark_read(uid, account_id, folder)` | Queue a mark-as-read operation; dispatched after the hook or keybinding returns |
 40| `matcha.mark_unread(uid, account_id, folder)` | Queue a mark-as-unread operation; dispatched after the hook or keybinding returns |
 41| `matcha.suppress_auto_read()` | Prevent the viewed email from being auto-marked as read; only effective inside an `email_viewed` callback |
 42
 43## Hook events
 44
 45| Event | Callback argument | Description |
 46|-------|-------------------|-------------|
 47| `startup` | — | Matcha has started |
 48| `shutdown` | — | Matcha is exiting |
 49| `email_received` | Lua table with `uid`, `from`, `to`, `subject`, `date`, `is_read`, `account_id`, `folder` | New email arrived |
 50| `email_viewed` | Same as `email_received` | User opened an email. Call `matcha.suppress_auto_read()` here to prevent automatic mark-as-read. |
 51| `email_send_before` | Table with `to`, `cc`, `subject`, `account_id` | About to send an email |
 52| `email_send_after` | Same as `email_send_before` | Email sent successfully |
 53| `folder_changed` | Folder name (string) | User switched folders |
 54| `composer_updated` | Table with `body`, `body_len`, `subject`, `to`, `cc`, `bcc` | Composer content changed |
 55| `email_body_render` | `(email_table, rendered, raw)` — return a string to replace the rendered body, or `nil` to keep it | About to display an email body. `rendered` is the ANSI-styled display string; `raw` is the original message source (HTML or plain text). Use for recoloring, bold/italic, removing parts, or fully replacing the displayed body with parsed output |
 56
 57## HTTP requests
 58
 59`matcha.http(options)` makes an HTTP request and returns `(response, err)`. Options is a table with:
 60
 61- `url` (string, required) — only `http` and `https` schemes
 62- `method` (string, optional, default `"GET"`)
 63- `headers` (table, optional)
 64- `body` (string, optional)
 65
 66The response table has `status` (number), `body` (string), and `headers` (table with lowercase keys).
 67
 68Safety limits: 10s timeout, 1 MB response body cap.
 69
 70```lua
 71local res, err = matcha.http({
 72    url     = "https://api.example.com/webhook",
 73    method  = "POST",
 74    headers = { ["Content-Type"] = "application/json" },
 75    body    = '{"text":"hello"}',
 76})
 77if err then
 78    matcha.log("error: " .. err)
 79    return
 80end
 81matcha.log("status: " .. res.status)
 82```
 83
 84## Persistent storage
 85
 86Plugins can store string key-value data between sessions. Storage is scoped per plugin and written to `~/.config/matcha/plugins/<plugin_name>/data.json`. Plugins that need structured values can encode them as strings.
 87
 88```lua
 89local matcha = require("matcha")
 90
 91-- Store a value
 92matcha.store_set("api_key", "sk-...")
 93
 94-- Retrieve a value
 95local key = matcha.store_get("api_key")
 96```
 97
 98Use `matcha.store_delete("api_key")` to remove a value. `matcha.store_keys()` returns a 1-indexed table of all keys stored by the current plugin, sorted lexicographically.
 99
100## User input prompts
101
102`matcha.prompt(placeholder, callback)` opens a text input overlay in the composer. When the user presses Enter, the callback receives their input string. Pressing Esc cancels without calling the callback.
103
104Only works inside a `bind_key` callback for the `"composer"` area.
105
106```lua
107matcha.bind_key("ctrl+r", "composer", "rewrite", function(state)
108    matcha.prompt("Enter instruction:", function(input)
109        -- input is the user's text
110        matcha.log("User typed: " .. input)
111    end)
112end)
113```
114
115## Body rendering
116
117`matcha.on("email_body_render", function(email, rendered, raw) ... end)` runs
118after the email body has been converted to its final ANSI-styled form and
119before it is placed in the viewport. The callback receives:
120
121- `email`: the same table as `email_viewed`
122- `rendered`: the current display string (ANSI-styled, post-HTML→terminal)
123- `raw`: the original message body (HTML or plain text) — useful for parsing
124  the source instead of the rendered output
125
126Return a new string to replace the rendered body, or `nil` to leave it
127unchanged. Multiple registered callbacks chain in registration order; each
128subsequent callback sees the previous callback's rendered output, but always
129the same raw source.
130
131`matcha.style(text, opts)` wraps `text` in lipgloss styling. `opts` keys (all
132optional):
133
134- `color`, `bg`: string color (hex `"#rrggbb"`, named like `"red"`, or ANSI 256 number as string)
135- `bold`, `italic`, `underline`, `strikethrough`, `faint`, `blink`, `reverse`: bool
136
137```lua
138local matcha = require("matcha")
139
140matcha.on("email_body_render", function(email, rendered, raw)
141    -- highlight TODO in red bold (operates on rendered)
142    rendered = rendered:gsub("TODO", function(m)
143        return matcha.style(m, { color = "#ff0000", bold = true })
144    end)
145    -- italicize anything in *asterisks*
146    rendered = rendered:gsub("%*([^%*]+)%*", function(m)
147        return matcha.style(m, { italic = true })
148    end)
149    -- strip a tracking footer entirely
150    rendered = rendered:gsub("%-%-%-%s*Sent via Tracker.*$", "")
151    return rendered
152end)
153
154-- Parse the raw source and prepend a summary; works regardless of HTML markup.
155matcha.on("email_body_render", function(email, rendered, raw)
156    local urls = {}
157    for url in raw:gmatch("https?://[%w%-_%.~%?=&/%%#:]+") do
158        urls[#urls + 1] = url
159    end
160    local header = matcha.style("URLs: " .. #urls, { bold = true }) .. "\n\n"
161    return header .. rendered
162end)
163```
164
165Caveats:
166
167- The `rendered` string already contains ANSI escape sequences from the
168  HTML→terminal conversion. Patterns that straddle existing escapes will not
169  match — match plain text spans for predictable behavior, or operate on `raw`.
170- Returning a fully replaced string fully takes over the displayed body. To
171  build styled output from scratch, compose with `matcha.style` and join with
172  newlines.
173
174## User-configurable settings
175
176`matcha.settings(spec)` declares configurable options for a plugin. Call it
177once at the top level of the plugin file. `spec` is a table mapping a setting
178key to `{ type, default, label, description }`. Supported types:
179
180- `"boolean"` — toggled in the TUI with a checkbox-style on/off selector
181- `"number"` — edited with a numeric input
182- `"string"` — edited with a text input
183
184The function returns a read-only proxy table whose fields reflect the
185currently saved value (or the default when unset). Read fields anywhere,
186including inside hook callbacks:
187
188```lua
189local matcha = require("matcha")
190
191local cfg = matcha.settings({
192    threshold  = { type = "number",  default = 5,    label = "Subject length threshold" },
193    enabled    = { type = "boolean", default = true, label = "Enable warnings" },
194    suffix     = { type = "string",  default = "!",  label = "Notification suffix" },
195})
196
197matcha.on("email_received", function(email)
198    if cfg.enabled and #email.subject > cfg.threshold then
199        matcha.notify("Long subject" .. cfg.suffix, 3)
200    end
201end)
202```
203
204Values are persisted in `~/.config/matcha/config.json` under
205`plugin_settings`. Edit them in **Settings → Plugins** in the TUI; booleans
206toggle with `enter`/`space`, numbers and strings open a text editor.
207
208## Flag management
209
210Plugins 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.
211
212```lua
213local matcha = require("matcha")
214
215-- Mark as read from a keybinding
216matcha.bind_key("r", "inbox", "Mark read", function(email)
217    if not email then return end
218    matcha.mark_read(email.uid, email.account_id, email.folder)
219end)
220
221-- Mark as unread from a keybinding
222matcha.bind_key("U", "inbox", "Mark unread", function(email)
223    if not email then return end
224    matcha.mark_unread(email.uid, email.account_id, email.folder)
225end)
226
227-- Suppress auto-read for a specific sender
228matcha.on("email_viewed", function(email)
229    if email.from:find("newsletter@") then
230        matcha.suppress_auto_read()
231    end
232end)
233```
234
235`matcha.suppress_auto_read()` must be called inside an `email_viewed` callback. Any other call site is a no-op.
236
237## Available plugins
238
239The following example plugins ship in `~/.config/matcha/plugins/`:
240
241- `email_age.lua`
242- `recipient_counter.lua`
243- `toggle_read.lua` — toggle read/unread on a selected email (configurable keybind)
244- `prevent_auto_read.lua` — suppress auto-mark-as-read when opening emails (on/off setting)
245
246## Files
247
248| File | Description |
249|------|-------------|
250| `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state; `FlagOp` type and pending flag-ops queue |
251| `hooks.go` | Hook definitions, callback registration, and hook invocation helpers |
252| `api.go` | `matcha` Lua module registration — all API functions including `mark_read`, `mark_unread`, `suppress_auto_read` |
253| `http.go` | `matcha.http()` implementation — HTTP client with timeout and body size limits |
254| `prompt.go` | `matcha.prompt()` implementation — user input overlay for the composer |