# 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 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 `, with the env loaded. ## The message One JSON object per message, `v: 1`: ```json { "v": 1, "id": "trafikkmeldinger::ordnet", "site": "Tomter Vel", "from": "Tomter Vel ", "reply_to": "post@example.no", "to": "kari@example.no", "subject": "Ordnet: Svingen ved skolen", "text": "…markdown as plain text…", "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 `.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+.@`, 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.