feat: HTTP for plugins (#473)

Drew Smirnoff and Lea created

Co-authored-by: Lea <lea@floatpane.com>

Change summary

docs/docs/Features/Plugins.md |  45 +++++++++
plugin/README.md              |  31 ++++++
plugin/api.go                 |   1 
plugin/http.go                | 109 +++++++++++++++++++++
plugin/http_test.go           | 185 +++++++++++++++++++++++++++++++++++++
plugins/weather_status.lua    |  22 ++++
plugins/webhook_notify.lua    |  26 +++++
7 files changed, 418 insertions(+), 1 deletion(-)

Detailed changes

docs/docs/Features/Plugins.md 🔗

@@ -134,6 +134,47 @@ matcha.bind_key("ctrl+g", "composer", "greeting", function(state)
 end)
 ```
 
+### matcha.http(options)
+
+Make an HTTP request. Takes a single options table and returns two values: a response table on success, or `nil` plus an error string on failure.
+
+**Options table:**
+
+| Field     | Type   | Required | Description                              |
+| --------- | ------ | -------- | ---------------------------------------- |
+| `url`     | string | yes      | Request URL (http or https only)         |
+| `method`  | string | no       | HTTP method (default `"GET"`)            |
+| `headers` | table  | no       | Request headers as key-value pairs       |
+| `body`    | string | no       | Request body                             |
+
+**Response table:**
+
+| Field     | Type   | Description                                  |
+| --------- | ------ | -------------------------------------------- |
+| `status`  | number | HTTP status code (e.g. 200)                  |
+| `body`    | string | Response body (capped at 1 MB)               |
+| `headers` | table  | Response headers (lowercase keys)            |
+
+**Limits:** Requests time out after 10 seconds. Response bodies are capped at 1 MB. Only `http://` and `https://` URLs are allowed.
+
+```lua
+-- GET request
+local res, err = matcha.http({ url = "https://api.example.com/status" })
+if err then
+  matcha.log("error: " .. err)
+  return
+end
+matcha.log("status: " .. res.status)
+
+-- POST request with headers and body
+local res, err = matcha.http({
+  url     = "https://hooks.slack.com/services/xxx",
+  method  = "POST",
+  headers = { ["Content-Type"] = "application/json" },
+  body    = '{"text":"New email received!"}',
+})
+```
+
 ### 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).
@@ -271,6 +312,8 @@ Example plugins are included in the repository under `examples/plugins/`:
 | `folder_announcer.lua` | Shows a notification on folder switch      |
 | `unread_counter.lua` | Displays unread count in the inbox title     |
 | `char_counter.lua`   | Live character count in the composer         |
+| `webhook_notify.lua` | Posts to a webhook when emails arrive        |
+| `weather_status.lua` | Shows current weather in the inbox status bar |
 
 To try one, copy it to your plugins directory:
 
@@ -290,3 +333,5 @@ Plugins run in a sandboxed Lua 5.1 environment. The following standard libraries
 - `package` (for `require`)
 
 The `os`, `io`, and `debug` libraries are **not** available. Plugins cannot access the filesystem or execute system commands.
+
+Plugins can make HTTP requests via `matcha.http()`, with built-in safety limits: 10-second timeout, 1 MB response cap, and only `http`/`https` schemes.

plugin/README.md 🔗

@@ -27,6 +27,7 @@ end)
 | `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"`) |
 | `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) |
 
 ## Hook events
 
@@ -41,6 +42,33 @@ end)
 | `folder_changed` | Folder name (string) | User switched folders |
 | `composer_updated` | Table with `body`, `body_len`, `subject`, `to`, `cc`, `bcc` | Composer content changed |
 
+## HTTP requests
+
+`matcha.http(options)` makes an HTTP request and returns `(response, err)`. Options is a table with:
+
+- `url` (string, required) — only `http` and `https` schemes
+- `method` (string, optional, default `"GET"`)
+- `headers` (table, optional)
+- `body` (string, optional)
+
+The response table has `status` (number), `body` (string), and `headers` (table with lowercase keys).
+
+Safety limits: 10s timeout, 1 MB response body cap.
+
+```lua
+local res, err = matcha.http({
+    url     = "https://api.example.com/webhook",
+    method  = "POST",
+    headers = { ["Content-Type"] = "application/json" },
+    body    = '{"text":"hello"}',
+})
+if err then
+    matcha.log("error: " .. err)
+    return
+end
+matcha.log("status: " .. res.status)
+```
+
 ## Available plugins
 
 The following example plugins ship in `~/.config/matcha/plugins/`:
@@ -54,4 +82,5 @@ 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`) |
+| `api.go` | `matcha` Lua module registration (`on`, `log`, `notify`, `set_status`, `set_compose_field`, `bind_key`, `http`) |
+| `http.go` | `matcha.http()` implementation — HTTP client with timeout and body size limits |

