• v0.1.0 3a38f00dc0

    v0.1.0
    Test / test (push) Successful in 2m1s
    Release / gates (push) Successful in 1m57s
    Release / build (amd64, freebsd) (push) Successful in 1m26s
    Release / build (amd64, linux) (push) Successful in 1m30s
    Release / build (arm64, freebsd) (push) Successful in 1m28s
    Release / build (arm64, linux) (push) Successful in 1m49s
    Release / build (loong64, linux) (push) Successful in 1m30s
    Release / build (riscv64, linux) (push) Successful in 1m29s
    Release / release (push) Successful in 41s
    Stable

    petrbalvin released this 2026-09-28 22:33:07 +00:00 | 0 commits to main since this release

    Added

    • A contact form backend in one static binary. nuntius serves JSON form
      endpoints, delivers the submissions by SMTP and writes structured JSON logs.
      No Node, no Python, no Redis, no Postgres, and no third-party Go module
      beyond the first-party interpres
      TOML parser.
    • Plain HTML form posts, no JavaScript required. Every endpoint accepts
      application/x-www-form-urlencoded and multipart/form-data under the
      fixed field names name, email, service and message beside the JSON
      contract, and a form with redirect_url set answers every accepted
      submission, honeypot hits included, with 303 See Other to that page.
      Failures stay machine-readable JSON. Body-parse rejections report
      invalid_body across every content type.
    • Four form types: contact, feedback, newsletter and generic. Each
      form has its own endpoint, rate limit, CORS allowlist, honeypot field and
      SMTP credentials, so one process serves many independent forms.
    • Declarative validation policy. A form's rules are configuration:
      services puts an allow-list on the optional service payload field of any
      form type (the empty value always passes, the * entry accepts any value,
      an explicit empty list accepts only the empty value), require_name and
      require_message move the free-text fields in and out of the checks, and
      min_name_runes, max_name_runes, min_message_runes, max_message_runes
      size them. A new form shape is a matter of config.toml, never of Go code.
    • Newsletter double opt-in. A signup stays pending and the subscriber
      receives a confirmation mail with a single-use link
      (GET <form path>/confirm?token=...); only a redeemed link writes the
      address into the JSONL log and notifies the owner. Unconfirmed entries
      expire after pending_ttl_seconds (72 hours by default), tokens are
      256-bit random values stored as SHA-256 hashes, and replaying a used link
      returns 410 Gone. A repeat signup of an already recorded address gets the
      same 200 ok response with no second mail and no duplicate record.
    • Abuse defence without CAPTCHA. A per-IP token bucket bounds each form
      (capped at server.rate_limit_max_buckets distinct IPs, unknown IPs denied
      while full), a configurable honeypot field silently drops bot submissions,
      per-form CORS closes cross-site posts, and proxy headers are trusted only
      with the explicit server.trust_proxy_headers opt-in, so a direct caller
      cannot rotate identities to dodge the per-IP limit.
    • Rate-limit state survives graceful restarts. On shutdown the per-IP
      buckets are snapshotted to data_dir/ratelimit-snapshot.json (atomic write,
      mode 0600) and restored at startup; stale entries are dropped and a
      missing or corrupt file behaves like an empty one.
    • Optional submission archive. A form with archive = true writes every
      accepted submission to data_dir/archive-<name>.jsonl before the mail is
      attempted, so a failed SMTP round-trip loses nothing; a failed append
      blocks the send, so a retry cannot split the mail from its record.
      Newsletter forms reject the key because they persist through the double
      opt-in log already.
    • Optional automated receipt to the submitter. A form with
      auto_reply = true mails the submitter a short confirmation with the
      owner's address as Reply-To; the receipt is best effort and its failure
      never turns an accepted submission into an error. Newsletter forms reject
      the key because their subscribers already receive the confirmation mail.
    • Telegram notifications. A per-form [forms.telegram] channel
      (bot_token, chat_id, optional timeout_seconds) posts the submission
      summary into the owner's chat beside the mail. The submission counts as
      delivered when either channel gets through: an SMTP outage does not
      silence the bell, and only when both fail does the caller see
      send_failed. The notification is one-way; newsletter forms reject the
      key because their double opt-in flow is mail-native.
    • Operational surface. GET /health reports the listener and the form
      count, GET /metrics reports lifetime counters per form and in total
      (optionally guarded by a server.metrics_token bearer credential),
      --check-config validates the configuration file and exits nonzero on any
      error (so a systemd ExecStartPre can refuse to start on a broken config),
      and the NUNTIUS_CONFIG environment variable moves the configuration file
      out of /etc/nuntius/ for development.
    • Every operational limit is a configuration key. server.bind,
      server.read_header_timeout_seconds, server.read_timeout_seconds,
      server.write_timeout_seconds, server.idle_timeout_seconds,
      server.shutdown_timeout_seconds, server.max_body_bytes,
      server.rate_limit_max_buckets, server.rate_limit_cleanup_seconds,
      server.rate_limit_max_bucket_age_seconds, and per form
      pending_ttl_seconds plus smtp.timeout_seconds. Every key is optional
      with its default documented in docs/CONFIGURATION.md.
    • SMTP delivery with a TLS policy. PLAIN auth over STARTTLS, implicit TLS
      on port 465 (encrypted from the first byte), the smtp.require_tls switch
      that aborts delivery when a STARTTLS port never upgrades, and a configurable
      conversation timeout. Secrets stay out of config.toml through ${VAR}
      expansion, and an undefined variable aborts startup instead of surfacing
      later as a failed SMTP auth.
    • Messages are hardened at composition. CR/LF sequences reaching From,
      To, Subject or Reply-To are replaced with spaces inside the SMTP
      builder itself, and the MIME boundary is random per message with a failure
      aborting the send rather than falling back to a fixed boundary.
    • Configuration fails loudly at startup. Unknown TOML fields and unknown
      form types are rejected, ports and limits are range-checked, form path
      values are validated as static route patterns so a mistyped path cannot
      widen into a wildcard, and form names are restricted to [a-zA-Z0-9_-] so
      a crafted name cannot escape the data directory.
    • The release identity comes from the toolchain. --version prints the
      tag the binary was built at, a pseudo-version naming the commit otherwise,
      and (devel) outside version control, with +dirty appended on a dirty
      tree. Nothing is injected at build time.
    • Binaries for Linux and FreeBSD. Every release ships linux/amd64,
      linux/arm64, linux/loong64 and linux/riscv64, plus freebsd/amd64 and
      freebsd/arm64 as cross compiles; the FreeBSD binaries are runtime
      untested.
    Downloads