Detailed changes
@@ -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).
@@ -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
@@ -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 |
@@ -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 {
@@ -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{
@@ -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",
@@ -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")