plugin/api.go 🔗

@@ -17,6 +17,7 @@ func (m *Manager) registerAPI() {
 		"set_status":        m.luaSetStatus,
 		"set_compose_field": m.luaSetComposeField,
 		"bind_key":          m.luaBindKey,
+		"http":              m.luaHTTP,
 	})
 
 	L.SetField(mod, "_VERSION", lua.LString("0.1.0"))

plugin/http.go 🔗

@@ -0,0 +1,109 @@
+package plugin
+
+import (
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+const (
+	httpTimeout     = 10 * time.Second
+	httpMaxBodySize = 1 << 20 // 1 MB
+)
+
+var httpClient = &http.Client{
+	Timeout: httpTimeout,
+}
+
+// luaHTTP implements matcha.http(options) — make an HTTP request.
+//
+// options is a table with fields:
+//   - url     (string, required)
+//   - method  (string, optional, default "GET")
+//   - headers (table, optional)
+//   - body    (string, optional)
+//
+// Returns (response_table, nil) on success or (nil, error_string) on failure.
+// response_table has fields: status (number), body (string), headers (table).
+func (m *Manager) luaHTTP(L *lua.LState) int {
+	opts := L.CheckTable(1)
+
+	// URL (required).
+	urlVal := opts.RawGetString("url")
+	if urlVal == lua.LNil {
+		L.Push(lua.LNil)
+		L.Push(lua.LString("missing required field: url"))
+		return 2
+	}
+	rawURL := urlVal.String()
+
+	// Scheme validation.
+	if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
+		L.Push(lua.LNil)
+		L.Push(lua.LString("unsupported URL scheme: only http and https are allowed"))
+		return 2
+	}
+
+	// Method (optional, default GET).
+	method := "GET"
+	if v := opts.RawGetString("method"); v != lua.LNil {
+		method = strings.ToUpper(v.String())
+	}
+
+	// Body (optional).
+	var bodyReader io.Reader
+	if v := opts.RawGetString("body"); v != lua.LNil {
+		bodyReader = strings.NewReader(v.String())
+	}
+
+	req, err := http.NewRequest(method, rawURL, bodyReader)
+	if err != nil {
+		L.Push(lua.LNil)
+		L.Push(lua.LString(err.Error()))
+		return 2
+	}
+
+	// Headers (optional).
+	if v := opts.RawGetString("headers"); v != lua.LNil {
+		if tbl, ok := v.(*lua.LTable); ok {
+			tbl.ForEach(func(k, v lua.LValue) {
+				req.Header.Set(k.String(), v.String())
+			})
+		}
+	}
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		L.Push(lua.LNil)
+		L.Push(lua.LString(err.Error()))
+		return 2
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxBodySize))
+	if err != nil {
+		L.Push(lua.LNil)
+		L.Push(lua.LString(err.Error()))
+		return 2
+	}
+
+	// Build response table.
+	result := L.NewTable()
+	result.RawSetString("status", lua.LNumber(resp.StatusCode))
+	result.RawSetString("body", lua.LString(string(body)))
+
+	headers := L.NewTable()
+	for k, vals := range resp.Header {
+		if len(vals) > 0 {
+			headers.RawSetString(strings.ToLower(k), lua.LString(vals[0]))
+		}
+	}
+	result.RawSetString("headers", headers)
+
+	L.Push(result)
+	L.Push(lua.LNil)
+	return 2
+}

plugin/http_test.go 🔗

