Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/docs/Features/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 62 additions & 1 deletion plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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/`:
Expand All @@ -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 |
53 changes: 53 additions & 0 deletions plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package plugin
import (
"log"

"charm.land/lipgloss/v2"
lua "github.com/yuin/gopher-lua"
)

Expand All @@ -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"))
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions plugin/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
HookEmailViewed = "email_viewed"
HookFolderChanged = "folder_changed"
HookComposerUpdated = "composer_updated"
HookEmailBodyRender = "email_body_render"
)

// Status area names.
Expand Down Expand Up @@ -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{
Expand Down
12 changes: 12 additions & 0 deletions plugins/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions tui/email_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading