gdo: the code that opens the iris
A durable consumer on portal's WORMHOLE stream, each message handed to the host's SMTP as text with an HTML alternative; acked on acceptance, retried after a minute otherwise, left for a newer gdo when the contract version is unknown. `gdo probe <to>` sends one through and waits for the consumer to drain. GDO_TYPST_THEME attaches a Typst PDF. A systemd unit and an installer for one gdo per host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
commit
983491295e
@@ -0,0 +1,46 @@
|
|||||||
|
name: Publish release
|
||||||
|
|
||||||
|
# A `v*` tag builds the binary and attaches it to the Gitea release as
|
||||||
|
# `gdo`, with the tag's CHANGELOG.md section as the notes. Hosts
|
||||||
|
# install it with deploy/install.sh.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: bare
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
|
||||||
|
RUSTC_WRAPPER: /usr/bin/sccache
|
||||||
|
RUSTUP_HOME: /var/local/rustup
|
||||||
|
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
|
||||||
|
SCCACHE_DIR: /var/local/sccache
|
||||||
|
SCCACHE_SERVER_PORT: "4228"
|
||||||
|
CARGO_TARGET_DIR: /var/local/cargo-target
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release && cargo test --release
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${{ github.ref_name }}"
|
||||||
|
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
|
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
|
||||||
|
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $tag"; exit 1; }
|
||||||
|
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
|
||||||
|
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||||
|
-d "$body" "$api/releases" | jq .id) \
|
||||||
|
|| id=$(curl -sf -H "$auth" "$api/releases/tags/$tag" | jq .id)
|
||||||
|
for aid in $(curl -sf -H "$auth" "$api/releases/$id/assets" | jq '.[].id'); do
|
||||||
|
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$aid"
|
||||||
|
done
|
||||||
|
curl -sf -X POST -H "$auth" \
|
||||||
|
-F "attachment=@/var/local/cargo-target/release/gdo" \
|
||||||
|
"$api/releases/$id/assets?name=gdo" > /dev/null
|
||||||
|
echo "published $tag"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: bare
|
||||||
|
env:
|
||||||
|
# Same shared toolchain and cache as uhhm/portal's workflows, with
|
||||||
|
# the writable cargo home uhhm/iris uses.
|
||||||
|
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
|
||||||
|
RUSTC_WRAPPER: /usr/bin/sccache
|
||||||
|
RUSTUP_HOME: /var/local/rustup
|
||||||
|
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
|
||||||
|
SCCACHE_DIR: /var/local/sccache
|
||||||
|
SCCACHE_SERVER_PORT: "4228"
|
||||||
|
CARGO_TARGET_DIR: /var/local/cargo-target
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Test
|
||||||
|
run: cargo test
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 (2026-09-23)
|
||||||
|
|
||||||
|
- First release: a durable consumer `gdo` on the `WORMHOLE` stream (`portal.mail.>`), each message handed to the host's SMTP as text with an HTML alternative, acked on acceptance and retried after a minute otherwise; `gdo probe <address>` sends one message through and reports when it was delivered; an optional Typst theme (`GDO_TYPST_THEME`) renders a PDF of every mail and attaches it.
|
||||||
Generated
+1720
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "gdo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Sends the code that opens the iris: delivers portal's outgoing mail from JetStream to the host's SMTP"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://project.uhhm.no/uhhm/gdo"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
async-nats = "0.38"
|
||||||
|
futures = "0.3"
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "hostname"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "process", "fs"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = "thin"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 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 `WORMHOLE`
|
||||||
|
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 a release, as root:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -sfLO https://project.uhhm.no/uhhm/gdo/raw/branch/main/deploy/install.sh
|
||||||
|
sudo sh install.sh v0.1.0 'nats://infra:<token>@127.0.0.1:4222'
|
||||||
|
```
|
||||||
|
|
||||||
|
That puts the binary in `/usr/local/bin`, the env in `/etc/gdo.env`
|
||||||
|
(mode 0600, the one file with a secret), and the unit
|
||||||
|
`deploy/gdo.service` enabled and running. Then `gdo probe <you>`.
|
||||||
|
|
||||||
|
## 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: "")
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# One per host, not per site: every portal instance on the host
|
||||||
|
# publishes to the same WORMHOLE stream, and each message carries its
|
||||||
|
# own sender. Install with deploy/install.sh.
|
||||||
|
[Unit]
|
||||||
|
Description=gdo - delivers portal's outgoing mail to the host's SMTP
|
||||||
|
After=network-online.target nats-server.service postfix.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/gdo
|
||||||
|
EnvironmentFile=-/etc/gdo.env
|
||||||
|
DynamicUser=yes
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Install or upgrade gdo on this host from a Gitea release. Run as root:
|
||||||
|
# sudo sh install.sh v0.1.0 [nats://user:pass@127.0.0.1:4222]
|
||||||
|
# The NATS URL is only needed the first time; it lands in /etc/gdo.env,
|
||||||
|
# which is the one file with a secret in it (mode 0600).
|
||||||
|
set -eu
|
||||||
|
tag=${1:?usage: install.sh vX.Y.Z [NATS_URL]}
|
||||||
|
base=https://project.uhhm.no/uhhm/gdo
|
||||||
|
tmp=$(mktemp)
|
||||||
|
curl -sfL "$base/releases/download/$tag/gdo" -o "$tmp"
|
||||||
|
install -m 0755 "$tmp" /usr/local/bin/gdo
|
||||||
|
rm -f "$tmp"
|
||||||
|
if [ ! -f /etc/gdo.env ]; then
|
||||||
|
nats=${2:-nats://127.0.0.1:4222}
|
||||||
|
umask 077
|
||||||
|
printf 'NATS_URL=%s\nSMTP_HOST=127.0.0.1\nSMTP_PORT=25\nSMTP_HELO=%s\n# GDO_TYPST_THEME=/etc/gdo/theme.typ\n' "$nats" "$(hostname -f 2>/dev/null || cat /etc/hostname)" > /etc/gdo.env
|
||||||
|
fi
|
||||||
|
curl -sfL "$base/raw/tag/$tag/deploy/gdo.service" -o /etc/systemd/system/gdo.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now gdo
|
||||||
|
systemctl restart gdo
|
||||||
|
sleep 2
|
||||||
|
systemctl is-active gdo && /usr/local/bin/gdo --version
|
||||||
+372
@@ -0,0 +1,372 @@
|
|||||||
|
//! gdo - the transmitter that sends the code through the gate so the
|
||||||
|
//! iris opens. Portal (uhhm/portal) decides when a person hears
|
||||||
|
//! something and renders the mail; gdo holds a durable consumer on
|
||||||
|
//! the `WORMHOLE` stream and hands each message to the host's SMTP.
|
||||||
|
//! No site knowledge, no state of its own: if it dies, the stream
|
||||||
|
//! keeps the mail until it is back.
|
||||||
|
//!
|
||||||
|
//! `gdo` run the consumer
|
||||||
|
//! `gdo probe <to>` send one message through and report when it
|
||||||
|
//! was delivered
|
||||||
|
//! `gdo --version`
|
||||||
|
//!
|
||||||
|
//! Env: NATS_URL (nats://127.0.0.1:4222), SMTP_HOST (127.0.0.1),
|
||||||
|
//! SMTP_PORT (25), GDO_CONSUMER (gdo), GDO_TYPST_THEME (a .typ file;
|
||||||
|
//! set, every mail also carries a PDF rendered from it).
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use lettre::{message::{header::ContentType, Attachment, MultiPart, SinglePart}, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Portal's side of this contract is `portal::mail`. Same names, same
|
||||||
|
/// stream config, so whichever boots first creates it.
|
||||||
|
const STREAM: &str = "WORMHOLE";
|
||||||
|
const SUBJECTS: &str = "portal.mail.>";
|
||||||
|
const SUBJECT: &str = "portal.mail.send";
|
||||||
|
const CONTRACT: u8 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct Outgoing {
|
||||||
|
v: u8,
|
||||||
|
id: String,
|
||||||
|
site: String,
|
||||||
|
from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
reply_to: Option<String>,
|
||||||
|
to: String,
|
||||||
|
subject: String,
|
||||||
|
text: String,
|
||||||
|
html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
nats_url: String,
|
||||||
|
smtp_host: String,
|
||||||
|
smtp_port: u16,
|
||||||
|
/// What gdo says in EHLO. A strict server (stalwart) refuses a bare
|
||||||
|
/// hostname, so this is the host's fully qualified name, or
|
||||||
|
/// `SMTP_HELO` when the host does not know its own.
|
||||||
|
smtp_helo: String,
|
||||||
|
consumer: String,
|
||||||
|
typst_theme: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
fn from_env() -> Self {
|
||||||
|
let var = |k: &str, d: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).unwrap_or_else(|| d.to_string());
|
||||||
|
Config {
|
||||||
|
nats_url: var("NATS_URL", "nats://127.0.0.1:4222"),
|
||||||
|
smtp_host: var("SMTP_HOST", "127.0.0.1"),
|
||||||
|
smtp_port: var("SMTP_PORT", "25").parse().unwrap_or(25),
|
||||||
|
smtp_helo: var("SMTP_HELO", &fqdn()),
|
||||||
|
consumer: var("GDO_CONSUMER", "gdo"),
|
||||||
|
typst_theme: std::env::var("GDO_TYPST_THEME").ok().filter(|v| !v.is_empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(url: &str) -> anyhow::Result<async_nats::Client> {
|
||||||
|
let parsed = url::parse(url)?;
|
||||||
|
let mut opts = async_nats::ConnectOptions::new();
|
||||||
|
if let Some((user, pass)) = parsed {
|
||||||
|
opts = opts.user_and_password(user, pass);
|
||||||
|
}
|
||||||
|
Ok(opts.connect(url).await.with_context(|| format!("connecting to {url}"))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user and password of a `nats://user:pass@host` URL, without a
|
||||||
|
/// URL crate: the one shape portal's own env uses.
|
||||||
|
mod url {
|
||||||
|
pub fn parse(url: &str) -> anyhow::Result<Option<(String, String)>> {
|
||||||
|
let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
|
||||||
|
let Some((cred, _)) = rest.rsplit_once('@') else { return Ok(None) };
|
||||||
|
let (user, pass) = cred.split_once(':').unwrap_or((cred, ""));
|
||||||
|
Ok(Some((user.to_string(), pass.to_string())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
||||||
|
Ok(js
|
||||||
|
.get_or_create_stream(async_nats::jetstream::stream::Config {
|
||||||
|
name: STREAM.to_string(),
|
||||||
|
subjects: vec![SUBJECTS.to_string()],
|
||||||
|
max_age: Duration::from_secs(7 * 24 * 3600),
|
||||||
|
duplicate_window: Duration::from_secs(24 * 3600),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mail as SMTP sees it: text and HTML alternatives, and the
|
||||||
|
/// themed PDF when a theme is set.
|
||||||
|
fn build_message(mail: &Outgoing, pdf: Option<Vec<u8>>) -> anyhow::Result<Message> {
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(mail.from.parse().with_context(|| format!("from address {:?}", mail.from))?)
|
||||||
|
.to(mail.to.parse().with_context(|| format!("to address {:?}", mail.to))?)
|
||||||
|
.subject(&mail.subject);
|
||||||
|
if let Some(reply_to) = &mail.reply_to {
|
||||||
|
builder = builder.reply_to(reply_to.parse().with_context(|| format!("reply-to address {reply_to:?}"))?);
|
||||||
|
}
|
||||||
|
let alternative = MultiPart::alternative()
|
||||||
|
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(mail.text.clone()))
|
||||||
|
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(mail.html.clone()));
|
||||||
|
let body = match pdf {
|
||||||
|
Some(bytes) => MultiPart::mixed()
|
||||||
|
.multipart(alternative)
|
||||||
|
.singlepart(Attachment::new(format!("{}.pdf", mail.site)).body(bytes, ContentType::parse("application/pdf")?)),
|
||||||
|
None => alternative,
|
||||||
|
};
|
||||||
|
Ok(builder.multipart(body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Typst theme: `typst compile` on the theme with the mail's fields
|
||||||
|
/// as `--input`s (`site`, `subject`, `text`, `to`), giving a PDF. A
|
||||||
|
/// theme that fails to render costs the attachment, never the mail.
|
||||||
|
async fn render_theme(theme: &str, mail: &Outgoing) -> Option<Vec<u8>> {
|
||||||
|
let out = std::env::temp_dir().join(format!("gdo-{}.pdf", mail.id.replace(['/', ':'], "_")));
|
||||||
|
let status = tokio::process::Command::new("typst")
|
||||||
|
.arg("compile")
|
||||||
|
.arg("--input").arg(format!("site={}", mail.site))
|
||||||
|
.arg("--input").arg(format!("subject={}", mail.subject))
|
||||||
|
.arg("--input").arg(format!("text={}", mail.text))
|
||||||
|
.arg("--input").arg(format!("to={}", mail.to))
|
||||||
|
.arg(theme)
|
||||||
|
.arg(&out)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
let bytes = tokio::fs::read(&out).await.ok();
|
||||||
|
let _ = tokio::fs::remove_file(&out).await;
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
Ok(s) => {
|
||||||
|
tracing::warn!(theme, id = %mail.id, "typst exited with {s}; sending without the PDF");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(theme, "typst could not be run: {e}; sending without the PDF");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(cfg: Config) -> anyhow::Result<()> {
|
||||||
|
let nats = connect(&cfg.nats_url).await?;
|
||||||
|
let js = async_nats::jetstream::new(nats);
|
||||||
|
let stream = ensure_stream(&js).await?;
|
||||||
|
let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp_host)
|
||||||
|
.port(cfg.smtp_port)
|
||||||
|
.hello_name(lettre::transport::smtp::extension::ClientId::Domain(cfg.smtp_helo.clone()))
|
||||||
|
.build();
|
||||||
|
match transport.test_connection().await {
|
||||||
|
Ok(true) => tracing::info!(host = %cfg.smtp_host, port = cfg.smtp_port, helo = %cfg.smtp_helo, "chevron seven, locked"),
|
||||||
|
Ok(false) => return Err(anyhow!("SMTP at {}:{} did not answer", cfg.smtp_host, cfg.smtp_port)),
|
||||||
|
Err(e) => return Err(anyhow!("SMTP at {}:{} refused the greeting as {:?}: {e} (set SMTP_HELO to this host's full name)", cfg.smtp_host, cfg.smtp_port, cfg.smtp_helo)),
|
||||||
|
}
|
||||||
|
let consumer = stream
|
||||||
|
.get_or_create_consumer(
|
||||||
|
&cfg.consumer,
|
||||||
|
async_nats::jetstream::consumer::pull::Config {
|
||||||
|
durable_name: Some(cfg.consumer.clone()),
|
||||||
|
filter_subject: SUBJECT.to_string(),
|
||||||
|
ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit,
|
||||||
|
ack_wait: Duration::from_secs(120),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut messages = consumer.messages().await?;
|
||||||
|
tracing::info!(consumer = %cfg.consumer, "listening on {STREAM}");
|
||||||
|
while let Some(next) = messages.next().await {
|
||||||
|
let msg = match next {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("consumer stream error: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mail: Outgoing = match serde_json::from_slice(&msg.payload) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
// Not ours to understand; acked so it does not block the
|
||||||
|
// rest, logged so someone sees it.
|
||||||
|
tracing::error!("unreadable message on {SUBJECT}: {e}");
|
||||||
|
let _ = msg.ack().await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if mail.v != CONTRACT {
|
||||||
|
tracing::error!(id = %mail.id, v = mail.v, "contract version {CONTRACT} expected; leaving it for a gdo that knows it");
|
||||||
|
let _ = msg.ack_with(async_nats::jetstream::AckKind::Nak(Some(Duration::from_secs(3600)))).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pdf = match &cfg.typst_theme {
|
||||||
|
Some(theme) => render_theme(theme, &mail).await,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let message = match build_message(&mail, pdf) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(id = %mail.id, to = %mail.to, "cannot build the message: {e}; dropped");
|
||||||
|
let _ = msg.ack().await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match transport.send(message).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!(id = %mail.id, to = %mail.to, site = %mail.site, "sent");
|
||||||
|
if let Err(e) = msg.double_ack().await {
|
||||||
|
tracing::warn!(id = %mail.id, "sent, but the ack failed: {e}; it may go twice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(id = %mail.id, to = %mail.to, "SMTP refused: {e}; retrying in a minute");
|
||||||
|
let _ = msg.ack_with(async_nats::jetstream::AckKind::Nak(Some(Duration::from_secs(60)))).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish one message and watch the consumer's pending count go back
|
||||||
|
/// to zero: delivered means the running gdo handed it to SMTP and
|
||||||
|
/// acked, which is what delivered means from here.
|
||||||
|
async fn probe(cfg: Config, to: &str, from: &str) -> anyhow::Result<()> {
|
||||||
|
let nats = connect(&cfg.nats_url).await?;
|
||||||
|
let js = async_nats::jetstream::new(nats);
|
||||||
|
let stream = ensure_stream(&js).await?;
|
||||||
|
let id = format!("probe:{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis());
|
||||||
|
let mail = Outgoing {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: id.clone(),
|
||||||
|
site: "gdo".into(),
|
||||||
|
from: from.into(),
|
||||||
|
reply_to: None,
|
||||||
|
to: to.into(),
|
||||||
|
subject: "gdo probe: chevron seven".into(),
|
||||||
|
text: format!("A probe through the gate, id {id}. If you read this, gdo delivers on this host."),
|
||||||
|
html: format!("<!doctype html><html><body><p>A probe through the gate, id {id}. If you read this, gdo delivers on this host.</p></body></html>"),
|
||||||
|
};
|
||||||
|
let mut headers = async_nats::HeaderMap::new();
|
||||||
|
headers.insert("Nats-Msg-Id", id.as_str());
|
||||||
|
js.publish_with_headers(SUBJECT, headers, serde_json::to_vec(&mail)?.into()).await?.await?;
|
||||||
|
println!("sent {id} into {STREAM}");
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
||||||
|
loop {
|
||||||
|
let Ok(mut consumer) = stream.get_consumer::<async_nats::jetstream::consumer::pull::Config>(&cfg.consumer).await else {
|
||||||
|
println!("no consumer {:?} on {STREAM}: gdo has never run against this NATS; the probe waits in the stream", cfg.consumer);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let info = consumer.info().await?;
|
||||||
|
if info.num_pending == 0 && info.num_ack_pending == 0 {
|
||||||
|
println!("delivered: gdo acked everything up to and including the probe");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() > deadline {
|
||||||
|
println!("not delivered within a minute: {} pending, {} awaiting ack - is gdo running, and does SMTP accept?", info.num_pending, info.num_ack_pending);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())).init();
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let cfg = Config::from_env();
|
||||||
|
match args.first().map(String::as_str) {
|
||||||
|
None => serve(cfg).await,
|
||||||
|
Some("probe") => {
|
||||||
|
let to = args.get(1).ok_or_else(|| anyhow!("usage: gdo probe <to> [--from <address>]"))?;
|
||||||
|
let from = args.iter().position(|a| a == "--from").and_then(|i| args.get(i + 1)).cloned().unwrap_or_else(|| format!("gdo@{}", hostname()));
|
||||||
|
probe(cfg, to, &from).await
|
||||||
|
}
|
||||||
|
Some("--version") | Some("-V") => {
|
||||||
|
println!("gdo {} (contract v{CONTRACT})", env!("CARGO_PKG_VERSION"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(other) => Err(anyhow!("unknown argument {other:?}; usage: gdo | gdo probe <to> [--from <address>] | gdo --version")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hostname() -> String {
|
||||||
|
std::fs::read_to_string("/etc/hostname").ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "localhost".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's full name for EHLO: /etc/hostname when it has a dot,
|
||||||
|
/// else the first dotted name /etc/hosts gives it, else the bare name
|
||||||
|
/// with `.localdomain`, which strict servers at least parse.
|
||||||
|
fn fqdn() -> String {
|
||||||
|
let short = hostname();
|
||||||
|
if short.contains('.') {
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
if let Ok(hosts) = std::fs::read_to_string("/etc/hosts") {
|
||||||
|
for line in hosts.lines() {
|
||||||
|
let names: Vec<&str> = line.split('#').next().unwrap_or("").split_whitespace().skip(1).collect();
|
||||||
|
if names.contains(&short.as_str()) {
|
||||||
|
if let Some(full) = names.iter().find(|n| n.contains('.')) {
|
||||||
|
return full.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{short}.localdomain")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mail() -> Outgoing {
|
||||||
|
Outgoing {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: "reports:abc:fixed".into(),
|
||||||
|
site: "Tomter Vel".into(),
|
||||||
|
from: "Tomter Vel <vel@example.no>".into(),
|
||||||
|
reply_to: Some("post@example.no".into()),
|
||||||
|
to: "Kari <kari@example.no>".into(),
|
||||||
|
subject: "Ordnet".into(),
|
||||||
|
text: "Det er ordnet.".into(),
|
||||||
|
html: "<p>Det er ordnet.</p>".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_carries_both_alternatives_and_its_headers() {
|
||||||
|
let m = build_message(&mail(), None).unwrap();
|
||||||
|
let raw = String::from_utf8(m.formatted()).unwrap();
|
||||||
|
assert!(raw.contains("From: \"Tomter Vel\" <vel@example.no>"));
|
||||||
|
assert!(raw.contains("Reply-To: post@example.no"));
|
||||||
|
assert!(raw.contains("Subject: Ordnet"));
|
||||||
|
assert!(raw.contains("multipart/alternative"));
|
||||||
|
assert!(raw.contains("text/plain") && raw.contains("text/html"));
|
||||||
|
assert!(!raw.contains("application/pdf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_theme_makes_it_mixed_with_a_pdf() {
|
||||||
|
let m = build_message(&mail(), Some(b"%PDF-1.7".to_vec())).unwrap();
|
||||||
|
let raw = String::from_utf8(m.formatted()).unwrap();
|
||||||
|
assert!(raw.contains("multipart/mixed") && raw.contains("application/pdf") && raw.contains("Tomter Vel.pdf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_address_is_refused_before_smtp() {
|
||||||
|
let mut bad = mail();
|
||||||
|
bad.to = "not an address".into();
|
||||||
|
assert!(build_message(&bad, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_nats_url_credentials_are_read() {
|
||||||
|
assert_eq!(url::parse("nats://infra:secret@127.0.0.1:4222").unwrap(), Some(("infra".into(), "secret".into())));
|
||||||
|
assert_eq!(url::parse("nats://127.0.0.1:4222").unwrap(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user