add HTTP, JSON and time module to starlark
Raphael Sprenger
Thu, 01 Oct 2026 13:46:55 +0200
2 files changed,
239 insertions(+),
1 deletions(-)
M
internal/policy/parse.go
→
internal/policy/parse.go
@@ -9,6 +9,8 @@ "os"
"postern/internal/db" + "go.starlark.net/lib/json" + sltime "go.starlark.net/lib/time" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" "go.starlark.net/syntax"@@ -56,12 +58,15 @@ // Expose the "os", "mda", "users", and "http" modules to the Starlark global environment
m := &MDA{ parsedListeners: make([]ListenerConfig, 0), } + predeclared := starlark.StringDict{ + "http": &HTTPModule{}, + "json": json.Module, "kv": NewKVModule(database), "mda": m, "os": osModule, + "time": sltime.Module, "users": usersModule, - "http": &HTTPModule{}, } // Create a Starlark thread. We customize the Print function
A
starlark-objects.md
@@ -0,0 +1,233 @@
+# Starlark Objects + +This document describes the objects available to Starlark policy scripts in +postern. These objects are passed into the policy hooks (`on_message_submit`, +`on_message_deliver`, `on_message_moved`) and are returned by the `users` +module. + +- [`conn`](#conn) +- [`user`](#user) +- [`msg`](#msg) + - [`Address`](#address) + - [`Attachment`](#attachment) +- [`users` module](#users-module) + - [`user.gatekeeper`](#usergatekeeper) + +--- + +## `conn` + +A `conn` represents the incoming network connection that delivered the message. +It is passed as the first argument to the `on_message_deliver` hook. + +### Attributes + +| Attribute | Type | Description | +|---------------|----------|-----------------------------------------------------------------------------------------------------------------------------------| +| `remote_addr` | `string` | The full remote address of the connection (e.g. `1.2.3.4:54321`). | +| `client_ip` | `string` | The remote IP address. For TCP connections this is the bare IP without port. Falls back to `remote_addr` for non-TCP connections. | +| `meta` | `struct` | A free-form, initially empty struct that scripts can use to stash per-connection metadata. | + +### Example + +```python +def on_message_deliver(conn, user, msg): + if conn.client_ip.startswith("10."): + return "INBOX" + return "INBOX" +``` + +--- + +## `user` + +A `user` represents a postern user account. It is passed as the second +argument to `on_message_deliver` and `on_message_submit`, and as the first +argument to `on_message_moved`. Users are also obtainable from the +[`users` module](#users-module). + +### Attributes + +| Attribute | Type | Description | +|---------------|-----------------------------------------------|-------------------------------------------------------------------------------| +| `name` | `string` | The username (login) of the user. | +| `is_disabled` | `bool` | Whether the user account is disabled. | +| `addresses` | `tuple[str]` | The list of email addresses belonging to the user, as a tuple of strings. | +| `gatekeeper` | [`UserGatekeeper`](#usergatekeeper) or `None` | The per-user gatekeeper sub-object, or `None` if no gatekeeper is configured. | + +### Example + +```python +def on_message_deliver(conn, user, msg): + if user.is_disabled: + return None + if "vip@example.com" in user.addresses: + return "VIP" + return "INBOX" +``` + +--- + +## `msg` + +A `msg` represents the email message being processed. It is passed as the +final argument to every policy hook. + +### Attributes + +| Attribute | Type | Description | +|---------------|-----------------------|----------------------------------------------------------------------------------| +| `header_from` | [`Address`](#address) | Parsed `From` header. Errors (e.g. missing `From`) propagate as Starlark errors. | +| `header_to` | [`Address`](#address) | Parsed `To` header. Returns an error if the `To` header is missing or invalid. | +| `subject` | `string` | The `Subject` header value, or an empty string if absent. | +| `size` | `int` | Size of the raw message in bytes. | +| `body` | `bytes` | The full raw message body (headers + content) as bytes. | +| `text_body` | `string` | The concatenated plain-text body parts of the message. | +| `html_body` | `string` | The concatenated HTML body parts of the message. | +| `attachments` | `list[Attachment]` | List of extracted attachments (see [`Attachment`](#attachment)). | + +Accessing `header_from`, `header_to`, `subject` triggers a one-time parse of +the message headers. Likewise, `text_body`, `html_body`, and `attachments` +trigger a one-time full parse of the message. These parses are memoized: the +message stream is only read once per group of attributes. + +### Example + +```python +def on_message_deliver(conn, user, msg): + sender = msg.header_from.address + subject = msg.subject + + for att in msg.attachments: + if att.filename.endswith(".pdf"): + print("found PDF:", att.filename) + + body_text = msg.text_body or msg.html_body + return "INBOX" +``` + +--- + +## `Address` + +A parsed email address, returned by `msg.header_from` and `msg.header_to`, and +accepted as an argument by some `users.gatekeeper` methods. + +### Attributes + +| Attribute | Type | Description | +|-----------|----------|---------------------------------------------------------------------------------------| +| `address` | `string` | The bare email address (e.g. `alice@example.com`). | +| `name` | `string` | The display name portion of the address, if any (e.g. `Alice`). Empty if not present. | +| `raw` | `string` | The original, unparsed header value as it appeared in the message. | + +### Example + +```python +addr = msg.header_from +print(addr.raw) # "Alice <alice@example.com>" +print(addr.name) # "Alice" +print(addr.address) # "alice@example.com" +``` + +--- + +## `Attachment` + +Represents a single attachment (or inline binary part) extracted from the +message. Returned as elements of `msg.attachments`. + +### Attributes + +| Attribute | Type | Description | +|----------------|----------|---------------------------------------------------------------------------------------------------------------------------| +| `filename` | `string` | The filename of the attachment, taken from `Content-Disposition` or the `name` parameter of `Content-Type`. May be empty. | +| `content_type` | `string` | The MIME media type of the attachment (e.g. `application/pdf`, `image/png`). | +| `data` | `bytes` | The decoded payload bytes of the attachment. | + +### Example + +```python +for att in msg.attachments: + if att.content_type == "application/pdf": + store_pdf(att.filename, att.data) +``` + +--- + +## `users` module + +The `users` module is a predeclared global that lets scripts look up user +accounts by name or by email address. + +### Functions + +#### `users.get(username)` + +Look up a user by username. + +- **Args**: `username: string` +- **Returns**: a [`User`](#user) object, or `None` if no such user exists. + +#### `users.get_by_address(address)` + +Look up the user that owns a given email address. + +- **Args**: `address: string` +- **Returns**: a [`User`](#user) object, or `None` if no user owns the address. + +### Example + +```python +def on_message_deliver(conn, user, msg): + recipient = msg.header_to.address + owner = users.get_by_address(recipient) + if owner is None: + return None # reject delivery + return "INBOX" +``` + +--- + +## `user.gatekeeper` + +The gatekeeper is a sub-object of [`User`](#user) that manages persistent +routing decisions: where mail from a given sender should be delivered for +that user. + +### Methods + +#### `gatekeeper.get_decision(msg)` + +Retrieve the stored routing destination for the sender of `msg`. + +- **Args**: `msg` (a [`msg`](#msg) object — only `msg.header_from` is read). +- **Returns**: `string` (the destination mailbox/folder), or `None` if no + decision is stored for this sender. + +#### `gatekeeper.route_to(from_address, destination)` + +Store or update the routing decision: mail coming from `from_address` to this +user should be delivered to `destination`. + +- **Args**: + - `from_address`: an [`Address`](#address) object (e.g. `msg.header_from`). + - `destination`: a `string` naming the destination mailbox/folder. +- **Returns**: `None`. + +### Example + +```python +def on_message_deliver(conn, user, msg): + sender = msg.header_from + dest = user.gatekeeper.get_decision(msg) + if dest is not None: + return dest + # First contact: route based on subject, and remember it. + if "invoice" in msg.subject.lower(): + dest = "Invoices" + else: + dest = "INBOX" + user.gatekeeper.route_to(sender, dest) + return dest +```