From 3c00cf616a76e756546078ee68826ecb219c3bc9 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sun, 3 May 2026 21:59:49 +0400 Subject: [PATCH] feat: manupulate bodies from plugins (#1226) ## What? This adds one focused extension point: post-render body transformation with raw-source access. Plugins can now: - Recolor / bold / italicize matched substrings (`gsub` + `matcha.style`) - Remove parts of the body (`gsub` with `""`) - Parse the raw HTML/plain source and prepend or fully replace the displayed output ## Why? Plugin SDK previously had no way to touch displayed email content. Signed-off-by: drew --- docs/docs/Features/Plugins.md | 46 +++++++++++++++++++++++++ main.go | 8 +++++ plugin/README.md | 63 ++++++++++++++++++++++++++++++++++- plugin/api.go | 53 +++++++++++++++++++++++++++++ plugin/hooks.go | 37 ++++++++++++++++++++ plugins/registry.json | 12 +++++++ tui/email_view.go | 16 +++++++++ 7 files changed, 234 insertions(+), 1 deletion(-) diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md index c71c151c5deca809f94c05f60f624058b7e534dd..fb929532a92ff4648217b2421fb64713552b9007 100644 --- a/docs/docs/Features/Plugins.md +++ b/docs/docs/Features/Plugins.md @@ -315,6 +315,52 @@ end) | `cc` | string | Current CC recipient(s) | | `bcc` | string | Current BCC recipient(s) | +### email_body_render + +Fired right before an email body is displayed in the email view. Receives `(email, rendered, raw)`: + +- `email`: same table as `email_viewed` +- `rendered`: the ANSI-styled display string (post HTML→terminal conversion) +- `raw`: the original message body (HTML or plain text) — parse this when you need the source instead of the rendered output + +Return a new string to replace the rendered body, or `nil` to leave it unchanged. You can recolor, bold/italicize, remove parts, or fully replace the displayed body with parsed output. + +```lua +matcha.on("email_body_render", function(email, rendered, raw) + -- highlight TODO red bold + rendered = rendered:gsub("TODO", function(m) + return matcha.style(m, { color = "#ff0000", bold = true }) + end) + -- italicize *asterisked* spans + rendered = rendered:gsub("%*([^%*]+)%*", function(m) + return matcha.style(m, { italic = true }) + end) + -- strip a tracking footer entirely + rendered = rendered:gsub("%-%-%-%s*Sent via Tracker.*$", "") + return rendered +end) + +-- Full replacement: parse raw source, prepend a URL summary. +matcha.on("email_body_render", function(email, rendered, raw) + local urls = {} + for url in raw:gmatch("https?://[%w%-_%.~%?=&/%%#:]+") do + urls[#urls + 1] = url + end + if #urls == 0 then return rendered end + local header = matcha.style("URLs: " .. #urls, { bold = true }) + return header .. "\n\n" .. rendered +end) +``` + +`matcha.style(text, opts)` wraps `text` in lipgloss styling. `opts` is a table with optional keys: + +| Key | Type | Description | +| -------------------------------------------------------------------- | ------ | ------------------------------------------------------------ | +| `color`, `bg` | string | Hex (`"#rrggbb"`), name (`"red"`), or ANSI 256 number string | +| `bold`, `italic`, `underline`, `strikethrough`, `faint`, `blink`, `reverse` | bool | Toggle the corresponding attribute | + +Caveat: the body string already contains ANSI escape sequences from the HTML→terminal conversion. Patterns that straddle existing escapes will not match. Match plain text spans for predictable behavior. + ## Marketplace Matcha includes a built-in plugin marketplace with 35+ community plugins. You can browse and install plugins from the terminal or from the [online marketplace](/marketplace). diff --git a/main.go b/main.go index 2fcfccc6bb0bd33d19ddc6cb606f4044fcfc6fae..87a83b094c6892a441e2b11c4f21ee3515c77634 100644 --- a/main.go +++ b/main.go @@ -3865,6 +3865,14 @@ func main() { plugins := plugin.NewManager() plugins.LoadPlugins() initialModel.plugins = plugins + tui.BodyTransformer = func(body string, email fetcher.Email) string { + folder := "INBOX" + if initialModel.folderInbox != nil { + folder = initialModel.folderInbox.GetCurrentFolder() + } + t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder) + return plugins.CallBodyRenderHook(t, body, email.Body) + } plugins.CallHook(plugin.HookStartup) // Background sync macOS features diff --git a/plugin/README.md b/plugin/README.md index 80cf6f6bf41eebf18670090be43cc19e0acc5c2d..f5a47bdcfa5346782e521c843aeb97c45b3b9cd9 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -29,6 +29,7 @@ end) | `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) | | `matcha.http(options)` | Make an HTTP request (see below) | | `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) | +| `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) | ## Hook events @@ -42,6 +43,7 @@ end) | `email_send_after` | Same as `email_send_before` | Email sent successfully | | `folder_changed` | Folder name (string) | User switched folders | | `composer_updated` | Table with `body`, `body_len`, `subject`, `to`, `cc`, `bcc` | Composer content changed | +| `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 | ## HTTP requests @@ -85,6 +87,65 @@ matcha.bind_key("ctrl+r", "composer", "rewrite", function(state) end) ``` +## Body rendering + +`matcha.on("email_body_render", function(email, rendered, raw) ... end)` runs +after the email body has been converted to its final ANSI-styled form and +before it is placed in the viewport. The callback receives: + +- `email`: the same table as `email_viewed` +- `rendered`: the current display string (ANSI-styled, post-HTML→terminal) +- `raw`: the original message body (HTML or plain text) — useful for parsing + the source instead of the rendered output + +Return a new string to replace the rendered body, or `nil` to leave it +unchanged. Multiple registered callbacks chain in registration order; each +subsequent callback sees the previous callback's rendered output, but always +the same raw source. + +`matcha.style(text, opts)` wraps `text` in lipgloss styling. `opts` keys (all +optional): + +- `color`, `bg`: string color (hex `"#rrggbb"`, named like `"red"`, or ANSI 256 number as string) +- `bold`, `italic`, `underline`, `strikethrough`, `faint`, `blink`, `reverse`: bool + +```lua +local matcha = require("matcha") + +matcha.on("email_body_render", function(email, rendered, raw) + -- highlight TODO in red bold (operates on rendered) + rendered = rendered:gsub("TODO", function(m) + return matcha.style(m, { color = "#ff0000", bold = true }) + end) + -- italicize anything in *asterisks* + rendered = rendered:gsub("%*([^%*]+)%*", function(m) + return matcha.style(m, { italic = true }) + end) + -- strip a tracking footer entirely + rendered = rendered:gsub("%-%-%-%s*Sent via Tracker.*$", "") + return rendered +end) + +-- Parse the raw source and prepend a summary; works regardless of HTML markup. +matcha.on("email_body_render", function(email, rendered, raw) + local urls = {} + for url in raw:gmatch("https?://[%w%-_%.~%?=&/%%#:]+") do + urls[#urls + 1] = url + end + local header = matcha.style("URLs: " .. #urls, { bold = true }) .. "\n\n" + return header .. rendered +end) +``` + +Caveats: + +- The `rendered` string already contains ANSI escape sequences from the + HTML→terminal conversion. Patterns that straddle existing escapes will not + match — match plain text spans for predictable behavior, or operate on `raw`. +- Returning a fully replaced string fully takes over the displayed body. To + build styled output from scratch, compose with `matcha.style` and join with + newlines. + ## Available plugins The following example plugins ship in `~/.config/matcha/plugins/`: @@ -98,6 +159,6 @@ The following example plugins ship in `~/.config/matcha/plugins/`: |------|-------------| | `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state | | `hooks.go` | Hook definitions, callback registration, and hook invocation helpers | -| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`, `prompt`) | +| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`, `prompt`, `style`) | | `http.go` | `matcha.http()` implementation — HTTP client with timeout and body size limits | | `prompt.go` | `matcha.prompt()` implementation — user input overlay for the composer | diff --git a/plugin/api.go b/plugin/api.go index ec3750b7b125d535109a85d5b8280cce668f5c8e..987923f00e5f376b9d95548b9d467f4c52609f33 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -3,6 +3,7 @@ package plugin import ( "log" + "charm.land/lipgloss/v2" lua "github.com/yuin/gopher-lua" ) @@ -19,6 +20,7 @@ func (m *Manager) registerAPI() { "bind_key": m.luaBindKey, "http": m.luaHTTP, "prompt": m.luaPrompt, + "style": m.luaStyle, }) L.SetField(mod, "_VERSION", lua.LString("0.1.0")) @@ -78,6 +80,57 @@ func (m *Manager) luaBindKey(L *lua.LState) int { return 0 } +// matcha.style(text, opts) — wrap text in lipgloss styling and return the +// resulting ANSI-styled string. opts is a table with optional keys: +// - color, bg: string (hex "#rrggbb", ANSI 256 number as string, or named like "red") +// - bold, italic, underline, strikethrough, faint, blink, reverse: bool +// +// Plugins use this from email_body_render callbacks to style matched substrings: +// +// matcha.on("email_body_render", function(email, body) +// return (body:gsub("TODO", function(m) +// return matcha.style(m, {color = "#ff0000", bold = true}) +// end)) +// end) +func (m *Manager) luaStyle(L *lua.LState) int { + text := L.CheckString(1) + opts := L.OptTable(2, nil) + + style := lipgloss.NewStyle() + if opts != nil { + if v, ok := opts.RawGetString("color").(lua.LString); ok && v != "" { + style = style.Foreground(lipgloss.Color(string(v))) + } + if v, ok := opts.RawGetString("bg").(lua.LString); ok && v != "" { + style = style.Background(lipgloss.Color(string(v))) + } + if lua.LVAsBool(opts.RawGetString("bold")) { + style = style.Bold(true) + } + if lua.LVAsBool(opts.RawGetString("italic")) { + style = style.Italic(true) + } + if lua.LVAsBool(opts.RawGetString("underline")) { + style = style.Underline(true) + } + if lua.LVAsBool(opts.RawGetString("strikethrough")) { + style = style.Strikethrough(true) + } + if lua.LVAsBool(opts.RawGetString("faint")) { + style = style.Faint(true) + } + if lua.LVAsBool(opts.RawGetString("blink")) { + style = style.Blink(true) + } + if lua.LVAsBool(opts.RawGetString("reverse")) { + style = style.Reverse(true) + } + } + + L.Push(lua.LString(style.Render(text))) + return 1 +} + // matcha.set_compose_field(field, value) — set a compose field value. // Valid fields: "to", "cc", "bcc", "subject", "body". func (m *Manager) luaSetComposeField(L *lua.LState) int { diff --git a/plugin/hooks.go b/plugin/hooks.go index 03cb7525204a2caffc859addda7410dc59fba100..58f6916e1c4a37984973fc896d3e2f28e9a86f0e 100644 --- a/plugin/hooks.go +++ b/plugin/hooks.go @@ -17,6 +17,7 @@ const ( HookEmailViewed = "email_viewed" HookFolderChanged = "folder_changed" HookComposerUpdated = "composer_updated" + HookEmailBodyRender = "email_body_render" ) // Status area names. @@ -119,6 +120,42 @@ func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc stri } } +// CallBodyRenderHook runs all email_body_render callbacks, threading the body +// string through each. Callbacks receive (email_table, rendered, raw): +// - rendered: the current display string (ANSI-styled, post-HTML→terminal) +// - raw: the original message body (HTML or plain text, same string fed to +// the renderer) — useful for parsing the source instead of the rendered +// output +// +// A callback may return a string to replace the rendered body, or nil to leave +// it unchanged. Non-string returns are ignored. Multiple callbacks chain in +// registration order; each subsequent callback sees the previous callback's +// rendered output, but always the same raw source. +func (m *Manager) CallBodyRenderHook(email *lua.LTable, rendered, raw string) string { + callbacks, ok := m.hooks[HookEmailBodyRender] + if !ok { + return rendered + } + + L := m.state + for _, fn := range callbacks { + if err := L.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, email, lua.LString(rendered), lua.LString(raw)); err != nil { + log.Printf("plugin hook %q error: %v", HookEmailBodyRender, err) + continue + } + ret := L.Get(-1) + L.Pop(1) + if s, ok := ret.(lua.LString); ok { + rendered = string(s) + } + } + return rendered +} + // CallKeyBinding invokes a plugin key binding callback with the given arguments. func (m *Manager) CallKeyBinding(binding KeyBinding, args ...lua.LValue) { if err := m.state.CallByParam(lua.P{ diff --git a/plugins/registry.json b/plugins/registry.json index 13e7f5e59f54e39e2709022da225466be46dad45..669a1d65259ed1e344e58e91fcccc978ca4317b6 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -47,6 +47,18 @@ "description": "Warns before sending an email with an empty body.", "file": "empty_body_guard.lua" }, + { + "name": "link_summary", + "title": "Link Summary", + "description": "Parses the raw body, extracts every URL, and prepends a numbered link summary to the displayed email. Demo of full body manipulation via the email_body_render hook.", + "file": "link_summary.lua" + }, + { + "name": "github_highlighter", + "title": "GitHub Highlighter", + "description": "Highlights every \"GitHub\" mention in displayed email bodies with bold purple text. Demo of the email_body_render hook.", + "file": "github_highlighter.lua" + }, { "name": "folder_announcer", "title": "Folder Announcer", diff --git a/tui/email_view.go b/tui/email_view.go index 73a26fba8dea57f94b31934b977c361712b62ed7..6d2b8b7528d9c51bc01740d61c8e11661418f989 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -29,6 +29,19 @@ var ( attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1) ) +// BodyTransformer, if set, post-processes the rendered email body before it is +// placed in the viewport. main.go wires this up to the plugin manager so that +// plugins registered on the "email_body_render" hook can rewrite, recolor, or +// remove parts of the displayed body. +var BodyTransformer func(body string, email fetcher.Email) string + +func applyBodyTransform(body string, email fetcher.Email) string { + if BodyTransformer == nil { + return body + } + return BodyTransformer(body, email) +} + type EmailView struct { viewport viewport.Model email fetcher.Email @@ -114,6 +127,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } + body = applyBodyTransform(body, email) // Create header and compute heights that reduce viewport space. header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) @@ -230,6 +244,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } + body = applyBodyTransform(body, m.email) m.imagePlacements = placements wrapped := wrapBodyToWidth(body, m.viewport.Width()) m.viewport.SetContent(wrapped + "\n") @@ -306,6 +321,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } + body = applyBodyTransform(body, m.email) m.imagePlacements = placements wrapped := wrapBodyToWidth(body, m.viewport.Width()) m.viewport.SetContent(wrapped + "\n")