feat: manupulate bodies from plugins (#1226)

Drew Smirnoff created

## 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 <me@andrinoff.com>

Change summary

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(-)

Detailed changes

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).

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

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 |

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 {

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{

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",

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")