feat(plugins): edit composer body (#457)

Drew Smirnoff and Steve Evans created

Co-authored-by: Steve Evans <steve@floatpane.com>

Change summary

docs/docs/Features/Plugins.md | 25 +++++++++++++++++++++++++
main.go                       | 24 +++++++++++++++++++++++-
plugin/README.md              |  5 +++--
plugin/api.go                 | 24 ++++++++++++++++++++----
plugin/hooks.go               |  4 +++-
plugin/plugin.go              | 17 +++++++++++++++--
plugins/auto_bcc.lua          | 13 +++++++++++++
tui/composer.go               | 30 ++++++++++++++++++++++++++++++
8 files changed, 132 insertions(+), 10 deletions(-)

Detailed changes

docs/docs/Features/Plugins.md 🔗

@@ -79,6 +79,29 @@ matcha.set_status("composer", "420 chars")  -- shows in composer help bar
 matcha.set_status("inbox", "")              -- clears the inbox status
 ```
 
+### matcha.set_compose_field(field, value)
+
+Set a compose field value from a plugin. Only works when the composer is active (e.g. inside a `composer_updated` callback). The change is applied after the hook returns.
+
+**Available fields:**
+
+| Field       | Description              |
+| ----------- | ------------------------ |
+| `"to"`      | Recipient(s)             |
+| `"cc"`      | CC recipient(s)          |
+| `"bcc"`     | BCC recipient(s)         |
+| `"subject"` | Subject line             |
+| `"body"`    | Email body               |
+
+```lua
+-- Auto-add a BCC on every new email
+matcha.on("composer_updated", function(state)
+  if state.bcc == "" then
+    matcha.set_compose_field("bcc", "archive@example.com")
+  end
+end)
+```
+
 ### matcha.notify(message [, seconds])
 
 Show a temporary notification in the Matcha UI. The optional second argument sets how long the notification is displayed (default 2 seconds).
@@ -201,6 +224,8 @@ end)
 | `body_len` | number | Length of the body in bytes           |
 | `subject`  | string | Current subject line                 |
 | `to`       | string | Current recipient(s)                 |
+| `cc`       | string | Current CC recipient(s)              |
+| `bcc`      | string | Current BCC recipient(s)             |
 
 ## Example Plugins
 

main.go 🔗

@@ -149,8 +149,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	// Fire composer_updated hook on key presses when the composer is active
 	if _, isKey := msg.(tea.KeyPressMsg); isKey {
 		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
-			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
+			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
 			m.syncPluginStatus()
+			m.applyPluginFields(composer)
 		}
 	}
 
@@ -1318,6 +1319,27 @@ func (m *mainModel) syncPluginStatus() {
 	}
 }
 
+func (m *mainModel) applyPluginFields(composer *tui.Composer) {
+	fields := m.plugins.TakePendingFields()
+	if fields == nil {
+		return
+	}
+	for field, value := range fields {
+		switch field {
+		case "to":
+			composer.SetTo(value)
+		case "cc":
+			composer.SetCc(value)
+		case "bcc":
+			composer.SetBcc(value)
+		case "subject":
+			composer.SetSubject(value)
+		case "body":
+			composer.SetBody(value)
+		}
+	}
+}
+
 func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
 	var allEmails []fetcher.Email
 	for _, emails := range emailsByAccount {

plugin/README.md 🔗

@@ -25,6 +25,7 @@ end)
 | `matcha.log(msg)` | Log a message to stderr |
 | `matcha.notify(msg [, seconds])` | Show a temporary notification in the TUI (default 2s) |
 | `matcha.set_status(area, text)` | Set a persistent status string for a view area (`"inbox"`, `"composer"`, `"email_view"`) |
+| `matcha.set_compose_field(field, value)` | Set a compose field value (`"to"`, `"cc"`, `"bcc"`, `"subject"`, `"body"`) |
 
 ## Hook events
 
@@ -37,7 +38,7 @@ end)
 | `email_send_before` | Table with `to`, `cc`, `subject`, `account_id` | About to send an email |
 | `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` | Composer content changed |
+| `composer_updated` | Table with `body`, `body_len`, `subject`, `to`, `cc`, `bcc` | Composer content changed |
 
 ## Available plugins
 
@@ -52,4 +53,4 @@ 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`) |
+| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`) |

plugin/api.go 🔗

@@ -11,10 +11,11 @@ func (m *Manager) registerAPI() {
 	L := m.state
 
 	mod := L.RegisterModule("matcha", map[string]lua.LGFunction{
-		"on":         m.luaOn,
-		"log":        m.luaLog,
-		"notify":     m.luaNotify,
-		"set_status": m.luaSetStatus,
+		"on":                m.luaOn,
+		"log":               m.luaLog,
+		"notify":            m.luaNotify,
+		"set_status":        m.luaSetStatus,
+		"set_compose_field": m.luaSetComposeField,
 	})
 
 	L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
@@ -51,3 +52,18 @@ func (m *Manager) luaNotify(L *lua.LState) int {
 	m.pendingDuration = float64(L.OptNumber(2, 2))
 	return 0
 }
+
+// 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 {
+	field := L.CheckString(1)
+	value := L.CheckString(2)
+
+	switch field {
+	case "to", "cc", "bcc", "subject", "body":
+		m.pendingFields[field] = value
+	default:
+		L.ArgError(1, "invalid field: must be \"to\", \"cc\", \"bcc\", \"subject\", or \"body\"")
+	}
+	return 0
+}

plugin/hooks.go 🔗

@@ -93,7 +93,7 @@ func (m *Manager) CallFolderHook(event string, folderName string) {
 }
 
 // CallComposerHook calls a hook with composer state info.
-func (m *Manager) CallComposerHook(event string, body, subject, to string) {
+func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc string) {
 	callbacks, ok := m.hooks[event]
 	if !ok {
 		return
@@ -105,6 +105,8 @@ func (m *Manager) CallComposerHook(event string, body, subject, to string) {
 	t.RawSetString("body", lua.LString(body))
 	t.RawSetString("subject", lua.LString(subject))
 	t.RawSetString("to", lua.LString(to))
+	t.RawSetString("cc", lua.LString(cc))
+	t.RawSetString("bcc", lua.LString(bcc))
 
 	for _, fn := range callbacks {
 		if err := L.CallByParam(lua.P{

plugin/plugin.go 🔗

@@ -19,13 +19,16 @@ type Manager struct {
 	// pendingNotification is set by matcha.notify() and consumed by the orchestrator.
 	pendingNotification string
 	pendingDuration     float64 // seconds, 0 means default (2s)
+	// pendingFields holds compose field updates set by matcha.set_compose_field().
+	pendingFields map[string]string
 }
 
 // NewManager creates a new plugin manager with a Lua VM.
 func NewManager() *Manager {
 	m := &Manager{
-		hooks:    make(map[string][]*lua.LFunction),
-		statuses: make(map[string]string),
+		hooks:         make(map[string][]*lua.LFunction),
+		statuses:      make(map[string]string),
+		pendingFields: make(map[string]string),
 	}
 
 	L := lua.NewState(lua.Options{
@@ -118,6 +121,16 @@ func (m *Manager) TakePendingNotification() (PendingNotification, bool) {
 	return n, true
 }
 
+// TakePendingFields returns and clears any pending compose field updates.
+func (m *Manager) TakePendingFields() map[string]string {
+	if len(m.pendingFields) == 0 {
+		return nil
+	}
+	fields := m.pendingFields
+	m.pendingFields = make(map[string]string)
+	return fields
+}
+
 // StatusText returns the plugin status string for the given view area.
 func (m *Manager) StatusText(area string) string {
 	return m.statuses[area]

plugins/auto_bcc.lua 🔗

@@ -0,0 +1,13 @@
+-- auto_bcc.lua
+-- Automatically adds a BCC address to every email you compose.
+-- Change the address below to your own archive/backup address.
+
+local matcha = require("matcha")
+
+local bcc_address = "archive@example.com"
+
+matcha.on("composer_updated", function(state)
+    if state.bcc == "" then
+        matcha.set_compose_field("bcc", bcc_address)
+    end
+end)

tui/composer.go 🔗

@@ -700,11 +700,41 @@ func (m *Composer) GetTo() string {
 	return m.toInput.Value()
 }
 
+// SetTo updates the To field with new content.
+func (m *Composer) SetTo(to string) {
+	m.toInput.SetValue(to)
+}
+
+// GetCc returns the current Cc field value.
+func (m *Composer) GetCc() string {
+	return m.ccInput.Value()
+}
+
+// SetCc updates the Cc field with new content.
+func (m *Composer) SetCc(cc string) {
+	m.ccInput.SetValue(cc)
+}
+
+// GetBcc returns the current Bcc field value.
+func (m *Composer) GetBcc() string {
+	return m.bccInput.Value()
+}
+
+// SetBcc updates the Bcc field with new content.
+func (m *Composer) SetBcc(bcc string) {
+	m.bccInput.SetValue(bcc)
+}
+
 // GetSubject returns the current Subject field value.
 func (m *Composer) GetSubject() string {
 	return m.subjectInput.Value()
 }
 
+// SetSubject updates the Subject field with new content.
+func (m *Composer) SetSubject(subject string) {
+	m.subjectInput.SetValue(subject)
+}
+
 // GetBody returns the current Body field value.
 func (m *Composer) GetBody() string {
 	return m.bodyInput.Value()