2026-09-23 19:47:57 +02:00
|
|
|
# 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
|
2026-09-23 21:15:40 +02:00
|
|
|
publishes each rendered mail on `portal.mail.send` in the `mail`
|
2026-09-23 19:47:57 +02:00
|
|
|
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 |
|
|
|
|
|
|
2026-09-23 20:07:54 +02:00
|
|
|
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:
|
2026-09-23 19:47:57 +02:00
|
|
|
|
|
|
|
|
```
|
2026-09-23 20:07:54 +02:00
|
|
|
pacman -Sy gdo
|
|
|
|
|
<edit /etc/gdo/env: NATS_URL with the host's credentials>
|
|
|
|
|
systemctl enable --now gdo
|
2026-09-23 19:47:57 +02:00
|
|
|
```
|
|
|
|
|
|
2026-09-23 20:07:54 +02:00
|
|
|
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.
|
2026-09-23 19:47:57 +02:00
|
|
|
|
|
|
|
|
## 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: "")
|
|
|
|
|
```
|