Skip to content

Commit 3c00cf6

Browse files
authored
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 <[email protected]>
1 parent 4a1229e commit 3c00cf6

7 files changed

Lines changed: 234 additions & 1 deletion

File tree

docs/docs/Features/Plugins.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,52 @@ end)
315315
| `cc` | string | Current CC recipient(s) |
316316
| `bcc` | string | Current BCC recipient(s) |
317317

318+
### email_body_render
319+
320+
Fired right before an email body is displayed in the email view. Receives `(email, rendered, raw)`:
321+
322+
- `email`: same table as `email_viewed`
323+
- `rendered`: the ANSI-styled display string (post HTML→terminal conversion)
324+
- `raw`: the original message body (HTML or plain text) — parse this when you need the source instead of the rendered output
325+
326+
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.
327+
328+
```lua
329+
matcha.on("email_body_render", function(email, rendered, raw)
330+
-- highlight TODO red bold
331+
rendered = rendered:gsub("TODO", function(m)
332+
return matcha.style(m, { color = "#ff0000", bold = true })
333+
end)
334+
-- italicize *asterisked* spans
335+
rendered = rendered:gsub("%*([^%*]+)%*", function(m)
336+
return matcha.style(m, { italic = true })
337+
end)
338+
-- strip a tracking footer entirely
339+
rendered = rendered:gsub("%-%-%-%s*Sent via Tracker.*$", "")
340+
return rendered
341+
end)
342+
343+
-- Full replacement: parse raw source, prepend a URL summary.
344+
matcha.on("email_body_render", function(email, rendered, raw)
345+
local urls = {}
346+
for url in raw:gmatch("https?://[%w%-_%.~%?=&/%%#:]+") do
347+
urls[#urls + 1] = url
348+
end
349+
if #urls == 0 then return rendered end
350+
local header = matcha.style("URLs: " .. #urls, { bold = true })
351+
return header .. "\n\n" .. rendered
352+
end)
353+
```
354+
355+
`matcha.style(text, opts)` wraps `text` in lipgloss styling. `opts` is a table with optional keys:
356+
357+
| Key | Type | Description |
358+
| -------------------------------------------------------------------- | ------ | ------------------------------------------------------------ |
359+
| `color`, `bg` | string | Hex (`"#rrggbb"`), name (`"red"`), or ANSI 256 number string |
360+
| `bold`, `italic`, `underline`, `strikethrough`, `faint`, `blink`, `reverse` | bool | Toggle the corresponding attribute |
361+
362+
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.
363+
318364
## Marketplace
319365

320366
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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3865,6 +3865,14 @@ func main() {
38653865
plugins := plugin.NewManager()
38663866
plugins.LoadPlugins()
38673867
initialModel.plugins = plugins
3868+
tui.BodyTransformer = func(body string, email fetcher.Email) string {
3869+
folder := "INBOX"
3870+
if initialModel.folderInbox != nil {
3871+
folder = initialModel.folderInbox.GetCurrentFolder()
3872+
}
3873+
t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
3874+
return plugins.CallBodyRenderHook(t, body, email.Body)
3875+
}
38683876
plugins.CallHook(plugin.HookStartup)
38693877

38703878
// Background sync macOS features

plugin/README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ end)
2929
| `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) |
3030
| `matcha.http(options)` | Make an HTTP request (see below) |
3131
| `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) |
32+
| `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) |
3233

3334
## Hook events
3435

@@ -42,6 +43,7 @@ end)
4243
| `email_send_after` | Same as `email_send_before` | Email sent successfully |
4344
| `folder_changed` | Folder name (string) | User switched folders |
4445
| `composer_updated` | Table with `body`, `body_len`, `subject`, `to`, `cc`, `bcc` | Composer content changed |
46+
| `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 |
4547

4648
## HTTP requests
4749

@@ -85,6 +87,65 @@ matcha.bind_key("ctrl+r", "composer", "rewrite", function(state)
8587
end)
8688
```
8789

90+
## Body rendering
91+
92+
`matcha.on("email_body_render", function(email, rendered, raw) ... end)` runs
93+
after the email body has been converted to its final ANSI-styled form and
94+
before it is placed in the viewport. The callback receives:
95+
96+
- `email`: the same table as `email_viewed`
97+
- `rendered`: the current display string (ANSI-styled, post-HTML→terminal)
98+
- `raw`: the original message body (HTML or plain text) — useful for parsing
99+
the source instead of the rendered output
100+
101+
Return a new string to replace the rendered body, or `nil` to leave it
102+
unchanged. Multiple registered callbacks chain in registration order; each
103+
subsequent callback sees the previous callback's rendered output, but always
104+
the same raw source.
105+
106+
`matcha.style(text, opts)` wraps `text` in lipgloss styling. `opts` keys (all
107+
optional):
108+
109+
- `color`, `bg`: string color (hex `"#rrggbb"`, named like `"red"`, or ANSI 256 number as string)
110+
- `bold`, `italic`, `underline`, `strikethrough`, `faint`, `blink`, `reverse`: bool
111+
112+
```lua
113+
local matcha = require("matcha")
114+
115+
matcha.on("email_body_render", function(email, rendered, raw)
116+
-- highlight TODO in red bold (operates on rendered)
117+
rendered = rendered:gsub("TODO", function(m)
118+
return matcha.style(m, { color = "#ff0000", bold = true })
119+
end)
120+
-- italicize anything in *asterisks*
121+
rendered = rendered:gsub("%*([^%*]+)%*", function(m)
122+
return matcha.style(m, { italic = true })
123+
end)
124+
-- strip a tracking footer entirely
125+
rendered = rendered:gsub("%-%-%-%s*Sent via Tracker.*$", "")
126+
return rendered
127+
end)
128+
129+
-- Parse the raw source and prepend a summary; works regardless of HTML markup.
130+
matcha.on("email_body_render", function(email, rendered, raw)
131+
local urls = {}
132+
for url in raw:gmatch("https?://[%w%-_%.~%?=&/%%#:]+") do
133+
urls[#urls + 1] = url
134+
end
135+
local header = matcha.style("URLs: " .. #urls, { bold = true }) .. "\n\n"
136+
return header .. rendered
137+
end)
138+
```
139+
140+
Caveats:
141+
142+
- The `rendered` string already contains ANSI escape sequences from the
143+
HTML→terminal conversion. Patterns that straddle existing escapes will not
144+
match — match plain text spans for predictable behavior, or operate on `raw`.
145+
- Returning a fully replaced string fully takes over the displayed body. To
146+
build styled output from scratch, compose with `matcha.style` and join with
147+
newlines.
148+
88149
## Available plugins
89150

90151
The following example plugins ship in `~/.config/matcha/plugins/`:
@@ -98,6 +159,6 @@ The following example plugins ship in `~/.config/matcha/plugins/`:
98159
|------|-------------|
99160
| `plugin.go` | Plugin manager — Lua VM setup, plugin discovery and loading, notification/status state |
100161
| `hooks.go` | Hook definitions, callback registration, and hook invocation helpers |
101-
| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`, `prompt`) |
162+
| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`, `prompt`, `style`) |
102163
| `http.go` | `matcha.http()` implementation — HTTP client with timeout and body size limits |
103164
| `prompt.go` | `matcha.prompt()` implementation — user input overlay for the composer |

plugin/api.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package plugin
33
import (
44
"log"
55

6+
"charm.land/lipgloss/v2"
67
lua "github.com/yuin/gopher-lua"
78
)
89

@@ -19,6 +20,7 @@ func (m *Manager) registerAPI() {
1920
"bind_key": m.luaBindKey,
2021
"http": m.luaHTTP,
2122
"prompt": m.luaPrompt,
23+
"style": m.luaStyle,
2224
})
2325

2426
L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
@@ -78,6 +80,57 @@ func (m *Manager) luaBindKey(L *lua.LState) int {
7880
return 0
7981
}
8082

83+
// matcha.style(text, opts) — wrap text in lipgloss styling and return the
84+
// resulting ANSI-styled string. opts is a table with optional keys:
85+
// - color, bg: string (hex "#rrggbb", ANSI 256 number as string, or named like "red")
86+
// - bold, italic, underline, strikethrough, faint, blink, reverse: bool
87+
//
88+
// Plugins use this from email_body_render callbacks to style matched substrings:
89+
//
90+
// matcha.on("email_body_render", function(email, body)
91+
// return (body:gsub("TODO", function(m)
92+
// return matcha.style(m, {color = "#ff0000", bold = true})
93+
// end))
94+
// end)
95+
func (m *Manager) luaStyle(L *lua.LState) int {
96+
text := L.CheckString(1)
97+
opts := L.OptTable(2, nil)
98+
99+
style := lipgloss.NewStyle()
100+
if opts != nil {
101+
if v, ok := opts.RawGetString("color").(lua.LString); ok && v != "" {
102+
style = style.Foreground(lipgloss.Color(string(v)))
103+
}
104+
if v, ok := opts.RawGetString("bg").(lua.LString); ok && v != "" {
105+
style = style.Background(lipgloss.Color(string(v)))
106+
}
107+
if lua.LVAsBool(opts.RawGetString("bold")) {
108+
style = style.Bold(true)
109+
}
110+
if lua.LVAsBool(opts.RawGetString("italic")) {
111+
style = style.Italic(true)
112+
}
113+
if lua.LVAsBool(opts.RawGetString("underline")) {
114+
style = style.Underline(true)
115+
}
116+
if lua.LVAsBool(opts.RawGetString("strikethrough")) {
117+
style = style.Strikethrough(true)
118+
}
119+
if lua.LVAsBool(opts.RawGetString("faint")) {
120+
style = style.Faint(true)
121+
}
122+
if lua.LVAsBool(opts.RawGetString("blink")) {
123+
style = style.Blink(true)
124+
}
125+
if lua.LVAsBool(opts.RawGetString("reverse")) {
126+
style = style.Reverse(true)
127+
}
128+
}
129+
130+
L.Push(lua.LString(style.Render(text)))
131+
return 1
132+
}
133+
81134
// matcha.set_compose_field(field, value) — set a compose field value.
82135
// Valid fields: "to", "cc", "bcc", "subject", "body".
83136
func (m *Manager) luaSetComposeField(L *lua.LState) int {

plugin/hooks.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
HookEmailViewed = "email_viewed"
1818
HookFolderChanged = "folder_changed"
1919
HookComposerUpdated = "composer_updated"
20+
HookEmailBodyRender = "email_body_render"
2021
)
2122

2223
// Status area names.
@@ -119,6 +120,42 @@ func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc stri
119120
}
120121
}
121122

123+
// CallBodyRenderHook runs all email_body_render callbacks, threading the body
124+
// string through each. Callbacks receive (email_table, rendered, raw):
125+
// - rendered: the current display string (ANSI-styled, post-HTML→terminal)
126+
// - raw: the original message body (HTML or plain text, same string fed to
127+
// the renderer) — useful for parsing the source instead of the rendered
128+
// output
129+
//
130+
// A callback may return a string to replace the rendered body, or nil to leave
131+
// it unchanged. Non-string returns are ignored. Multiple callbacks chain in
132+
// registration order; each subsequent callback sees the previous callback's
133+
// rendered output, but always the same raw source.
134+
func (m *Manager) CallBodyRenderHook(email *lua.LTable, rendered, raw string) string {
135+
callbacks, ok := m.hooks[HookEmailBodyRender]
136+
if !ok {
137+
return rendered
138+
}
139+
140+
L := m.state
141+
for _, fn := range callbacks {
142+
if err := L.CallByParam(lua.P{
143+
Fn: fn,
144+
NRet: 1,
145+
Protect: true,
146+
}, email, lua.LString(rendered), lua.LString(raw)); err != nil {
147+
log.Printf("plugin hook %q error: %v", HookEmailBodyRender, err)
148+
continue
149+
}
150+
ret := L.Get(-1)
151+
L.Pop(1)
152+
if s, ok := ret.(lua.LString); ok {
153+
rendered = string(s)
154+
}
155+
}
156+
return rendered
157+
}
158+
122159
// CallKeyBinding invokes a plugin key binding callback with the given arguments.
123160
func (m *Manager) CallKeyBinding(binding KeyBinding, args ...lua.LValue) {
124161
if err := m.state.CallByParam(lua.P{

plugins/registry.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@
4747
"description": "Warns before sending an email with an empty body.",
4848
"file": "empty_body_guard.lua"
4949
},
50+
{
51+
"name": "link_summary",
52+
"title": "Link Summary",
53+
"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.",
54+
"file": "link_summary.lua"
55+
},
56+
{
57+
"name": "github_highlighter",
58+
"title": "GitHub Highlighter",
59+
"description": "Highlights every \"GitHub\" mention in displayed email bodies with bold purple text. Demo of the email_body_render hook.",
60+
"file": "github_highlighter.lua"
61+
},
5062
{
5163
"name": "folder_announcer",
5264
"title": "Folder Announcer",

tui/email_view.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ var (
2929
attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
3030
)
3131

32+
// BodyTransformer, if set, post-processes the rendered email body before it is
33+
// placed in the viewport. main.go wires this up to the plugin manager so that
34+
// plugins registered on the "email_body_render" hook can rewrite, recolor, or
35+
// remove parts of the displayed body.
36+
var BodyTransformer func(body string, email fetcher.Email) string
37+
38+
func applyBodyTransform(body string, email fetcher.Email) string {
39+
if BodyTransformer == nil {
40+
return body
41+
}
42+
return BodyTransformer(body, email)
43+
}
44+
3245
type EmailView struct {
3346
viewport viewport.Model
3447
email fetcher.Email
@@ -114,6 +127,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
114127
if err != nil {
115128
body = fmt.Sprintf("Error rendering body: %v", err)
116129
}
130+
body = applyBodyTransform(body, email)
117131

118132
// Create header and compute heights that reduce viewport space.
119133
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) {
230244
if err != nil {
231245
body = fmt.Sprintf("Error rendering body: %v", err)
232246
}
247+
body = applyBodyTransform(body, m.email)
233248
m.imagePlacements = placements
234249
wrapped := wrapBodyToWidth(body, m.viewport.Width())
235250
m.viewport.SetContent(wrapped + "\n")
@@ -306,6 +321,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
306321
if err != nil {
307322
body = fmt.Sprintf("Error rendering body: %v", err)
308323
}
324+
body = applyBodyTransform(body, m.email)
309325
m.imagePlacements = placements
310326
wrapped := wrapBodyToWidth(body, m.viewport.Width())
311327
m.viewport.SetContent(wrapped + "\n")

0 commit comments

Comments
 (0)