Assisted-by: GLM 5.3 Flash
12 KiB
Configuration
nuntius reads its configuration from one TOML file, /etc/nuntius/config.toml
by default. The NUNTIUS_CONFIG environment variable points the server at
any other path. When the file does not exist, the first start writes a
three-form starter template there.
File
A complete example with every key present at its default value, so the file documents itself:
data_dir = "./data"
[server]
port = 8080
bind = "::"
read_header_timeout_seconds = 10
read_timeout_seconds = 15
write_timeout_seconds = 30
idle_timeout_seconds = 60
shutdown_timeout_seconds = 15
max_body_bytes = 1048576
rate_limit_max_buckets = 32768
rate_limit_cleanup_seconds = 3600
rate_limit_max_bucket_age_seconds = 7200
trust_proxy_headers = false
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"
[[forms]]
name = "contact"
path = "/api/nuntius/contact"
type = "contact"
redirect_url = ""
archive = false
auto_reply = false
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
require_name = true
require_message = true
min_name_runes = 2
max_name_runes = 100
min_message_runes = 10
max_message_runes = 5000
subject_prefix = "nuntius"
email_brand = "nuntius"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
require_tls = false
timeout_seconds = 20
[forms.telegram]
bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
chat_id = "123456789"
timeout_seconds = 10
[[forms]]
name = "newsletter"
path = "/api/nuntius/newsletter"
type = "newsletter"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
pending_ttl_seconds = 259200
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
The newsletter form shows the keys that apply to it; archive,
auto_reply and telegram do not apply to newsletter forms and are
rejected at startup.
Keys
| Key | Type | Default | Effect |
|---|---|---|---|
data_dir |
string | "./data" |
Directory for the JSONL logs; created on first write. Relative to the working directory. |
server.port |
int | 8080 |
HTTP listen port. Must be 1 to 65535. |
server.bind |
string | "::" |
Host part of the listen address; "::" is the dual-stack wildcard, an address binds that one only. Must not contain whitespace. |
server.read_header_timeout_seconds |
int | 10 |
Budget for reading the request headers (Slowloris defence). 0 switches it off. |
server.read_timeout_seconds |
int | 15 |
Budget for reading the request body. 0 switches it off. |
server.write_timeout_seconds |
int | 30 |
Budget for writing the response. 0 switches it off. |
server.idle_timeout_seconds |
int | 60 |
Keep-alive idle budget. 0 switches it off. |
server.shutdown_timeout_seconds |
int | 15 |
Graceful shutdown budget before in-flight connections are cut. |
server.max_body_bytes |
int | 1048576 |
Request body cap in bytes; oversized bodies get 413. Must be at least 1. |
server.rate_limit_max_buckets |
int | 32768 |
Per-form cap on distinct IP buckets; once full, unknown IPs are denied until cleanup frees stale entries. Must be at least 1. |
server.rate_limit_cleanup_seconds |
int | 3600 |
Interval between bucket cleanup sweeps. Must be at least 1. |
server.rate_limit_max_bucket_age_seconds |
int | 7200 |
Age at which an idle bucket is dropped. Must be at least 1. |
server.trust_proxy_headers |
bool | false |
Read client IPs from X-Forwarded-For / X-Real-IP instead of the connection peer address. Enable only behind a proxy that overwrites these headers (Caddy 2.5+ does by default). |
server.metrics_token |
string | none | Bearer token guarding GET /metrics. Empty keeps the endpoint open. Supports ${VAR} expansion. |
forms[].name |
string | yes | Internal identifier; appears in logs and file names. Letters, digits, hyphens and underscores only. |
forms[].path |
string | yes | HTTP path the form is served on. Must start with /, be unique, and be a static pattern with no empty segments. |
forms[].type |
string | "contact" |
One of contact, feedback, newsletter, generic; selects the behaviour bundle, the mail template and the default validation policy. |
forms[].smtp |
table | yes | SMTP connection settings: host, port, user, password, require_tls, timeout_seconds (default 20). Port 465 speaks implicit TLS; 587 upgrades via STARTTLS, which require_tls = true makes mandatory. |
forms[].to |
string | yes | Recipient address (To: header). |
forms[].from |
string | smtp.user |
Sender address (From: header). |
forms[].redirect_url |
string | none | Turns the form into a plain HTML form target: accepted submissions answer 303 See Other with this location. Failures stay JSON. Must not contain whitespace. |
forms[].archive |
bool | false |
Persist every accepted submission to data_dir/archive-<name>.jsonl before the mail is attempted. Not valid on newsletter forms. |
forms[].auto_reply |
bool | false |
Mail the submitter a short automated receipt with Reply-To set to to. Best effort; failures land in the auto_reply_failed counter. Not valid on newsletter forms. |
forms[].telegram |
table | none | Telegram notification channel: bot_token (supports ${VAR}), chat_id (numeric id or @channelusername), timeout_seconds (default 10). Not valid on newsletter forms. |
forms[].rate_limit_per_hour |
int | 10 |
Submissions per IP per hour. 0 disables. |
forms[].honeypot_field |
string | "website" |
Name of the invisible form field. Empty string disables the honeypot. |
forms[].allowed_origins |
[]string | [] |
CORS allowlist, one origin per entry; a same-origin HTML form lists its own origin too. |
forms[].services |
[]string | type default | Allow-list for the optional service payload field, on any form type. |
forms[].require_name |
bool | type default | Switch the name field into the validation. |
forms[].require_message |
bool | type default | Switch the message field into the validation. |
forms[].min_name_runes |
int | 2 |
Minimum name length in runes; 0 or more. |
forms[].max_name_runes |
int | 100 |
Maximum name length in runes; at least min_name_runes and 1 or more. |
forms[].min_message_runes |
int | 10 |
Minimum message length in runes; 0 or more. |
forms[].max_message_runes |
int | 5000 |
Maximum message length in runes; at least min_message_runes and 1 or more. |
forms[].pending_ttl_seconds |
int | 259200 |
Double opt-in lifetime for newsletter forms (72 hours). Must be at least 1. |
forms[].subject_prefix |
string | "nuntius" |
The [<prefix>/<name>] segment of every mail subject. Empty string drops the segment. |
forms[].email_brand |
string | "nuntius" |
The Delivered by <brand> footer in every mail. Empty string drops the footer. |
The email address is always required and always checked against RFC 5322: every form delivers mail and needs a reply-to address, so there is no key to switch that check off.
Form types
The four types are behaviour bundles. Each bundles a mail template, a subject text, and a default validation policy; every part of that policy can be overridden per form with the keys above, so a form whose needs differ is a configuration matter, never a code change.
| Type | Default required fields | Email subject | Notes |
|---|---|---|---|
contact |
name, email, message, optional service |
[nuntius/<name>] Contact form submission, or [nuntius/<name>][<service>] ... if service is set |
The default. service is checked against the allow-list: the built-in list by default, or the form's own services key. |
feedback |
name, email, message |
[nuntius/<name>] New feedback |
Same shape as contact minus the service field. Distinct HTML template. A services key opts the field into the validation and the mail. |
newsletter |
email |
[nuntius/<name>] New newsletter subscriber |
Double opt-in: the address waits in data_dir/newsletter-<name>-pending.json until the emailed link (GET <form path>/confirm?token=...) is redeemed. |
generic |
name, email, message |
[nuntius/<name>] New submission |
Escape hatch for one-off forms; pair it with the per-form policy keys to shape it freely. |
The service allow-list
The service payload field is optional everywhere and the empty value
always passes, so a frontend that never sends it needs no configuration at
all. When a value is sent, it is checked against the form's allow-list:
| Configuration | Accepted service values |
|---|---|
| key omitted | contact: the built-in list below. Other types: the field is not validated at all. |
services = ["consulting", "support"] |
only the listed values (plus the empty value), on any form type |
services = ["*"] |
any value |
services = [] |
only the empty value |
The built-in list for contact: architecture, ai, infrastructure,
software, unix, other. Anything else triggers a 400 validation
error. Entries must not be empty and must not carry surrounding
whitespace; such a config is rejected at startup.
The newsletter log
A confirmed signup is one JSON line in
data_dir/newsletter-<name>.jsonl:
{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}
email is the trimmed address, ip the client IP as seen by nuntius, and
created_at a UTC timestamp set at append time. The log is append-only,
survives restarts, and is never rotated automatically: add a logrotate
unit if it grows. Malformed lines are skipped on read, so a partial write
can never brick the file.
Precedence
The configuration is one file plus the environment, in this order:
NUNTIUS_CONFIGchooses the file; without it the path is/etc/nuntius/config.toml.${VAR_NAME}and$VAR_NAMEreferences in the raw text expand from the process environment before parsing. Expansion is strict: a referenced variable that is not set aborts startup with an error naming it, while a set-but-empty value expands to the empty string and a dollar sign with no variable name stays literal. Comment lines are never expanded.- Every key omitted from the file falls back to the default in the table above; an explicit value, including a disabling zero, always wins.
Secrets belong in the environment, not the file: reference them as
${NUNTIUS_SMTP_PASSWORD} and provide them through the systemd
EnvironmentFile= (/etc/nuntius/.env, mode 0600, which
scripts/install.pl sets up). Credentials are never logged.
Validation
A wrong value stops the program at startup with a message naming the key;
nothing is silently ignored and nothing falls back silently. The loader
rejects unknown TOML fields and unknown form types, ports outside 1 to
65535, negative timeouts and limits, form paths that are not static route
patterns, form names with characters outside [a-zA-Z0-9_-], policy
windows where the maximum sits below the minimum, service entries that are
empty or carry surrounding whitespace, redirect_url with whitespace,
archive, auto_reply or telegram on newsletter forms, and a Telegram
channel without bot_token or chat_id. At request time the payload is
validated against the form's policy: limits are counted in runes after
whitespace is trimmed, the email address must parse as RFC 5322, and the
error messages quote the configured numbers. See API.md for the
response shapes.