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