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

4.5 KiB

gdo

The transmitter that sends the code through the gate so the iris opens. iris keeps bad content out of a 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:

{ "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:

#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.