Detailed changes
@@ -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.
@@ -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 |
@@ -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"))
@@ -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
+}
@@ -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))
+ }
+}
@@ -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)
@@ -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)