@@ -0,0 +1,185 @@
+package plugin
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	lua "github.com/yuin/gopher-lua"
+)
+
+// newTestManager creates a Manager with a fresh Lua VM for testing.
+func newTestManager() *Manager {
+	return NewManager()
+}
+
+func TestHTTPGet(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "GET" {
+			t.Errorf("expected GET, got %s", r.Method)
+		}
+		w.Header().Set("X-Test", "hello")
+		w.WriteHeader(200)
+		w.Write([]byte("ok"))
+	}))
+	defer srv.Close()
+
+	m := newTestManager()
+	defer m.Close()
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		res, err = matcha.http({ url = "` + srv.URL + `" })
+	`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	errVal := m.state.GetGlobal("err")
+	if errVal != lua.LNil {
+		t.Fatalf("expected nil error, got %v", errVal)
+	}
+
+	res := m.state.GetGlobal("res")
+	tbl, ok := res.(*lua.LTable)
+	if !ok {
+		t.Fatalf("expected table, got %T", res)
+	}
+
+	if status := tbl.RawGetString("status"); status.(lua.LNumber) != 200 {
+		t.Errorf("expected status 200, got %v", status)
+	}
+	if body := tbl.RawGetString("body"); body.String() != "ok" {
+		t.Errorf("expected body 'ok', got %q", body.String())
+	}
+
+	headers := tbl.RawGetString("headers").(*lua.LTable)
+	if v := headers.RawGetString("x-test"); v.String() != "hello" {
+		t.Errorf("expected header x-test='hello', got %q", v.String())
+	}
+}
+
+func TestHTTPPostWithBodyAndHeaders(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Errorf("expected POST, got %s", r.Method)
+		}
+		if ct := r.Header.Get("Content-Type"); ct != "application/json" {
+			t.Errorf("expected Content-Type application/json, got %q", ct)
+		}
+		body, _ := io.ReadAll(r.Body)
+		w.Write(body)
+	}))
+	defer srv.Close()
+
+	m := newTestManager()
+	defer m.Close()
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		res, err = matcha.http({
+			url = "` + srv.URL + `",
+			method = "post",
+			headers = { ["Content-Type"] = "application/json" },
+			body = '{"key":"value"}',
+		})
+	`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	errVal := m.state.GetGlobal("err")
+	if errVal != lua.LNil {
+		t.Fatalf("expected nil error, got %v", errVal)
+	}
+
+	res := m.state.GetGlobal("res")
+	tbl := res.(*lua.LTable)
+	if body := tbl.RawGetString("body"); body.String() != `{"key":"value"}` {
+		t.Errorf("expected echoed body, got %q", body.String())
+	}
+}
+
+func TestHTTPMissingURL(t *testing.T) {
+	m := newTestManager()
+	defer m.Close()
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		res, err = matcha.http({})
+	`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resVal := m.state.GetGlobal("res")
+	if resVal != lua.LNil {
+		t.Errorf("expected nil result, got %v", resVal)
+	}
+
+	errVal := m.state.GetGlobal("err")
+	if errVal == lua.LNil {
+		t.Fatal("expected error, got nil")
+	}
+	if !strings.Contains(errVal.String(), "url") {
+		t.Errorf("expected error about url, got %q", errVal.String())
+	}
+}
+
+func TestHTTPInvalidScheme(t *testing.T) {
+	m := newTestManager()
+	defer m.Close()
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		res, err = matcha.http({ url = "file:///etc/passwd" })
+	`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resVal := m.state.GetGlobal("res")
+	if resVal != lua.LNil {
+		t.Errorf("expected nil result, got %v", resVal)
+	}
+
+	errVal := m.state.GetGlobal("err")
+	if errVal == lua.LNil {
+		t.Fatal("expected error, got nil")
+	}
+	if !strings.Contains(errVal.String(), "scheme") {
+		t.Errorf("expected error about scheme, got %q", errVal.String())
+	}
+}
+
+func TestHTTPBodyTruncation(t *testing.T) {
+	// Server returns more than 1 MB.
+	bigBody := strings.Repeat("x", httpMaxBodySize+1024)
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte(bigBody))
+	}))
+	defer srv.Close()
+
+	m := newTestManager()
+	defer m.Close()
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		res, err = matcha.http({ url = "` + srv.URL + `" })
+		body_len = #res.body
+	`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	bodyLen := m.state.GetGlobal("body_len")
+	n, ok := bodyLen.(lua.LNumber)
+	if !ok {
+		t.Fatalf("expected number, got %T", bodyLen)
+	}
+	if int(n) > httpMaxBodySize {
+		t.Errorf("expected body to be capped at %d, got %d", httpMaxBodySize, int(n))
+	}
+}

plugins/weather_status.lua 🔗

@@ -0,0 +1,22 @@
+-- weather_status.lua
+-- Fetches current weather and displays it in the inbox status bar.
+-- Uses the free wttr.in API (no API key required).
+
+local matcha = require("matcha")
+
+local CITY = "London"
+
+matcha.on("startup", function()
+    local res, err = matcha.http({
+        url = "https://wttr.in/" .. CITY .. "?format=%t+%C",
+    })
+
+    if err then
+        matcha.log("weather: " .. err)
+        return
+    end
+
+    if res.status == 200 then
+        matcha.set_status("inbox", res.body)
+    end
+end)

plugins/webhook_notify.lua 🔗

@@ -0,0 +1,26 @@
+-- webhook_notify.lua
+-- Posts a JSON payload to a webhook URL when an email is received.
+
+local matcha = require("matcha")
+
+local WEBHOOK_URL = "https://example.com/webhook"
+
+matcha.on("email_received", function(email)
+    local payload = '{"from":"' .. email.from .. '","subject":"' .. email.subject .. '"}'
+
+    local res, err = matcha.http({
+        url = WEBHOOK_URL,
+        method = "POST",
+        headers = { ["Content-Type"] = "application/json" },
+        body = payload,
+    })
+
+    if err then
+        matcha.log("webhook error: " .. err)
+        return
+    end
+
+    if res.status >= 400 then
+        matcha.log("webhook returned status " .. res.status)
+    end
+end)