gdo: the code that opens the iris
Test / test (push) Successful in 4s
Publish release / publish (push) Successful in 18s

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:
Bendik Aagaard Lynghaug
2026-09-23 19:47:57 +02:00
co-authored by Claude Fable 5.1
commit 983491295e
10 changed files with 2318 additions and 0 deletions
+46
View File
@@ -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"
+24
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
target
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
+84
View File
@@ -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: "")
```
+21
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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);
}
}