diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md index 932b5ed7de6c942e823c738dc6da87e9d0c6edd7..5fdb8a1d4f3ec51bc0ccb589b1e811a408d03558 100644 --- a/docs/docs/Features/Plugins.md +++ b/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. diff --git a/plugin/README.md b/plugin/README.md index 1c390be7ef0096eca9e1fdbe76f35f3771b90514..a92527ac32923e57d186757a3832c33ab8702905 100644 --- a/plugin/README.md +++ b/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 | diff --git a/plugin/api.go b/plugin/api.go index 6bc161ebc0d44c1efba99e1b6d159b284f65cdf1..dc7308d76388c65ffda42f1bf947dde954a893bb 100644 --- a/plugin/api.go +++ b/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")) diff --git a/plugin/http.go b/plugin/http.go new file mode 100644 index 0000000000000000000000000000000000000000..a608d164d17a74fbf8b5d88f536f51d0710f3e5a --- /dev/null +++ b/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 +} diff --git a/plugin/http_test.go b/plugin/http_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9928eb70aa15272d9e80de18ae5bc08d6c670106 --- /dev/null +++ b/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)) + } +} diff --git a/plugins/weather_status.lua b/plugins/weather_status.lua new file mode 100644 index 0000000000000000000000000000000000000000..0f0f127857bed8e123d0df51d5d4ee6d17263dc0 --- /dev/null +++ b/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) diff --git a/plugins/webhook_notify.lua b/plugins/webhook_notify.lua new file mode 100644 index 0000000000000000000000000000000000000000..9a081671efab13bd286734511f372dac4b2d3e1e --- /dev/null +++ b/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)