Files
Bendik Aagaard LynghaugandClaude Opus 5 1c75dd5681
Test / test (push) Successful in 21s
Publish release / package (aarch64, , aarch64, , , ) (push) Failing after 2s
Publish release / package (x86_64, /var/local/cargo-target/iris-cargo-home, bare, /usr/bin/sccache, /var/local/rustup, /var/local/cargo-target) (push) Failing after 36s
The way back in: replies read over JMAP, handed to portal as notes
A mail about a record now answers to case+<bucket>.<record>@<domain>
when a mailbox is configured, and gdo reads that mailbox: it cuts the
quoted history off what the person wrote, publishes each reply on
portal.mail.received, and marks it seen only once portal has it. The
address carries portal's own chain hash and opens nothing by itself;
portal checks the sender against the record and never moves a case on
a reply. Unconfigured, gdo sends exactly as before.

Tested: the address built for a case and refused for an invite, the
address parsed back or rejected (wrong domain, wrong prefix, no
record, odd characters), the quote and signature cut in English and
Norwegian, a JMAP mail turned into a reply with its own id, an
HTML-only mail falling back to its preview, and a half-configured
mailbox staying off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:33:41 +02:00

114 lines
4.5 KiB
Markdown

# gdo
The transmitter that sends the code through the gate so the iris
opens. [iris](https://project.uhhm.no/uhhm/iris) keeps bad content
out of a [portal](https://project.uhhm.no/uhhm/portal) site; gdo
sends the mail that lets a known person in, and every other mail a
site's content says to send.
Portal decides *when* a person hears something and renders the mail:
`mail:` on a state in `aggregates.yaml` sends when a record enters
that state, and an invite mails its one-time link built in. It
publishes each rendered mail on `portal.mail.send` in the `mail`
JetStream stream. gdo holds a durable consumer there and hands each
message to the host's SMTP. Portal never talks SMTP; gdo never reads
content; a message survives either of them being down, because the
stream keeps it.
## Run
```
gdo # the consumer; one per host
gdo probe kari@example.no # one message through, reports delivery
gdo --version
```
| Env | Default | |
|---|---|---|
| `NATS_URL` | `nats://127.0.0.1:4222` | credentials in the URL, as portal's own |
| `SMTP_HOST`, `SMTP_PORT` | `127.0.0.1`, `25` | the host's postfix; no TLS, no auth, localhost only |
| `SMTP_HELO` | the host's full name | what gdo says in EHLO; a strict server refuses a bare hostname |
| `GDO_CONSUMER` | `gdo` | the durable consumer's name |
| `GDO_TYPST_THEME` | unset | a `.typ` file; set, every mail carries a PDF rendered from it |
Install on a host from the [uhhm] Arch registry on project.uhhm.no
(the repo and its key: see uhhm/corp's README), as root:
```
pacman -Sy gdo
<edit /etc/gdo/env: NATS_URL with the host's credentials>
systemctl enable --now gdo
```
The package puts the binary in `/usr/bin`, the env template in
`/etc/gdo/env` (mode 0600, the one file with a secret), and the unit
`gdo.service`. Then `gdo probe <you>`, with the env loaded.
## The message
One JSON object per message, `v: 1`:
```json
{ "v": 1, "id": "trafikkmeldinger:<record>:ordnet", "site": "Tomter Vel",
"from": "Tomter Vel <vel@example.no>", "reply_to": "post@example.no",
"to": "kari@example.no", "subject": "Ordnet: Svingen ved skolen",
"text": "…markdown as plain text…", "html": "<!doctype html>…" }
```
`id` is the JetStream message id: a transition that fires twice sends
once. Sent as `multipart/alternative`, text first, so the plain text
is what every client can read and the HTML is the courtesy. A message
gdo cannot parse is acked and logged; one with a `v` it does not know
is left in the stream for a gdo that does; one SMTP refuses is retried
after a minute, for as long as the stream keeps it (a week).
## Typst themes
Plain text is the default and needs nothing. With `GDO_TYPST_THEME`
set to a `.typ` file and `typst` on the host, gdo runs
`typst compile` with `--input site=… subject=… text=… to=…` and
attaches the PDF as `<site>.pdf`. Typst's PDF is dependable; its HTML
export is not yet mail-grade, so the HTML alternative stays the plain
one and the theme is the attachment. A theme that fails to render
costs the attachment, never the mail.
A minimal theme:
```typst
#let site = sys.inputs.at("site", default: "")
#set page(width: 148mm, height: auto, margin: 18mm)
#set text(font: "Libertinus Serif", size: 11pt)
#text(size: 9pt, fill: gray)[#site]
#v(6mm)
#heading(sys.inputs.at("subject", default: ""))
#v(3mm)
#sys.inputs.at("text", default: "")
```
## The way back in
With a mailbox configured, every mail about a record carries a
Reply-To of its own and replies come back to the case:
| Env | |
|---|---|
| `JMAP_URL` | the session endpoint, e.g. `https://mail.example.no/.well-known/jmap` |
| `JMAP_TOKEN` | a bearer token for that account |
| `MAIL_REPLY_DOMAIN` | the domain the reply addresses live on |
| `MAIL_REPLY_PREFIX` | the local part before the `+`, `case` by default |
| `JMAP_EVERY` | seconds between looks, 60 by default |
All three of the first are needed or inbound stays off: a reply
address nobody reads is worse than none. The address is
`case+<bucket>.<record>@<domain>`, and the record id in it is
portal's own chain hash - the same unguessable capability a decide
link carries. It is half the proof: portal also checks the sender
against the address the record holds, and a reply is only ever a
note, never a decision.
gdo reads what is unread, publishes each reply on
`portal.mail.received`, and marks it seen only once portal has it - a
crash in between costs a repeated note, which portal refuses by id,
rather than a lost one. Mail addressed to anything but a record is
left unread for a person.