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