feat: contact form backend for linux and freebsd servers
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

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+850
View File
@@ -0,0 +1,850 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package config loads nuntius configuration from a TOML file.
//
// Configuration supports env var expansion for secrets:
// 1. A TOML file (config.toml), checked into the repo or provisioned
// per environment. It holds non-secret defaults and references to env vars.
// 2. Environment variables, used for secrets like SMTP passwords.
// Reference them in the TOML file as ${VAR_NAME} or $VAR_NAME.
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// Default values applied by validate() when fields are omitted.
const (
DefaultPort = 8080
DefaultDataDir = "./data"
DefaultRateLimitPerHour = 10
DefaultHoneypotField = "website"
// Bind is the host part of the listen address: the dual-stack IPv6
// wildcard, rendered as "[::]:port" once the port is joined on.
DefaultBind = "::"
// HTTP server timeouts, in seconds. An explicit 0 keeps its net/http
// meaning: the timeout is switched off.
DefaultReadHeaderTimeoutSeconds = 10
DefaultReadTimeoutSeconds = 15
DefaultWriteTimeoutSeconds = 30
DefaultIdleTimeoutSeconds = 60
DefaultShutdownTimeoutSeconds = 15
// Request body cap and the rate limiter's memory mechanics.
DefaultMaxBodyBytes = 1 << 20
DefaultRateLimitMaxBuckets = 32768
DefaultRateLimitCleanupSeconds = 3600
DefaultRateLimitMaxBucketAgeSeconds = 7200
// Newsletter double opt-in lifetime: 72 hours.
DefaultPendingTTLSeconds = 72 * 3600
// Telegram API call bound, in seconds.
DefaultTelegramTimeoutSeconds = 10
// SMTP conversation bound, in seconds.
DefaultSMTPTimeoutSeconds = 20
// Mail branding: the subject prefix and the "Delivered by" footer.
DefaultSubjectPrefix = "nuntius"
DefaultEmailBrand = "nuntius"
)
// validFormTypes lists the form types nuntius knows how to handle.
var validFormTypes = map[string]bool{
"contact": true,
"feedback": true,
"newsletter": true,
"generic": true,
}
// IsValidFormType reports whether t is a supported form type.
func IsValidFormType(t string) bool {
return validFormTypes[t]
}
// Config is the root configuration for nuntius.
type Config struct {
Server ServerConfig `toml:"server"`
Forms []Form `toml:"forms"`
DataDir string `toml:"data_dir"`
}
// ServerConfig holds server-wide settings.
type ServerConfig struct {
Port int `toml:"port"`
// Bind is the host part of the listen address, resolved to DefaultBind
// by validate() when omitted. "::" binds the dual-stack wildcard, an
// address or hostname binds that one only.
Bind string `toml:"bind"`
// TrustProxyHeaders opts in to client IPs read from X-Forwarded-For /
// X-Real-IP instead of the connection peer address. It is off by
// default because those headers are client-controlled: without a
// trusted reverse proxy in front of nuntius that overwrites them,
// enabling this would let any caller forge its rate-limit identity.
TrustProxyHeaders bool `toml:"trust_proxy_headers"`
// MetricsToken guards GET /metrics with a bearer token. Empty (the
// default) keeps the endpoint open, protected at the reverse proxy
// like any other operational surface. The value supports the same
// ${VAR} expansion as the rest of the file.
MetricsToken string `toml:"metrics_token"`
// The timeout fields are pointers so that an omitted key falls back to
// its default while an explicit 0 keeps its net/http meaning: the
// timeout is switched off. The getters return durations.
ReadHeaderTimeoutSeconds *int `toml:"read_header_timeout_seconds"`
ReadTimeoutSeconds *int `toml:"read_timeout_seconds"`
WriteTimeoutSeconds *int `toml:"write_timeout_seconds"`
IdleTimeoutSeconds *int `toml:"idle_timeout_seconds"`
ShutdownTimeoutSeconds *int `toml:"shutdown_timeout_seconds"`
// MaxBodyBytes caps the JSON request body. An explicit value must be
// at least 1 byte.
MaxBodyBytes *int `toml:"max_body_bytes"`
// Rate limiter mechanics: the per-form cap on distinct IP buckets, the
// cleanup tick and the age at which an idle bucket is dropped (also
// applied when a restart restores the persisted buckets). Explicit
// values must be at least 1.
RateLimitMaxBuckets *int `toml:"rate_limit_max_buckets"`
RateLimitCleanupSeconds *int `toml:"rate_limit_cleanup_seconds"`
RateLimitMaxBucketAgeSeconds *int `toml:"rate_limit_max_bucket_age_seconds"`
}
// ReadHeaderTimeout returns the budget for reading the request headers.
func (s ServerConfig) ReadHeaderTimeout() time.Duration {
return secondsOrDefault(s.ReadHeaderTimeoutSeconds, DefaultReadHeaderTimeoutSeconds)
}
// ReadTimeout returns the budget for reading the request body.
func (s ServerConfig) ReadTimeout() time.Duration {
return secondsOrDefault(s.ReadTimeoutSeconds, DefaultReadTimeoutSeconds)
}
// WriteTimeout returns the budget for writing the response.
func (s ServerConfig) WriteTimeout() time.Duration {
return secondsOrDefault(s.WriteTimeoutSeconds, DefaultWriteTimeoutSeconds)
}
// IdleTimeout returns the keep-alive idle budget.
func (s ServerConfig) IdleTimeout() time.Duration {
return secondsOrDefault(s.IdleTimeoutSeconds, DefaultIdleTimeoutSeconds)
}
// ShutdownTimeout returns the graceful shutdown budget.
func (s ServerConfig) ShutdownTimeout() time.Duration {
return secondsOrDefault(s.ShutdownTimeoutSeconds, DefaultShutdownTimeoutSeconds)
}
// BodyLimit returns the request body cap in bytes.
func (s ServerConfig) BodyLimit() int {
if s.MaxBodyBytes == nil {
return DefaultMaxBodyBytes
}
return *s.MaxBodyBytes
}
// MaxRateLimitBuckets returns the per-form cap on distinct IP buckets.
func (s ServerConfig) MaxRateLimitBuckets() int {
if s.RateLimitMaxBuckets == nil {
return DefaultRateLimitMaxBuckets
}
return *s.RateLimitMaxBuckets
}
// RateLimitCleanup returns the interval between bucket cleanup sweeps.
func (s ServerConfig) RateLimitCleanup() time.Duration {
return secondsOrDefault(s.RateLimitCleanupSeconds, DefaultRateLimitCleanupSeconds)
}
// RateLimitMaxBucketAge returns the age at which an idle bucket is dropped.
func (s ServerConfig) RateLimitMaxBucketAge() time.Duration {
return secondsOrDefault(s.RateLimitMaxBucketAgeSeconds, DefaultRateLimitMaxBucketAgeSeconds)
}
// secondsOrDefault converts an optional seconds value into a duration: a
// nil pointer yields the default, an explicit 0 stays 0 (the documented
// "switched off" meaning), anything else is the value in seconds.
func secondsOrDefault(v *int, def int) time.Duration {
if v == nil {
return time.Duration(def) * time.Second
}
return time.Duration(*v) * time.Second
}
// Form is one contact / feedback / newsletter endpoint.
//
// Optional numeric and string options are pointers so that an omitted key
// can fall back to its default while an explicit zero value keeps its
// documented meaning (`rate_limit_per_hour = 0` disables the limit,
// `honeypot_field = ""` disables the honeypot).
//
// The validation keys (services, require_name, require_message and the
// four rune limits) override the built-in preset of the form's type, so a
// new form shape is a matter of configuration, never of Go code.
type Form struct {
Name string `toml:"name"`
Path string `toml:"path"`
Type string `toml:"type"`
SMTP SMTPConfig `toml:"smtp"`
To string `toml:"to"`
From string `toml:"from"`
RateLimitPerHour *int `toml:"rate_limit_per_hour"`
HoneypotField *string `toml:"honeypot_field"`
AllowedOrigins []string `toml:"allowed_origins"`
// RedirectURL turns the form into a plain HTML form target: an
// accepted submission answers 303 See Other with this location, so
// the form works without JavaScript. Empty (the default) keeps the
// JSON contract.
RedirectURL string `toml:"redirect_url"`
// Archive persists every accepted submission to
// data_dir/archive-<name>.jsonl before the mail is attempted, so a
// failed SMTP round-trip loses nothing. Newsletter forms always
// persist through the double opt-in log instead and reject the key.
Archive bool `toml:"archive"`
// AutoReply mails the submitter a short receipt confirming the
// message arrived. The submitter's address is always validated
// first and the request is rate limited like any other, so the
// receipt cannot be turned into a mail relay. Newsletter forms
// reject the key: their subscribers already receive the
// confirmation mail.
AutoReply bool `toml:"auto_reply"`
// Telegram is the optional notification channel: the submission
// summary lands in the chat alongside the mail. The submission
// counts as delivered when either channel gets through. Newsletter
// forms reject the key: the double opt-in flow is mail-native.
Telegram *TelegramConfig `toml:"telegram"`
// Services is the allow-list for the optional service payload field.
// A nil value (the key omitted) keeps the type's preset behaviour: the
// built-in list for contact, no service validation for the other
// types. An explicit list applies to any type; the empty value always
// passes and the "*" entry accepts any value. An explicit empty list
// accepts only the empty value.
Services []string `toml:"services"`
// RequireName and RequireMessage switch the two free-text fields into
// the validation. The email address is always required.
RequireName *bool `toml:"require_name"`
RequireMessage *bool `toml:"require_message"`
// Length limits in runes; the preset values are 2 and 100 for the name
// and 10 and 5000 for the message.
MinNameRunes *int `toml:"min_name_runes"`
MaxNameRunes *int `toml:"max_name_runes"`
MinMessageRunes *int `toml:"min_message_runes"`
MaxMessageRunes *int `toml:"max_message_runes"`
// PendingTTLSeconds is the double opt-in lifetime for newsletter
// forms; the default is 72 hours.
PendingTTLSeconds *int `toml:"pending_ttl_seconds"`
// SubjectPrefix carries the "[nuntius/<name>]" segment of every mail
// subject; an explicit empty string drops the segment.
SubjectPrefix *string `toml:"subject_prefix"`
// EmailBrand carries the "Delivered by <brand>" footer; an explicit
// empty string drops the footer.
EmailBrand *string `toml:"email_brand"`
}
// RateLimit returns the effective submissions-per-hour cap for the form.
// An omitted value yields DefaultRateLimitPerHour; an explicit 0 disables
// rate limiting. Validate rejects negative values at load time.
func (f *Form) RateLimit() int {
if f.RateLimitPerHour == nil {
return DefaultRateLimitPerHour
}
return *f.RateLimitPerHour
}
// Honeypot returns the name of the hidden anti-bot field. An omitted value
// yields DefaultHoneypotField; an explicit empty string disables the
// honeypot for the form.
func (f *Form) Honeypot() string {
if f.HoneypotField == nil {
return DefaultHoneypotField
}
return *f.HoneypotField
}
// PendingTTL returns the double opt-in lifetime for newsletter forms.
func (f *Form) PendingTTL() time.Duration {
if f.PendingTTLSeconds == nil {
return time.Duration(DefaultPendingTTLSeconds) * time.Second
}
return time.Duration(*f.PendingTTLSeconds) * time.Second
}
// EmailSubjectPrefix returns the "[<prefix>/<form name>]" segment of every
// mail subject; an explicit empty string disables the segment.
func (f *Form) EmailSubjectPrefix() string {
if f.SubjectPrefix == nil {
return DefaultSubjectPrefix
}
return *f.SubjectPrefix
}
// Brand returns the "Delivered by <brand>" footer name; an explicit empty
// string disables the footer.
func (f *Form) Brand() string {
if f.EmailBrand == nil {
return DefaultEmailBrand
}
return *f.EmailBrand
}
// Policy builds the validation policy for this form: the built-in preset
// for its type with every configured key overriding the preset value.
func (f *Form) Policy() contactform.Policy {
p := contactform.Preset(f.Type)
if f.RequireName != nil {
p.RequireName = *f.RequireName
}
if f.RequireMessage != nil {
p.RequireMessage = *f.RequireMessage
}
if f.MinNameRunes != nil {
p.MinNameRunes = *f.MinNameRunes
}
if f.MaxNameRunes != nil {
p.MaxNameRunes = *f.MaxNameRunes
}
if f.MinMessageRunes != nil {
p.MinMessageRunes = *f.MinMessageRunes
}
if f.MaxMessageRunes != nil {
p.MaxMessageRunes = *f.MaxMessageRunes
}
if f.Services != nil {
p.Services = f.Services
}
return p
}
// SMTPConfig holds SMTP credentials and connection details.
type SMTPConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
User string `toml:"user"`
// Password is expanded from the environment before the TOML parse.
Password string `toml:"password"`
// RequireTLS aborts delivery when an SMTP server on a STARTTLS port
// never advertises STARTTLS. Irrelevant for implicit-TLS ports.
RequireTLS bool `toml:"require_tls"`
// TimeoutSeconds bounds the whole SMTP conversation. An explicit value
// must be at least 1.
TimeoutSeconds *int `toml:"timeout_seconds"`
}
// TelegramConfig holds the optional Telegram notification channel: one
// bot posting submission summaries into one chat.
type TelegramConfig struct {
// BotToken is the bot's token from BotFather, expanded from the
// environment like every other secret.
BotToken string `toml:"bot_token"`
// ChatID is the chat receiving the summaries: a numeric chat id
// (group chats carry a negative number) or an @channelusername.
ChatID string `toml:"chat_id"`
// TimeoutSeconds bounds the whole API call. An explicit value must
// be at least 1.
TimeoutSeconds *int `toml:"timeout_seconds"`
}
// Timeout returns the bound on the whole Telegram API call.
func (t TelegramConfig) Timeout() time.Duration {
if t.TimeoutSeconds == nil {
return time.Duration(DefaultTelegramTimeoutSeconds) * time.Second
}
return time.Duration(*t.TimeoutSeconds) * time.Second
}
// Timeout returns the bound on the whole SMTP conversation.
func (s SMTPConfig) Timeout() time.Duration {
if s.TimeoutSeconds == nil {
return time.Duration(DefaultSMTPTimeoutSeconds) * time.Second
}
return time.Duration(*s.TimeoutSeconds) * time.Second
}
// ImplicitTLSPort is the SMTP port that speaks TLS from the first byte.
// Port 465 upgrades the connection itself instead of negotiating STARTTLS
// inside a plaintext session.
const ImplicitTLSPort = 465
// Load reads, expands env vars in, and parses the TOML file at path.
// If the file does not exist, a default template is created first.
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
// O_EXCL makes the create atomic: if two instances race, only one
// succeeds and the other sees os.ErrExist, which is safe to ignore.
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err == nil {
if _, werr := f.Write(defaultConfig); werr != nil {
f.Close()
return nil, fmt.Errorf("write default config to %s: %w", path, werr)
}
if cerr := f.Close(); cerr != nil {
return nil, fmt.Errorf("close default config %s: %w", path, cerr)
}
} else if !os.IsExist(err) {
return nil, fmt.Errorf("create default config %s: %w", path, err)
}
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
// Step 1: expand ${VAR_NAME} references in the raw text, failing on
// variables that are referenced but not set.
expanded, err := expandConfig(string(raw))
if err != nil {
return nil, fmt.Errorf("expand env vars in %s: %w", path, err)
}
// Step 2: parse TOML, rejecting unknown fields to catch typos early.
var cfg Config
if err := interpres.Unmarshal([]byte(expanded), &cfg, interpres.RejectUnknownFields(true)); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
// validate runs sanity checks on the loaded config and applies defaults.
func (c *Config) validate() error {
if c.Server.Port == 0 {
c.Server.Port = DefaultPort
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("server.port must be between 1 and 65535, got %d", c.Server.Port)
}
if c.Server.Bind == "" {
c.Server.Bind = DefaultBind
}
if strings.ContainsAny(c.Server.Bind, " \t\r\n") {
return fmt.Errorf("server.bind %q must not contain whitespace", c.Server.Bind)
}
for name, v := range map[string]*int{
"server.read_header_timeout_seconds": c.Server.ReadHeaderTimeoutSeconds,
"server.read_timeout_seconds": c.Server.ReadTimeoutSeconds,
"server.write_timeout_seconds": c.Server.WriteTimeoutSeconds,
"server.idle_timeout_seconds": c.Server.IdleTimeoutSeconds,
"server.shutdown_timeout_seconds": c.Server.ShutdownTimeoutSeconds,
} {
if v != nil && *v < 0 {
return fmt.Errorf("%s must be >= 0, got %d", name, *v)
}
}
for name, v := range map[string]*int{
"server.max_body_bytes": c.Server.MaxBodyBytes,
"server.rate_limit_max_buckets": c.Server.RateLimitMaxBuckets,
"server.rate_limit_cleanup_seconds": c.Server.RateLimitCleanupSeconds,
"server.rate_limit_max_bucket_age_seconds": c.Server.RateLimitMaxBucketAgeSeconds,
} {
if v != nil && *v < 1 {
return fmt.Errorf("%s must be >= 1, got %d", name, *v)
}
}
if c.DataDir == "" {
c.DataDir = DefaultDataDir
}
if len(c.Forms) == 0 {
return fmt.Errorf("at least one form must be defined under `forms`")
}
paths := make(map[string]string, len(c.Forms))
for i := range c.Forms {
f := &c.Forms[i]
if f.Name == "" {
return fmt.Errorf("form #%d: name is required", i+1)
}
if !isValidName(f.Name) {
return fmt.Errorf("form %q: name may only contain letters, digits, hyphens and underscores", f.Name)
}
if f.Path == "" {
return fmt.Errorf("form %q: path is required", f.Name)
}
if f.Type == "" {
c.Forms[i].Type = "contact"
}
if !IsValidFormType(c.Forms[i].Type) {
supported := make([]string, 0, len(validFormTypes))
for k := range validFormTypes {
supported = append(supported, k)
}
return fmt.Errorf("form %q: type %q is not supported (must be one of: %s)",
f.Name, c.Forms[i].Type, strings.Join(supported, ", "))
}
if !isValidPath(f.Path) {
return fmt.Errorf("form %q: path %q must start with / and contain only letters, digits, '-', '_', '.', '/' with no empty segments", f.Name, f.Path)
}
if existing, ok := paths[f.Path]; ok {
return fmt.Errorf("form %q: duplicate path %q (also used by %q)", f.Name, f.Path, existing)
}
paths[f.Path] = f.Name
if f.SMTP.Host == "" {
return fmt.Errorf("form %q: smtp.host is required", f.Name)
}
if f.SMTP.Port == 0 {
return fmt.Errorf("form %q: smtp.port is required", f.Name)
}
if f.SMTP.User == "" {
return fmt.Errorf("form %q: smtp.user is required", f.Name)
}
if f.To == "" {
return fmt.Errorf("form %q: `to` is required", f.Name)
}
if f.From == "" {
// Default From to the SMTP user.
c.Forms[i].From = f.SMTP.User
}
if f.RedirectURL != "" && strings.ContainsAny(f.RedirectURL, " \t\r\n") {
return fmt.Errorf("form %q: redirect_url %q must not contain whitespace", f.Name, f.RedirectURL)
}
if f.Archive && f.Type == "newsletter" {
return fmt.Errorf("form %q: archive does not apply to newsletter forms; they persist through the double opt-in log", f.Name)
}
if f.AutoReply && f.Type == "newsletter" {
return fmt.Errorf("form %q: auto_reply does not apply to newsletter forms; the subscriber already receives the confirmation mail", f.Name)
}
if f.Telegram != nil {
if f.Type == "newsletter" {
return fmt.Errorf("form %q: telegram does not apply to newsletter forms; the double opt-in flow is mail-native", f.Name)
}
if f.Telegram.BotToken == "" {
return fmt.Errorf("form %q: telegram.bot_token is required", f.Name)
}
if f.Telegram.ChatID == "" {
return fmt.Errorf("form %q: telegram.chat_id is required", f.Name)
}
if f.Telegram.TimeoutSeconds != nil && *f.Telegram.TimeoutSeconds < 1 {
return fmt.Errorf("form %q: telegram.timeout_seconds must be >= 1, got %d", f.Name, *f.Telegram.TimeoutSeconds)
}
}
// An explicit 0 keeps its documented meaning: rate limiting off.
if f.RateLimitPerHour != nil && *f.RateLimitPerHour < 0 {
return fmt.Errorf("form %q: rate_limit_per_hour must be >= 0, got %d", f.Name, *f.RateLimitPerHour)
}
if f.PendingTTLSeconds != nil && *f.PendingTTLSeconds < 1 {
return fmt.Errorf("form %q: pending_ttl_seconds must be >= 1, got %d", f.Name, *f.PendingTTLSeconds)
}
if f.SMTP.TimeoutSeconds != nil && *f.SMTP.TimeoutSeconds < 1 {
return fmt.Errorf("form %q: smtp.timeout_seconds must be >= 1, got %d", f.Name, *f.SMTP.TimeoutSeconds)
}
for _, svc := range f.Services {
if svc == "" {
return fmt.Errorf("form %q: services must not contain an empty entry; the empty value is always accepted", f.Name)
}
if strings.TrimSpace(svc) != svc {
return fmt.Errorf("form %q: services entry %q must not carry surrounding whitespace", f.Name, svc)
}
}
if err := checkPolicyLimits(f.Name, f.Policy()); err != nil {
return err
}
}
return nil
}
// checkPolicyLimits rejects a form policy whose length limits are
// unusable: negative minima, a maximum below one, or a window that
// excludes everything.
func checkPolicyLimits(formName string, p contactform.Policy) error {
for _, l := range []struct {
field string
min int
max int
}{
{"name", p.MinNameRunes, p.MaxNameRunes},
{"message", p.MinMessageRunes, p.MaxMessageRunes},
} {
if l.min < 0 {
return fmt.Errorf("form %q: min_%s_runes must be >= 0, got %d", formName, l.field, l.min)
}
if l.max < 1 {
return fmt.Errorf("form %q: max_%s_runes must be >= 1, got %d", formName, l.field, l.max)
}
if l.max < l.min {
return fmt.Errorf("form %q: max_%s_runes (%d) must be greater than or equal to min_%s_runes (%d)",
formName, l.field, l.max, l.field, l.min)
}
}
return nil
}
// isValidName reports whether s contains only safe characters for use
// in file paths and log lines.
func isValidName(s string) bool {
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// isValidPath reports whether s is a safe HTTP route pattern: a static path
// built from a leading slash plus letters, digits, hyphens, underscores,
// dots and single-slash separators. Characters that carry meaning inside
// net/http ServeMux patterns (braces, spaces, ...) are rejected so that a
// mistyped config cannot crash route registration or silently widen a form
// endpoint into a wildcard or subtree match.
func isValidPath(s string) bool {
if !strings.HasPrefix(s, "/") || strings.Contains(s, "//") {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z',
r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '/':
default:
return false
}
}
return true
}
// ConfigPath returns the canonical path for nuntius configuration:
// the NUNTIUS_CONFIG environment variable when set, otherwise
// /etc/nuntius/config.toml. The override keeps local development free of
// root-only paths.
func ConfigPath() string {
if p := os.Getenv("NUNTIUS_CONFIG"); p != "" {
return p
}
return "/etc/nuntius/config.toml"
}
// expandConfig applies expandEnv to every non-comment line of a TOML file.
// Lines whose first non-blank character is '#' are documentation, never
// values, so dollar signs there must survive verbatim even when they show
// placeholder syntax like ${VAR_NAME}.
func expandConfig(s string) (string, error) {
lines := strings.Split(s, "\n")
for i, ln := range lines {
if strings.HasPrefix(strings.TrimLeft(ln, " \t"), "#") {
continue
}
out, err := expandEnv(ln)
if err != nil {
return "", err
}
lines[i] = out
}
return strings.Join(lines, "\n"), nil
}
// expandEnv substitutes $VAR and ${VAR} references with their environment
// values. Syntax follows os.Expand. Unlike os.Expand it fails when a
// referenced variable is not set: a silently empty SMTP password is far
// harder to diagnose than an explicit startup error. A dollar sign not
// followed by a variable name stays literal.
func expandEnv(s string) (string, error) {
var b strings.Builder
for i := 0; i < len(s); {
c := s[i]
if c != '$' {
b.WriteByte(c)
i++
continue
}
name, width := varName(s[i+1:])
if name == "" {
// "$" with no name after it (or unterminated braces): keep it.
b.WriteByte('$')
i++
continue
}
value, ok := os.LookupEnv(name)
if !ok {
return "", fmt.Errorf("environment variable %s is referenced but not set", name)
}
b.WriteString(value)
i += 1 + width
}
return b.String(), nil
}
// varName parses the variable name that follows a dollar sign, mirroring
// os.Expand rules: either "${NAME}" or a bare run of ASCII letters, digits
// and underscores. The second result counts the bytes consumed after the
// dollar sign; zero width means there was no name at all.
func varName(s string) (string, int) {
if len(s) > 0 && s[0] == '{' {
end := strings.IndexByte(s, '}')
if end < 0 {
return "", 0
}
return s[1:end], end + 1
}
n := 0
for n < len(s) {
c := s[n]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') {
break
}
n++
}
return s[:n], n
}
// AddrFor returns "host:port" for the given SMTP config.
func (s SMTPConfig) AddrFor() string {
return s.Host + ":" + strconv.Itoa(s.Port)
}
// defaultConfig is written to disk when no config exists yet.
var defaultConfig = []byte(`# nuntius configuration.
#
# Secrets (SMTP passwords) are referenced as ${VAR_NAME} and expanded from the
# environment at startup, so they never live in this file. Referencing a
# variable that is not set aborts startup with an explicit error.
#
# Every key below is optional: omitting one keeps its default. The commented
# lines document the keys most deployments never need to touch.
data_dir = "./data"
[server]
port = 8080
# Host part of the listen address; the default "::" binds the dual-stack
# wildcard, so port 8080 answers on IPv4 and IPv6 alike.
#bind = "::"
# HTTP timeouts in seconds; 0 switches a timeout off. Defaults: 10, 15, 30, 60.
#read_header_timeout_seconds = 10
#read_timeout_seconds = 15
#write_timeout_seconds = 30
#idle_timeout_seconds = 60
# Graceful shutdown budget in seconds (default 15).
#shutdown_timeout_seconds = 15
# Request body cap in bytes (default 1048576, i.e. 1 MiB).
#max_body_bytes = 1048576
# Rate limiter mechanics: per-form cap on distinct IP buckets, cleanup tick
# and the age at which an idle bucket is dropped (defaults 32768, 3600, 7200).
#rate_limit_max_buckets = 32768
#rate_limit_cleanup_seconds = 3600
#rate_limit_max_bucket_age_seconds = 7200
# Bearer token guarding GET /metrics; empty keeps the endpoint open.
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
# Plain HTML form mode: an accepted submission answers 303 See Other with
# this location, so the form works without JavaScript. Omitted (the
# default), accepted submissions answer JSON.
#redirect_url = "https://example.com/thanks"
# Archive accepted submissions to data_dir/archive-<name>.jsonl before the
# mail goes out, so a failed SMTP round-trip loses nothing. Newsletter
# forms always persist through the double opt-in log instead.
#archive = true
# Send the submitter a short automated receipt. Newsletter forms reject
# the key; their subscribers already receive the confirmation mail.
#auto_reply = true
# Telegram notification channel: the summary lands in the chat alongside
# the mail, and the submission counts as delivered when either gets
# through. Newsletter forms reject the key.
#[forms.telegram]
#bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
#chat_id = "123456789"
#timeout_seconds = 10
# Allow-list for the optional "service" payload field. The empty value is
# always accepted; the "*" entry accepts any value; omitting the key keeps
# the built-in list below; an explicit empty list accepts only the empty
# value. The same key works on any form type.
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
# Validation overrides on top of the type preset; the values shown are the
# defaults. The email address is always required and never length-limited.
#require_name = true
#require_message = true
#min_name_runes = 2
#max_name_runes = 100
#min_message_runes = 10
#max_message_runes = 5000
# Subject prefix, rendered as "[nuntius/contact]" in every mail subject; an
# empty string drops the bracket segment. Default "nuntius".
#subject_prefix = "nuntius"
# Footer brand, rendered as "Delivered by nuntius"; an empty string drops
# the footer. Default "nuntius".
#email_brand = "nuntius"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
# Whole SMTP conversation bound in seconds (default 20).
#timeout_seconds = 20
[[forms]]
name = "feedback"
type = "feedback"
path = "/api/nuntius/feedback"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
[[forms]]
name = "newsletter"
type = "newsletter"
path = "/api/nuntius/newsletter"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
# Double opt-in lifetime in seconds (default 259200, i.e. 72 hours).
#pending_ttl_seconds = 259200
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
`)
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package contactform provides the types and the declarative validation
// the form pipeline runs on.
package contactform
// Request is the JSON body sent to the contact endpoint.
type Request struct {
Name string `json:"name"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
}
// Response is the success response.
type Response struct {
OK bool `json:"ok"`
}
// FieldError describes a single validation failure.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ErrorResponse is returned for any non-2xx response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details []FieldError `json:"details,omitempty"`
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package contactform
import (
"fmt"
"net/mail"
"slices"
"strings"
"unicode/utf8"
)
// MinNameRunes is the default minimum allowed length of a name.
const MinNameRunes = 2
// MaxNameRunes is the default maximum allowed length of a name.
const MaxNameRunes = 100
// MinMessageRunes is the default minimum allowed length of a message body.
const MinMessageRunes = 10
// MaxMessageRunes is the default maximum allowed length of a message body.
const MaxMessageRunes = 5000
// ServiceAny is the services entry that accepts any service value.
const ServiceAny = "*"
// DefaultServices is the built-in service allow-list the contact preset
// applies when a form does not define its own list. The empty value is
// always accepted on top of whatever this list holds, because a form that
// offers no choice never sends the field at all.
var DefaultServices = []string{
"architecture",
"ai",
"infrastructure",
"software",
"unix",
"other",
}
// Policy is the declarative validation rule set for one form. The server
// builds it from the form's configuration; a library caller writes it
// directly. The zero value accepts any name and message, so every limit
// that matters must be set explicitly.
type Policy struct {
// RequireName and RequireMessage switch the length checks for the two
// free-text fields on and off. The email address is always required
// and always checked: every form delivers mail and needs a reply-to.
RequireName bool
RequireMessage bool
MinNameRunes int
MaxNameRunes int
MinMessageRunes int
MaxMessageRunes int
// Services is the allow-list for the optional service field. A nil
// list means the field is not validated at all; a non-nil list holds
// the accepted values, with the empty value always accepted and the
// ServiceAny entry lifting the restriction entirely.
Services []string
}
// Preset returns the built-in policy for a form type. An empty or unknown
// type falls back to the contact preset, which is also how the server
// treats an unconfigured type. Every preset carries the default length
// limits; the newsletter preset simply leaves both free-text fields out
// of the checks, so switching them on later starts from sane limits.
func Preset(formType string) Policy {
switch formType {
case "newsletter":
return Policy{
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
}
case "feedback", "generic":
return Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
}
default: // contact, and the fallback for every unknown type
return Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
Services: slices.Clone(DefaultServices),
}
}
}
// Validate normalises the request in place (trims whitespace from every
// text field) and checks it against p. It returns one entry per failed
// field; a nil slice means the request is valid.
func Validate(r *Request, p Policy) []FieldError {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
r.Service = strings.TrimSpace(r.Service)
r.Message = strings.TrimSpace(r.Message)
var errs []FieldError
if p.RequireName {
errs = append(errs, checkRunes("name", r.Name, p.MinNameRunes, p.MaxNameRunes)...)
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if p.Services != nil && !serviceAllowed(p.Services, r.Service) {
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
}
if p.RequireMessage {
errs = append(errs, checkRunes("message", r.Message, p.MinMessageRunes, p.MaxMessageRunes)...)
}
return errs
}
// NormalizeAndValidate trims whitespace from all text fields and then
// checks the request against the built-in preset for the form type.
// Supported types: contact, feedback, newsletter, generic; an empty or
// unknown type falls back to contact. Callers that need their own limits
// or their own service allow-list build a Policy and call Validate.
func NormalizeAndValidate(r *Request, formType string) []FieldError {
return Validate(r, Preset(formType))
}
// checkRunes reports the length errors for one field in rune counts.
func checkRunes(field, value string, minRunes, maxRunes int) []FieldError {
n := utf8.RuneCountInString(value)
var errs []FieldError
if n < minRunes {
errs = append(errs, FieldError{
Field: field,
Message: fmt.Sprintf("%s must be at least %d characters", field, minRunes),
})
} else if n > maxRunes {
errs = append(errs, FieldError{
Field: field,
Message: fmt.Sprintf("%s must be at most %d characters", field, maxRunes),
})
}
return errs
}
// serviceAllowed reports whether s passes the allow-list. The empty value
// is always legitimate: petrbalvin.org and every other frontend is free to
// post a payload without a service field. The ServiceAny entry accepts any
// non-empty value.
func serviceAllowed(list []string, s string) bool {
if s == "" {
return true
}
if slices.Contains(list, ServiceAny) {
return true
}
return slices.Contains(list, s)
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package contactform
import (
"slices"
"strings"
"testing"
)
func TestValidateContact_Happy(t *testing.T) {
r := &Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss a project.",
}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateContact_DefaultType(t *testing.T) {
// Unknown / empty type falls back to contact validation.
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
if errs := NormalizeAndValidate(r, ""); len(errs) > 0 {
t.Fatalf("expected no errors with empty type, got %v", errs)
}
}
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
errs := NormalizeAndValidate(r, "contact")
if len(errs) == 0 {
t.Fatal("expected error for invalid email")
}
if errs[0].Field != "email" {
t.Errorf("expected field=email, got %q", errs[0].Field)
}
}
func TestValidateContact_RejectsBadService(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
errs := NormalizeAndValidate(r, "contact")
if len(errs) == 0 || errs[0].Field != "service" {
t.Fatalf("expected service error, got %v", errs)
}
}
func TestValidateFeedback_OmitsService(t *testing.T) {
// Feedback validates like generic: name, email, message; no service field.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
if errs := NormalizeAndValidate(r, "feedback"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
errs := NormalizeAndValidate(r, "generic")
if len(errs) == 0 || errs[0].Field != "message" {
t.Fatalf("expected message error, got %v", errs)
}
}
func TestValidateNewsletter_Happy(t *testing.T) {
r := &Request{Email: "jane@example.com"}
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
r := &Request{Email: "garbage"}
errs := NormalizeAndValidate(r, "newsletter")
if len(errs) == 0 {
t.Fatal("expected error for missing/invalid email")
}
}
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
// Newsletter type only cares about the email field.
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
}
}
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
// validation, not to a hard error. The hard error is enforced at the
// config layer, not in Validate itself.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
if errs := NormalizeAndValidate(r, "foo"); len(errs) > 0 {
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
}
}
func TestValidateTrimsWhitespace(t *testing.T) {
r := &Request{
Name: " Jane ",
Email: " jane@example.com ",
Service: " architecture ",
Message: " hello there ",
}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
t.Errorf("expected whitespace to be trimmed, got %+v", r)
}
}
// ---------------------------------------------------------------------------
// Policy-driven validation
// ---------------------------------------------------------------------------
// The presets must reproduce the exact rules the fixed validators enforced
// before the policy layer existed.
func TestPresets(t *testing.T) {
contact := Preset("contact")
if !contact.RequireName || !contact.RequireMessage {
t.Error("contact preset must require name and message")
}
if contact.MinNameRunes != MinNameRunes || contact.MaxNameRunes != MaxNameRunes {
t.Errorf("contact name limits = %d/%d, want %d/%d",
contact.MinNameRunes, contact.MaxNameRunes, MinNameRunes, MaxNameRunes)
}
if contact.MinMessageRunes != MinMessageRunes || contact.MaxMessageRunes != MaxMessageRunes {
t.Errorf("contact message limits = %d/%d, want %d/%d",
contact.MinMessageRunes, contact.MaxMessageRunes, MinMessageRunes, MaxMessageRunes)
}
if !slices.Contains(contact.Services, "architecture") || !slices.Contains(contact.Services, "other") {
t.Errorf("contact preset services = %v, want the built-in list", contact.Services)
}
for _, typ := range []string{"feedback", "generic"} {
p := Preset(typ)
if !p.RequireName || !p.RequireMessage {
t.Errorf("%s preset must require name and message", typ)
}
if p.Services != nil {
t.Errorf("%s preset must not validate service, got %v", typ, p.Services)
}
}
news := Preset("newsletter")
if news.RequireName || news.RequireMessage || news.Services != nil {
t.Errorf("newsletter preset = %+v, want email-only", news)
}
// Unknown and empty types fall back to contact.
fallback := Preset("nonsense")
if !fallback.RequireName || fallback.Services == nil {
t.Errorf("unknown type preset = %+v, want the contact preset", fallback)
}
}
// A payload without a service field is legitimate under the contact
// preset: the field is optional, never a mandatory category.
func TestValidateContactWithoutServiceField(t *testing.T) {
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "A plain hello for you."}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("payload without service must pass, got %v", errs)
}
}
// A nil Services list means the field is not validated at all, so any
// value rides through untouched.
func TestPolicyNilServicesSkipsValidation(t *testing.T) {
p := Preset("feedback")
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "anything-goes", Message: "A message long enough."}
if errs := Validate(r, p); len(errs) > 0 {
t.Fatalf("nil services list must skip the field, got %v", errs)
}
}
func TestPolicyServices(t *testing.T) {
base := func(services []string, service string) []FieldError {
p := Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: 2, MaxNameRunes: 100,
MinMessageRunes: 10, MaxMessageRunes: 5000,
Services: services,
}
r := &Request{Name: "Jane", Email: "jane@example.com", Service: service, Message: "A message long enough."}
return Validate(r, p)
}
tests := []struct {
name string
services []string
service string
wantErr bool
}{
{"empty service always passes", []string{"consulting"}, "", false},
{"listed value passes", []string{"consulting", "support"}, "support", false},
{"unlisted value fails", []string{"consulting"}, "hairstyling", true},
{"explicit empty list rejects any value", []string{}, "consulting", true},
{"explicit empty list keeps empty value", []string{}, "", false},
{"wildcard accepts anything", []string{ServiceAny}, "hairstyling", false},
{"wildcard beside entries still accepts anything", []string{"consulting", ServiceAny}, "anything", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := base(tt.services, tt.service)
if gotErr := len(errs) > 0; gotErr != tt.wantErr {
t.Fatalf("Validate(service=%q, list=%v) errors = %v, wantErr %v",
tt.service, tt.services, errs, tt.wantErr)
}
if tt.wantErr && errs[0].Field != "service" {
t.Errorf("error field = %q, want service", errs[0].Field)
}
})
}
}
// Custom limits are honoured and the error messages carry the configured
// numbers, not the built-in defaults.
func TestPolicyCustomLimits(t *testing.T) {
p := Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: 5,
MaxNameRunes: 10,
MinMessageRunes: 20,
MaxMessageRunes: 40,
}
r := &Request{Name: "Ja", Email: "jane@example.com", Message: "too short"}
errs := Validate(r, p)
if len(errs) != 2 {
t.Fatalf("errors = %v, want two length failures", errs)
}
if errs[0].Field != "name" || !strings.Contains(errs[0].Message, "at least 5 characters") {
t.Errorf("name error = %+v, want the configured minimum", errs[0])
}
if errs[1].Field != "message" || !strings.Contains(errs[1].Message, "at least 20 characters") {
t.Errorf("message error = %+v, want the configured minimum", errs[1])
}
r2 := &Request{Name: "A very long name over the limit", Email: "jane@example.com",
Message: strings.Repeat("x", 41)}
errs = Validate(r2, p)
if len(errs) != 2 {
t.Fatalf("errors = %v, want two maximum failures", errs)
}
if !strings.Contains(errs[0].Message, "at most 10 characters") ||
!strings.Contains(errs[1].Message, "at most 40 characters") {
t.Errorf("errors = %v, want the configured maxima", errs)
}
}
// A false Require flag takes the whole field out of the checks; the field
// is still trimmed so the email body sees clean text.
func TestPolicyOptionalFields(t *testing.T) {
p := Policy{RequireName: false, RequireMessage: false, Services: []string{ServiceAny}}
r := &Request{Name: "", Email: "jane@example.com", Message: "", Service: "x"}
if errs := Validate(r, p); len(errs) > 0 {
t.Fatalf("optional fields must pass empty, got %v", errs)
}
if r.Name != "" || r.Message != "" || r.Service != "x" {
t.Errorf("fields should stay trimmed, got %+v", r)
}
}
// The email address is never optional: it is the reply-to of the mail the
// form produces.
func TestPolicyEmailAlwaysRequired(t *testing.T) {
p := Policy{}
r := &Request{Name: "Jane", Email: "not-an-email", Message: "hello"}
errs := Validate(r, p)
if len(errs) != 1 || errs[0].Field != "email" {
t.Fatalf("errors = %v, want exactly the email failure", errs)
}
}
// Validate must produce the same verdicts as the type dispatcher did
// before the policy layer: every case the old tests covered still holds
// through the wrapper.
func TestNormalizeAndValidateBackwardCompatible(t *testing.T) {
cases := []struct {
name string
formType string
req Request
wantErr bool
}{
{"contact happy", "contact", Request{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss a project."}, false},
{"contact bad service", "contact", Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}, true},
{"feedback happy", "feedback", Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}, false},
{"generic short message", "generic", Request{Name: "Jane", Email: "jane@example.com", Message: "short"}, true},
{"newsletter happy", "newsletter", Request{Email: "jane@example.com"}, false},
{"newsletter bad email", "newsletter", Request{Email: "garbage"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := tc.req
errs := NormalizeAndValidate(&req, tc.formType)
if got := len(errs) > 0; got != tc.wantErr {
t.Fatalf("NormalizeAndValidate(%q) errors = %v, wantErr %v", tc.formType, errs, tc.wantErr)
}
})
}
}
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
// Sanity: the minLength error message in the en.json UI mirror should
// remain a simple string. Here we just check that error messages are
// human-readable, not empty.
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
errs := NormalizeAndValidate(r, "contact")
if len(errs) < 2 {
t.Fatalf("expected at least 2 errors, got %v", errs)
}
for _, e := range errs {
if strings.TrimSpace(e.Message) == "" {
t.Errorf("error message is empty for field %q", e.Field)
}
}
}
// FuzzValidate explores the validator with arbitrary payloads and
// policies. The invariants: it never panics, every reported error names
// one of the four known fields with a non-empty message, and validation
// is deterministic in the trimmed request.
func FuzzValidate(f *testing.F) {
seeds := []Request{
{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss an engagement."},
{Name: "", Email: "", Service: "", Message: ""},
{Name: " J ", Email: " j@example.com ", Service: " unix ", Message: "x"},
{Name: "<script>", Email: "a@b", Service: "unknown", Message: strings.Repeat("m", 6000)},
{Name: "Elf", Email: "u@exämple.com", Service: "*", Message: "emoji 🚀 body"},
}
for _, s := range seeds {
f.Add(s.Name, s.Email, s.Service, s.Message, true, true, 2, 100, 10, 5000, 0)
}
f.Add("A", "a@b.c", "", "long enough", false, false, 0, 1, 0, 1, 3)
knownFields := []string{"name", "email", "service", "message"}
serviceLists := [][]string{nil, {}, {"architecture", "unix"}, {"*"}}
f.Fuzz(func(t *testing.T, name, email, service, message string,
requireName, requireMessage bool,
minName, maxName, minMessage, maxMessage, serviceChoice int) {
policy := Policy{
RequireName: requireName,
RequireMessage: requireMessage,
MinNameRunes: minName,
MaxNameRunes: maxName,
MinMessageRunes: minMessage,
MaxMessageRunes: maxMessage,
Services: serviceLists[((serviceChoice%len(serviceLists))+len(serviceLists))%len(serviceLists)],
}
req := Request{Name: name, Email: email, Service: service, Message: message}
errs := Validate(&req, policy)
for _, e := range errs {
if !slices.Contains(knownFields, e.Field) {
t.Fatalf("unknown field %q in error %q", e.Field, e.Message)
}
if strings.TrimSpace(e.Message) == "" {
t.Fatalf("empty message on field %q", e.Field)
}
}
again := Validate(&req, policy)
if !slices.Equal(errs, again) {
t.Fatalf("validation is not deterministic: %v then %v", errs, again)
}
})
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
// emailTemplateAcknowledgement is the HTML body of the automated receipt
// a form with auto_reply enabled sends to the submitter.
var emailTemplateAcknowledgement = template.Must(template.New("acknowledgement").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="460" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:20px 24px">
<p style="margin:0;font-size:18px;font-weight:700;color:#fff">Message received</p>
</td></tr>
<tr><td style="padding:24px">
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0 0 12px">Hello,</p>
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0 0 12px">your message to <strong>{{.FormName}}</strong> was received. A reply will follow as soon as possible.</p>
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0">Please do not respond to this automated receipt.</p>
{{if .Brand}}
<p style="font-size:11px;color:#9ca3af;margin:16px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
{{end}}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// SendAcknowledgement mails the submitter a short receipt. It uses the
// form's SMTP identity, and the Reply-To points at the owner, so a reply
// to the receipt lands in the human's inbox and not into the void.
func (s *FormSender) SendAcknowledgement(to string) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg, err := composeAcknowledgement(s.form, to)
if err != nil {
return fmt.Errorf("compose acknowledgement: %w", err)
}
return sendMail(s.form.SMTP, auth, s.form.From, []string{to}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
}
// composeAcknowledgement builds the multipart receipt. The submitted
// values are deliberately not echoed back: the receipt confirms arrival,
// it does not mirror a message's content into the submitter's inbox,
// where any third party who could fill the form would read it.
func composeAcknowledgement(form *config.Form, to string) ([]byte, error) {
// --- HTML part ---
var htmlBuf bytes.Buffer
if err := emailTemplateAcknowledgement.Execute(&htmlBuf, map[string]string{
"FormName": form.Name,
"Brand": form.Brand(),
}); err != nil {
// template.Must guarantees valid templates; unreachable in
// normal operation.
htmlBuf.Reset()
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
}
// --- Subject + plain-text body ---
subject := "Message received"
if tag := subjectTag(form, ""); tag != "" {
subject = tag + " " + subject
}
footer := ""
if brand := form.Brand(); brand != "" {
footer = "--\r\nDelivered by " + brand + "\r\n"
}
text := fmt.Sprintf(
"Hello,\r\n\r\n"+
"your message to %s was received. A reply will follow as soon as possible.\r\n"+
"Please do not respond to this automated receipt.\r\n\r\n%s",
form.Name, footer,
)
// Assemble multipart/alternative. The boundary comes first so that
// randomness failure aborts the message before anything is built.
boundary, err := randomBoundary()
if err != nil {
return nil, err
}
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(form.From)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(form.To)))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary))
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(text)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(htmlBuf.String())
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
return msg.Bytes(), nil
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
func ackForm() *config.Form {
return &config.Form{
Name: "contact",
Type: "contact",
To: "owner@example.com",
From: "noreply@example.com",
SubjectPrefix: new("nuntius"),
EmailBrand: new("nuntius"),
}
}
func TestComposeAcknowledgement(t *testing.T) {
msg, err := composeAcknowledgement(ackForm(), "jane@example.com")
if err != nil {
t.Fatalf("compose: %v", err)
}
raw := string(msg)
for _, want := range []string{
"From: noreply@example.com\r\n",
"To: jane@example.com\r\n",
"Subject: [nuntius/contact] Message received\r\n",
// The reply lands in the owner's inbox, not in the void.
"Reply-To: owner@example.com\r\n",
"Content-Type: multipart/alternative; boundary=nuntius-",
"your message to contact was received",
"Delivered by nuntius",
} {
if !strings.Contains(raw, want) {
t.Errorf("message missing %q", want)
}
}
// The submitted values are deliberately never echoed back.
if strings.Contains(raw, "jane@example.com\r\n\r\n") && strings.Count(raw, "jane@example.com") != 1 {
t.Errorf("message echoes the submitter context beyond the To header")
}
}
func TestComposeAcknowledgementHeaderInjection(t *testing.T) {
msg, err := composeAcknowledgement(ackForm(), "jane@example.com\r\nBcc: victim@example.com")
if err != nil {
t.Fatalf("compose: %v", err)
}
raw := string(msg)
// The whole string collapses into one To header line: no line starts
// with the injected header name.
for line := range strings.SplitSeq(raw, "\r\n") {
if strings.HasPrefix(line, "Bcc:") {
t.Errorf("a CR/LF in the recipient injected a header line %q", line)
}
}
if !strings.Contains(raw, "To: jane@example.com Bcc: victim@example.com\r\n") {
t.Errorf("the CR/LF was not neutralised into spaces")
}
}
func TestComposeAcknowledgementSubjectWithoutPrefix(t *testing.T) {
form := ackForm()
form.SubjectPrefix = new("")
msg, err := composeAcknowledgement(form, "jane@example.com")
if err != nil {
t.Fatalf("compose: %v", err)
}
if !strings.Contains(string(msg), "Subject: Message received\r\n") {
t.Errorf("subject with an empty prefix = %q, want the bare subject", subjectOf(t, string(msg)))
}
}
func subjectOf(t *testing.T, raw string) string {
t.Helper()
for line := range strings.SplitSeq(raw, "\r\n") {
if after, ok := strings.CutPrefix(line, "Subject: "); ok {
return after
}
}
t.Fatalf("no subject line in message")
return ""
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"bytes"
"fmt"
"net/smtp"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
// SendConfirmation mails the double opt-in link directly to the subscriber.
// It uses the form's SMTP identity so replies land at the owner address,
// which is set as Reply-To.
func (s *FormSender) SendConfirmation(to, link string) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg := composeConfirmation(s.form.From, to, s.form.To, s.form, link)
return sendMail(s.form.SMTP, auth, s.form.From, []string{to}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
}
// composeConfirmation builds a single-part plain-text message. Transactional
// confirmations stay deliberately simple: one link, no tracking, no HTML.
// The subject prefix and the footer brand come from the form's
// configuration; the defaults reproduce the earlier wording.
func composeConfirmation(from, to, replyToOwner string, form *config.Form, link string) []byte {
subject := "Confirm your subscription"
if prefix := form.EmailSubjectPrefix(); prefix != "" {
subject = fmt.Sprintf("[%s/%s] %s", prefix, form.Name, subject)
}
var body bytes.Buffer
fmt.Fprintf(&body, "Hi,\r\n\r\n")
fmt.Fprintf(&body, "someone signed this address up for the \"%s\" form.\r\n", form.Name)
fmt.Fprintf(&body, "If that was you, please confirm the subscription by opening:\r\n\r\n")
fmt.Fprintf(&body, " %s\r\n\r\n", link)
fmt.Fprintf(&body, "If it was not you, ignore this message and nothing will happen:\r\n")
fmt.Fprintf(&body, "the request expires automatically without any action from you.\r\n\r\n")
if brand := form.Brand(); brand != "" {
fmt.Fprintf(&body, "Delivered by %s\r\n", brand)
}
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(from)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(replyToOwner)))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
msg.WriteString("\r\n")
msg.Write(body.Bytes())
return msg.Bytes()
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
func TestComposeConfirmation(t *testing.T) {
form := &config.Form{Name: "newsletter"}
msg := string(composeConfirmation("noreply@example.com", "jane@example.com",
"owner@example.com", form,
"https://example.com/api/news/confirm?token=abc123"))
headers := msg[:strings.Index(msg, "\r\n\r\n")]
if !strings.Contains(headers, "Subject: [nuntius/newsletter] Confirm your subscription") {
t.Errorf("subject missing in:\n%s", headers)
}
if strings.Count(headers, "Subject:") != 1 {
t.Errorf("exactly one Subject header expected:\n%s", headers)
}
if !strings.Contains(headers, "Reply-To: owner@example.com") {
t.Errorf("Reply-To should reach the owner:\n%s", headers)
}
if !strings.Contains(msg, "https://example.com/api/news/confirm?token=abc123") {
t.Error("confirmation link missing from the body")
}
if !strings.Contains(msg, "Delivered by nuntius\r\n") {
t.Error("default brand footer missing from the body")
}
}
// A configured prefix and brand replace the default nuntius wording; an
// empty prefix drops the bracket segment and an empty brand drops the
// footer line entirely.
func TestComposeConfirmationPrefixAndBrand(t *testing.T) {
form := &config.Form{
Name: "news",
SubjectPrefix: new("web"),
EmailBrand: new("Acme Mail"),
}
msg := string(composeConfirmation("noreply@example.com", "jane@example.com",
"owner@example.com", form, "https://example.com/confirm?token=abc"))
if !strings.Contains(msg, "Subject: [web/news] Confirm your subscription") {
t.Errorf("configured prefix missing:\n%s", msg)
}
if !strings.Contains(msg, "Delivered by Acme Mail\r\n") {
t.Errorf("configured brand missing:\n%s", msg)
}
form.SubjectPrefix = new("")
form.EmailBrand = new("")
msg = string(composeConfirmation("noreply@example.com", "jane@example.com",
"owner@example.com", form, "https://example.com/confirm?token=abc"))
if !strings.Contains(msg, "Subject: Confirm your subscription\r\n") {
t.Errorf("empty prefix must drop the bracket segment:\n%s", msg)
}
if strings.Contains(msg, "Delivered by") {
t.Errorf("empty brand must drop the footer:\n%s", msg)
}
}
+494
View File
@@ -0,0 +1,494 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package email handles SMTP message composition and delivery.
package email
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"html/template"
"net"
"net/smtp"
"strings"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// effectiveTLSConfig returns cfg when set, otherwise a default config that
// verifies the server certificate against host.
func effectiveTLSConfig(cfg *tls.Config, host string) *tls.Config {
if cfg != nil {
return cfg
}
return &tls.Config{ServerName: host}
}
// FormSender delivers contact form submissions for a single form
// using its own SMTP credentials. Each form has its own FormSender
// so credentials are isolated per tenant.
type FormSender struct {
form *config.Form
// TLSConfig optionally overrides the TLS configuration used for
// STARTTLS. When nil, a default config with ServerName set to the
// SMTP host is used.
TLSConfig *tls.Config
}
// NewFormSender returns a new FormSender bound to the given form.
func NewFormSender(form *config.Form) *FormSender {
return &FormSender{form: form}
}
// Send composes and sends a multi-part email (plain text + HTML)
// for the given request. Returns an error if composition or the SMTP
// round-trip fails. The SMTP conversation bound comes from the form's
// configured smtp.timeout_seconds.
func (s *FormSender) Send(req contactform.Request) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg, err := compose(s.form.From, s.form.To, req, s.form)
if err != nil {
return fmt.Errorf("compose message: %w", err)
}
return sendMail(s.form.SMTP, auth, s.form.From, []string{s.form.To}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
}
// sendMail delivers msg over SMTP with a hard timeout on every network
// operation. When cfg.Port is 465 the connection speaks TLS from the first
// byte (implicit TLS); any other port starts plaintext and upgrades via
// STARTTLS when the server advertises it. With cfg.RequireTLS set, an SMTP
// server that never offers STARTTLS aborts the delivery instead of sending
// over plaintext. If tlsConfig is nil, a default config with ServerName set
// to the target host is used.
func sendMail(cfg config.SMTPConfig, auth smtp.Auth, from string, to []string, msg []byte, timeout time.Duration, tlsConfig *tls.Config) error {
addr := cfg.AddrFor()
implicitTLS := cfg.Port == config.ImplicitTLSPort
var conn net.Conn
if implicitTLS {
dialer := &net.Dialer{Timeout: timeout}
tconn, err := tls.DialWithDialer(dialer, "tcp", addr, effectiveTLSConfig(tlsConfig, cfg.Host))
if err != nil {
return fmt.Errorf("dial smtps %s: %w", addr, err)
}
conn = tconn
} else {
pconn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("dial smtp %s: %w", addr, err)
}
conn = pconn
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
return fmt.Errorf("set smtp deadline: %w", err)
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if !implicitTLS {
ok, _ := c.Extension("STARTTLS")
switch {
case ok:
if err := c.StartTLS(effectiveTLSConfig(tlsConfig, cfg.Host)); err != nil {
return fmt.Errorf("starttls: %w", err)
}
case cfg.RequireTLS:
return fmt.Errorf("smtp server %s does not advertise starttls but require_tls is enabled", addr)
}
}
if auth != nil {
if ok, _ := c.Extension("AUTH"); ok {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
for _, rcpt := range to {
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp write data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
if err := c.Quit(); err != nil {
return fmt.Errorf("smtp quit: %w", err)
}
return nil
}
// randomBoundary returns a MIME boundary that is practically impossible
// to collide with message content. An error means crypto/rand failed; the
// message must then not be sent at all, because a predictable delimiter
// would let crafted content forge MIME part boundaries.
func randomBoundary() (string, error) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("generate mime boundary: %w", err)
}
return "nuntius-" + hex.EncodeToString(buf[:]), nil
}
// sanitizeHeaderValue makes an interpolated value safe to embed in a
// single RFC 5322 header line. A CR or LF would terminate the header and
// let a crafted value inject arbitrary additional headers.
func sanitizeHeaderValue(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
// emailTemplateContact is the HTML body template for contact-type forms.
var emailTemplateContact = template.Must(template.New("contact").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.06)">
<!-- Header -->
<tr>
<td style="background-color:#1e40af;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:0.5px">
Contact Form Submission
</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#ffffff">
{{.FormName}}
</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:28px 28px 12px">
<!-- Submitter -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:20px">
<tr>
<td style="padding-bottom:8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">From</span>
<br>
<span style="font-size:15px;font-weight:600;color:#1f2937">{{.Name}}</span>
<span style="font-size:14px;color:#1e40af;margin-left:8px">{{.Email}}</span>
</td>
</tr>
{{if .Service}}
<tr>
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Service Interest</span>
<br>
<span style="font-size:14px;color:#1f2937">{{.Service}}</span>
</td>
</tr>
{{end}}
<tr>
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Received</span>
<br>
<span style="font-size:13px;color:#9ca3af">{{.Received}}</span>
</td>
</tr>
</table>
<!-- Message -->
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;margin:0 0 8px">Message</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;padding:6px 0">{{.Message}}</div>
</td>
</tr>
<!-- Footer -->
{{if .Brand}}
<tr>
<td style="padding:16px 28px 28px">
<p style="margin:0;font-size:11px;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:16px">
Delivered by <strong style="color:#6b7280">{{.Brand}}</strong>, a contact form backend for Linux servers.
</p>
</td>
</tr>
{{end}}
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateFeedback is the HTML body template for feedback-type forms.
var emailTemplateFeedback = template.Must(template.New("feedback").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#0f766e;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#5eead4;text-transform:uppercase;letter-spacing:.5px">New Feedback</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
</td></tr>
<tr><td style="padding:28px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#0f766e">{{.Email}}</span></p>
{{if .Service}}
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Service Interest</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;margin:0 0 16px">{{.Service}}</div>
{{end}}
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Feedback</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
{{if .Brand}}
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
{{end}}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateNewsletter is the HTML body template for newsletter-signup forms.
var emailTemplateNewsletter = template.Must(template.New("newsletter").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="460" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:20px 24px">
<p style="margin:0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}: new subscriber</p>
</td></tr>
<tr><td style="padding:24px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 4px">Email</p>
<p style="font-size:16px;font-weight:600;color:#1e40af;margin:0 0 16px">{{.Email}}</p>
{{if .Brand}}
<p style="font-size:11px;color:#9ca3af;margin:16px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
{{end}}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateGeneric is the HTML body template for generic forms.
var emailTemplateGeneric = template.Must(template.New("generic").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:.5px">Form Submission</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
</td></tr>
<tr><td style="padding:28px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#1e40af">{{.Email}}</span></p>
{{if .Service}}
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Service Interest</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;margin:0 0 16px">{{.Service}}</div>
{{end}}
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Message</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
{{if .Brand}}
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
{{end}}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// compose builds the multipart message for one submission. The subject
// prefix and the footer brand come from the form's configuration; the
// defaults reproduce the subjects and footers earlier releases sent.
func compose(from, to string, req contactform.Request, form *config.Form) ([]byte, error) {
received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC")
// The service interest only surfaces when the form actually accepts
// the field: the contact template has always shown it, and a form
// with its own services list has opted the field into validation.
service := ""
if req.Service != "" && (form.Type == "contact" || form.Services != nil) {
service = req.Service
}
// --- HTML part ---
var htmlBuf bytes.Buffer
tmpl := emailTemplateContact
switch form.Type {
case "newsletter":
tmpl = emailTemplateNewsletter
case "feedback":
tmpl = emailTemplateFeedback
case "generic":
tmpl = emailTemplateGeneric
}
if err := tmpl.Execute(&htmlBuf, map[string]string{
"FormName": form.Name,
"Name": req.Name,
"Email": req.Email,
"Service": service,
"Message": req.Message,
"Received": received,
"Brand": form.Brand(),
}); err != nil {
// template.Must guarantees valid templates; this path is
// unreachable in normal operation.
htmlBuf.Reset()
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
}
// --- Subject + plain-text body per form type ---
var base string
switch form.Type {
case "newsletter":
base = "New newsletter subscriber"
case "feedback":
base = "New feedback"
case "generic":
base = "New submission"
default: // contact
base = "Contact form submission"
}
subject := base
if tag := subjectTag(form, service); tag != "" {
subject = tag + " " + base
}
footer := ""
if brand := form.Brand(); brand != "" {
footer = "Delivered by " + brand + "\r\n"
}
var text string
switch form.Type {
case "newsletter":
text = fmt.Sprintf(
"New Newsletter Subscriber: %s\r\n"+
"---\r\n"+
"Email: %s\r\n"+
"Received: %s\r\n"+
"\r\n",
form.Name, req.Email, received,
)
case "feedback":
text = fmt.Sprintf(
"New Feedback: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"%s"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n",
form.Name, req.Name, req.Email, serviceLine(service), received, req.Message,
)
case "generic":
text = fmt.Sprintf(
"New Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"%s"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n",
form.Name, req.Name, req.Email, serviceLine(service), received, req.Message,
)
default: // contact; the line is printed even when empty, as always
text = fmt.Sprintf(
"Contact Form Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Service interest: %s\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n",
form.Name, req.Name, req.Email, req.Service, received, req.Message,
)
}
text += footer
// Assemble multipart/alternative message. The boundary comes first so
// that randomness failure aborts the message before anything is built.
boundary, err := randomBoundary()
if err != nil {
return nil, err
}
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(from)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(req.Email)))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary))
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(text)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(htmlBuf.String())
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
return msg.Bytes(), nil
}
// subjectTag renders the bracket segments of a subject line: the
// configurable "[<prefix>/<form name>]" segment when a prefix is set,
// plus the "[<service>]" segment when a service value surfaced.
func subjectTag(form *config.Form, service string) string {
tag := ""
if prefix := form.EmailSubjectPrefix(); prefix != "" {
tag = "[" + prefix + "/" + form.Name + "]"
}
if service != "" {
tag += "[" + service + "]"
}
return tag
}
// serviceLine renders the optional plain-text service row for the form
// types whose body has no fixed service field.
func serviceLine(service string) string {
if service == "" {
return ""
}
return fmt.Sprintf("Service interest: %s\r\n", service)
}
+623
View File
@@ -0,0 +1,623 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"net/smtp"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// mustCompose wraps compose for tests: a composition failure is always a
// bug, not a case worth branching on. The bare form carries no policy
// keys, so the composition defaults apply.
func mustCompose(t *testing.T, from, to string, req contactform.Request, formName, formType string) []byte {
t.Helper()
b, err := compose(from, to, req, &config.Form{Name: formName, Type: formType})
if err != nil {
t.Fatalf("compose: %v", err)
}
return b
}
func TestComposeContactWithoutService(t *testing.T) {
req := contactform.Request{
Name: "Alice",
Email: "alice@example.com",
Message: "I have a question about your services.",
}
b := mustCompose(t, "from@example.com", "to@example.com", req, "MyForm", "contact")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") {
t.Errorf("expected subject with form name only, got body:\n%s", s)
}
if !strings.Contains(s, "From: from@example.com") {
t.Error("expected From header")
}
if !strings.Contains(s, "To: to@example.com") {
t.Error("expected To header")
}
if !strings.Contains(s, "Reply-To: alice@example.com") {
t.Error("expected Reply-To header")
}
if !strings.Contains(s, "MIME-Version: 1.0") {
t.Error("expected MIME-Version header")
}
if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") {
t.Error("expected text/html part")
}
if !strings.Contains(s, "Content-Type: multipart/alternative") {
t.Error("expected multipart/alternative content type")
}
if !strings.Contains(s, "I have a question about your services.") {
t.Error("expected message content in body")
}
if !strings.Contains(s, "Contact Form Submission:") {
t.Error("expected contact form plain-text header")
}
}
func TestComposeContactWithService(t *testing.T) {
req := contactform.Request{
Name: "Bob",
Email: "bob@example.com",
Service: "architecture",
Message: "I would like a consultation.",
}
b := mustCompose(t, "from@e.com", "to@e.com", req, "ContactForm", "contact")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") {
t.Errorf("expected subject with service tag, got body:\n%s", s)
}
if !strings.Contains(s, "Service interest: architecture") {
t.Error("expected service interest in plain text")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
if !strings.Contains(s, "bob@example.com") {
t.Error("expected submitter email in body")
}
}
func TestComposeFeedback(t *testing.T) {
req := contactform.Request{
Name: "Carol",
Email: "carol@example.com",
Message: "Great platform, but can you add dark mode?",
}
b := mustCompose(t, "sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") {
t.Errorf("expected feedback subject, got body:\n%s", s)
}
if !strings.Contains(s, "New Feedback:") {
t.Error("expected feedback plain-text header")
}
if !strings.Contains(s, "From: Carol <carol@example.com>") {
t.Error("expected From line in plain text")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
}
func TestComposeNewsletter(t *testing.T) {
req := contactform.Request{
Email: "subscriber@example.com",
}
b := mustCompose(t, "news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") {
t.Errorf("expected newsletter subject, got body:\n%s", s)
}
if !strings.Contains(s, "New Newsletter Subscriber:") {
t.Error("expected newsletter plain-text header")
}
if !strings.Contains(s, "Email: subscriber@example.com") {
t.Error("expected subscriber email in plain text")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
}
func TestComposeGeneric(t *testing.T) {
req := contactform.Request{
Name: "Dave",
Email: "dave@example.com",
Message: "Generic inquiry.",
}
b := mustCompose(t, "g@h.com", "g@h.com", req, "GenericForm", "generic")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") {
t.Errorf("expected generic subject, got body:\n%s", s)
}
if !strings.Contains(s, "New Submission:") {
t.Error("expected generic plain-text header")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
}
func TestComposeEmptyFormNameAndEmail(t *testing.T) {
req := contactform.Request{
Name: "",
Email: "",
Message: "",
}
b := mustCompose(t, "", "", req, "", "generic")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/] New submission") {
t.Errorf("expected subject with empty form name, got body:\n%s", s)
}
if !strings.Contains(s, "MIME-Version: 1.0") {
t.Error("expected MIME-Version header even with empty fields")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part even with empty fields")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part even with empty fields")
}
}
func TestComposeLongMessage(t *testing.T) {
longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200)
req := contactform.Request{
Name: "Eve",
Email: "eve@example.com",
Message: longMsg,
}
b := mustCompose(t, "x@y.com", "z@y.com", req, "LongForm", "feedback")
s := string(b)
if !strings.Contains(s, longMsg) {
t.Error("expected long message content in body")
}
// Verify it's in both text and html parts by checking after the respective
// content-type boundaries.
textIdx := strings.Index(s, "Content-Type: text/plain")
htmlIdx := strings.Index(s, "Content-Type: text/html")
if textIdx == -1 || htmlIdx == -1 {
t.Fatal("expected both text/plain and text/html parts")
}
textPart := s[textIdx:htmlIdx]
htmlPart := s[htmlIdx:]
if !strings.Contains(textPart, longMsg) {
t.Error("expected long message in text/plain part")
}
if !strings.Contains(htmlPart, longMsg) {
t.Error("expected long message in text/html part")
}
}
func TestComposeSpecialCharacters(t *testing.T) {
specialMsg := "Café résumé, déjà vu\nLine\twith\ttabs\n€uro sign © 2026"
req := contactform.Request{
Name: "Renée",
Email: "renée@example.com",
Message: specialMsg,
}
b := mustCompose(t, "ñ@c.com", "ö@c.com", req, "SpaForm", "contact")
s := string(b)
if !strings.Contains(s, "Café résumé, déjà vu") {
t.Error("expected accented characters to survive round-trip")
}
if !strings.Contains(s, "€uro sign © 2026") {
t.Error("expected special symbols to survive round-trip")
}
if !strings.Contains(s, "Renée") {
t.Error("expected accented name in body")
}
if !strings.Contains(s, "MIME-Version: 1.0") {
t.Error("expected MIME-Version header")
}
}
func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) {
// Unknown form type should default to the contact template.
req := contactform.Request{
Name: "Fallback",
Email: "fallback@example.com",
Message: "Does this work?",
}
b := mustCompose(t, "a@b.com", "c@b.com", req, "UnknownForm", "nonexistent")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") {
t.Errorf("expected contact fallback subject, got body:\n%s", s)
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part in fallback")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part in fallback")
}
}
func TestComposeAllHeadersPresent(t *testing.T) {
req := contactform.Request{
Name: "Test",
Email: "test@example.com",
Message: "Checking headers.",
}
b := mustCompose(t, "from@x.com", "to@x.com", req, "HeaderForm", "contact")
s := string(b)
required := []string{
"From: from@x.com",
"To: to@x.com",
"Subject:",
"Date:",
"Reply-To: test@example.com",
"MIME-Version: 1.0",
"Content-Type: multipart/alternative",
}
for _, h := range required {
if !strings.Contains(s, h) {
t.Errorf("expected header %q in message", h)
}
}
}
func TestComposeMultipartBoundary(t *testing.T) {
req := contactform.Request{
Name: "Boundary",
Email: "boundary@example.com",
Message: "Boundary test.",
}
b := mustCompose(t, "from@t.com", "to@t.com", req, "BoundForm", "contact")
s := string(b)
// The boundary is random per message; verify structure, not a fixed value.
if !strings.Contains(s, "Content-Type: multipart/alternative; boundary=nuntius-") {
t.Error("expected multipart/alternative with nuntius- prefixed boundary")
}
// Extract the boundary token and verify opening, middle and closing markers.
idx := strings.Index(s, "boundary=nuntius-")
if idx == -1 {
t.Fatal("boundary token not found")
}
boundary := s[idx+len("boundary="):]
if end := strings.IndexByte(boundary, '\r'); end >= 0 {
boundary = boundary[:end]
}
if count := strings.Count(s, "--"+boundary); count < 3 {
t.Errorf("expected at least 3 boundary markers for %q, got %d", boundary, count)
}
if !strings.Contains(s, "--"+boundary+"--") {
t.Error("expected closing boundary marker")
}
}
func TestComposeDeliveredByFooter(t *testing.T) {
req := contactform.Request{
Name: "Footer",
Email: "footer@example.com",
Message: "Footer check.",
}
for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} {
b := mustCompose(t, "f@t.com", "t@t.com", req, "FooterForm", ft)
s := string(b)
if !strings.Contains(s, "Delivered by nuntius") {
t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft)
}
}
}
// A configured subject prefix and brand replace the default nuntius
// wording in both the plain-text and the HTML part.
func TestComposePrefixAndBrand(t *testing.T) {
req := contactform.Request{
Name: "Alice",
Email: "alice@example.com",
Message: "A custom branding check.",
}
form := &config.Form{
Name: "MyForm",
Type: "feedback",
SubjectPrefix: new("web"),
EmailBrand: new("Acme Mail"),
}
b, err := compose("from@example.com", "to@example.com", req, form)
if err != nil {
t.Fatalf("compose: %v", err)
}
s := string(b)
if !strings.Contains(s, "Subject: [web/MyForm] New feedback") {
t.Errorf("configured prefix missing from subject:\n%s", s)
}
if !strings.Contains(s, "Delivered by Acme Mail") {
t.Error("configured brand missing from the plain-text footer")
}
if !strings.Contains(s, "Delivered by <strong style=\"color:#6b7280\">Acme Mail</strong>") {
t.Error("configured brand missing from the HTML footer")
}
// An empty prefix and brand drop the segments entirely.
form.SubjectPrefix = new("")
form.EmailBrand = new("")
b, err = compose("from@example.com", "to@example.com", req, form)
if err != nil {
t.Fatalf("compose: %v", err)
}
s = string(b)
if !strings.Contains(s, "Subject: New feedback\r\n") {
t.Errorf("empty prefix must drop the bracket segment:\n%s", s)
}
if strings.Contains(s, "Delivered by") {
t.Errorf("empty brand must drop the footer:\n%s", s)
}
}
// A service value configured onto a non-contact form surfaces in the
// subject tag, the plain-text body and the HTML body.
func TestComposeServiceOnFeedback(t *testing.T) {
req := contactform.Request{
Name: "Alice",
Email: "alice@example.com",
Service: "bug",
Message: "A service-aware feedback.",
}
form := &config.Form{
Name: "MyForm",
Type: "feedback",
Services: []string{"bug", "idea"},
}
b, err := compose("from@example.com", "to@example.com", req, form)
if err != nil {
t.Fatalf("compose: %v", err)
}
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/MyForm][bug] New feedback") {
t.Errorf("service tag missing from subject:\n%s", s)
}
if !strings.Contains(s, "Service interest: bug") {
t.Error("service line missing from the plain-text body")
}
if !strings.Contains(s, "Service Interest") {
t.Error("service block missing from the HTML body")
}
}
// ---------------------------------------------------------------------------
// SMTP delivery tests
// ---------------------------------------------------------------------------
func selfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
}
// startFakeSMTP runs a minimal SMTP server that supports STARTTLS and AUTH.
// It returns the listen address.
func startFakeSMTP(t *testing.T, cert tls.Certificate) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
fmt.Fprintf(conn, "220 fake ESMTP\r\n")
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
fmt.Fprintf(conn, "250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(line, "STARTTLS"):
fmt.Fprintf(conn, "220 Ready to start TLS\r\n")
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
reader = bufio.NewReader(conn)
case strings.HasPrefix(line, "AUTH"):
fmt.Fprintf(conn, "235 Authentication successful\r\n")
case strings.HasPrefix(line, "MAIL FROM:"), strings.HasPrefix(line, "RCPT TO:"):
fmt.Fprintf(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprintf(conn, "354 End data with <CR><LF>.<CR><LF>\r\n")
for {
dataLine, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(dataLine) == "." {
break
}
}
fmt.Fprintf(conn, "250 OK\r\n")
case strings.HasPrefix(line, "QUIT"):
fmt.Fprintf(conn, "221 Bye\r\n")
return
default:
fmt.Fprintf(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String()
}
// A validated email may still contain characters that would terminate a
// header line; composition must neutralise them defensively in the header
// block. Body content is free-form and delimited by the random boundary.
func TestComposeSanitisesHeaderInjection(t *testing.T) {
req := contactform.Request{
Name: "Eve",
Email: "eve@example.com",
Message: "Hello there, this is fine.",
}
b := mustCompose(t, "from@example.com", "to@example.com", req,
"Form\r\nBcc: victim@example.com", "contact")
s := string(b)
headers := s[:strings.Index(s, "\r\n\r\n")]
for line := range strings.SplitSeq(headers, "\r\n") {
if strings.HasPrefix(line, "Bcc:") {
t.Errorf("injected Bcc header survived composition:\n%s", s)
}
}
if n := strings.Count(headers, "Subject:"); n != 1 {
t.Errorf("expected exactly one Subject header in block %q, got %d", headers, n)
}
}
func TestRandomBoundary(t *testing.T) {
b1, err := randomBoundary()
if err != nil {
t.Fatalf("randomBoundary: %v", err)
}
b2, err := randomBoundary()
if err != nil {
t.Fatalf("randomBoundary: %v", err)
}
if !strings.HasPrefix(b1, "nuntius-") {
t.Errorf("boundary %q missing nuntius- prefix", b1)
}
if b1 == b2 {
t.Error("two consecutive boundaries should differ")
}
}
func TestSendMailDialError(t *testing.T) {
// Connect to a closed port to trigger a dial error quickly.
err := sendMail(config.SMTPConfig{Host: "127.0.0.1", Port: 1}, nil, "a@b.c", []string{"d@e.f"}, []byte("x"), 100*time.Millisecond, nil)
if err == nil {
t.Fatal("expected dial error")
}
}
func TestSendMailWithTLS(t *testing.T) {
cert := selfSignedCert(t)
addr := startFakeSMTP(t, cert)
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("parse port: %v", err)
}
auth := smtp.PlainAuth("", "user", "pass", host)
msg := mustCompose(t, "from@example.com", "to@example.com", contactform.Request{
Name: "Test User",
Email: "test@example.com",
Message: "Hello, this is a test message.",
}, "test", "contact")
err = sendMail(config.SMTPConfig{Host: host, Port: port}, auth, "from@example.com", []string{"to@example.com"}, msg, 5*time.Second, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("sendMail: %v", err)
}
}
func TestFormSenderSend(t *testing.T) {
cert := selfSignedCert(t)
addr := startFakeSMTP(t, cert)
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host: %v", err)
}
port := 0
for _, c := range portStr {
port = port*10 + int(c-'0')
}
form := &config.Form{
Name: "test",
Type: "contact",
From: "from@example.com",
To: "to@example.com",
SMTP: config.SMTPConfig{
Host: host,
Port: port,
User: "user",
Password: "pass",
},
}
s := NewFormSender(form)
s.TLSConfig = &tls.Config{InsecureSkipVerify: true}
err = s.Send(contactform.Request{
Name: "Test User",
Email: "test@example.com",
Message: "Hello, this is a test message.",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
+765
View File
@@ -0,0 +1,765 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"net/url"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
"sourcedock.dev/petrbalvin/nuntius/internal/email"
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
"sourcedock.dev/petrbalvin/nuntius/internal/telegram"
)
const (
// secondsPerHour is the token-bucket refill window. It is part of the
// meaning of rate_limit_per_hour, not an independent policy: the
// bucket refills at perHour/3600 tokens per second.
secondsPerHour = 3600.0
)
// formSender is the interface for delivering a submission.
type formSender interface {
Send(req contactform.Request) error
}
// subscriberStorer persists newsletter subscribers.
// storage.NewsletterStore satisfies this interface.
type subscriberStorer interface {
Append(sub storage.Subscriber) error
}
// duplicateChecker lets the pipeline skip repeat newsletter subscriptions
// for an already recorded address. *storage.DedupeNewsletterStore satisfies
// it; stores without it keep their previous behaviour.
type duplicateChecker interface {
Has(email string) bool
}
// confirmationSender mails the double opt-in link to the subscriber.
// *email.FormSender satisfies it once its SMTP identity is configured.
type confirmationSender interface {
SendConfirmation(to, link string) error
}
// archiveStorer persists submissions for forms that asked for durability.
// *storage.ArchiveStore satisfies it.
type archiveStorer interface {
Append(sub storage.Submission) error
}
// acknowledgementSender mails the submitter a receipt. *email.FormSender
// satisfies it once its SMTP identity is configured.
type acknowledgementSender interface {
SendAcknowledgement(to string) error
}
// telegramNotifier delivers a submission summary to the owner's chat.
// *telegram.Notifier satisfies it.
type telegramNotifier interface {
Notify(formName string, req contactform.Request) error
}
// ContactHandler serves one or more contact forms, dispatched by URL path.
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
// Newsletter-type forms also get a per-form subscriber store.
type ContactHandler struct {
trustProxy bool
dataDir string
// maxBodyBytes caps the request body size to prevent memory
// exhaustion; it comes from server.max_body_bytes.
maxBodyBytes int
// metricsToken guards GET /metrics; empty keeps the endpoint open.
metricsToken string
forms map[string]*config.Form
senders map[string]formSender
rateLimits map[string]*rateLimiter
stores map[string]subscriberStorer
archives map[string]archiveStorer
pendings map[string]*storage.PendingStore
notifiers map[string]telegramNotifier
stats *formStatsRegistry
closeOnce sync.Once
}
// New constructs a ContactHandler that serves all forms defined in cfg.
// The server-wide mechanics (body cap, rate limiter memory bounds) and the
// per-form policies (validation, pending lifetime) all come from cfg.
func New(cfg *config.Config) *ContactHandler {
h := &ContactHandler{
trustProxy: cfg.Server.TrustProxyHeaders,
dataDir: cfg.DataDir,
maxBodyBytes: cfg.Server.BodyLimit(),
metricsToken: cfg.Server.MetricsToken,
forms: make(map[string]*config.Form, len(cfg.Forms)),
senders: make(map[string]formSender, len(cfg.Forms)),
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
stores: make(map[string]subscriberStorer, len(cfg.Forms)),
archives: make(map[string]archiveStorer, len(cfg.Forms)),
pendings: make(map[string]*storage.PendingStore, len(cfg.Forms)),
notifiers: make(map[string]telegramNotifier, len(cfg.Forms)),
}
for i := range cfg.Forms {
f := &cfg.Forms[i]
h.forms[f.Path] = f
h.senders[f.Path] = email.NewFormSender(f)
h.rateLimits[f.Path] = newRateLimiter(limiterSettings{
perHour: f.RateLimit(),
maxBuckets: cfg.Server.MaxRateLimitBuckets(),
cleanupEvery: cfg.Server.RateLimitCleanup(),
maxBucketAge: cfg.Server.RateLimitMaxBucketAge(),
})
if f.Type == "newsletter" {
storePath := filepath.Join(cfg.DataDir, "newsletter-"+f.Name+".jsonl")
h.stores[f.Path] = storage.NewDedupeNewsletterStore(storage.NewNewsletterStore(storePath))
h.pendings[f.Path] = storage.NewPendingStore(
filepath.Join(cfg.DataDir, "newsletter-"+f.Name+"-pending.json"), f.PendingTTL())
} else if f.Archive {
h.archives[f.Path] = storage.NewArchiveStore(
filepath.Join(cfg.DataDir, "archive-"+f.Name+".jsonl"))
}
if f.Telegram != nil {
h.notifiers[f.Path] = telegram.New(
f.Telegram.BotToken, f.Telegram.ChatID, f.Telegram.Timeout())
}
}
// The registry needs the fully populated form map, hence after the loop.
h.stats = newFormStatsRegistry(h.forms)
h.restoreState()
return h
}
// Close stops the background cleanup goroutines for all rate limiters.
// It is idempotent: calling it more than once, from the shutdown path or
// a caller's cleanup, is safe.
func (h *ContactHandler) Close() {
h.closeOnce.Do(func() {
for _, lim := range h.rateLimits {
lim.stop()
}
})
}
// Register mounts one POST + OPTIONS handler per form, plus a single
// GET /health handler and a GET /metrics endpoint. Newsletter forms also
// get a GET <path>/confirm endpoint redeeming their opt-in tokens.
func (h *ContactHandler) Register(mux *http.ServeMux) {
for path := range h.forms {
mux.HandleFunc("POST "+path, h.makeHandler(path))
mux.HandleFunc("OPTIONS "+path, h.makeHandler(path))
if h.pendings[path] != nil {
mux.HandleFunc("GET "+path+"/confirm", h.makeConfirmHandler(path))
}
}
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /metrics", h.Metrics)
}
// makeHandler returns the per-form HTTP handler.
func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
form := h.forms[path]
sender := h.senders[path]
limiter := h.rateLimits[path]
store := h.stores[path] // nil for non-newsletter forms
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// CORS preflight.
if r.Method == http.MethodOptions {
if formAllowed(form, origin) {
writeCORS(w, origin, form.AllowedOrigins)
}
w.WriteHeader(http.StatusNoContent)
return
}
// CORS on actual request.
if formAllowed(form, origin) {
writeCORS(w, origin, form.AllowedOrigins)
} else if origin != "" {
h.bump(path, metricOriginBlocked)
respondError(w, http.StatusForbidden, "origin_not_allowed", "")
return
}
h.bump(path, metricReceived)
// Rate limit. An explicit rate_limit_per_hour = 0 disables it.
if form.RateLimit() > 0 {
ip := ClientIP(r, h.trustProxy)
if !limiter.allow(ip) {
h.bump(path, metricRateLimited)
respondError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests, please try again later.")
return
}
}
// Parse the body. A plain HTML form post speaks urlencoded or
// multipart and carries the same fields as the JSON contract
// under fixed names; every other content type speaks JSON. The
// size cap applies to all shapes alike. The honeypot check is
// bound here as well, because the two shapes carry it
// differently: a field on the form, a key in the raw JSON.
r.Body = http.MaxBytesReader(w, r.Body, int64(h.maxBodyBytes))
var req contactform.Request
mediaType := mediaTypeOf(r.Header.Get("Content-Type"))
honeypotHit := func() bool { return false }
switch mediaType {
case "application/x-www-form-urlencoded", "multipart/form-data":
// 1 MiB of in-memory multipart is plenty: only the value
// parts are read, file parts are ignored, and the body cap
// bounds the whole request anyway.
if mediaType == "multipart/form-data" {
if err := r.ParseMultipartForm(1 << 20); err != nil {
h.parseFailed(w, path, err)
return
}
} else if err := r.ParseForm(); err != nil {
h.parseFailed(w, path, err)
return
}
req = requestFromForm(r.PostForm)
if hp := form.Honeypot(); hp != "" {
honeypotHit = func() bool { return r.PostForm.Get(hp) != "" }
}
default:
body, err := io.ReadAll(r.Body)
if err != nil {
h.parseFailed(w, path, err)
return
}
if err := json.Unmarshal(body, &req); err != nil {
h.bump(path, metricInvalidBody)
respondError(w, http.StatusBadRequest, "invalid_body", "Could not parse request body.")
return
}
if hp := form.Honeypot(); hp != "" {
honeypotHit = func() bool {
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return false
}
v, ok := raw[hp]
return ok && v != nil && v != ""
}
}
}
// Honeypot: silently accept but never send. An empty field name
// disables the check for this form.
if honeypotHit() {
h.bump(path, metricHoneypotBlocked)
slog.Info("honeypot triggered, dropping silently",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondSuccess(w, r, form.RedirectURL)
return
}
// Validate against the form's configured policy.
if errs := contactform.Validate(&req, form.Policy()); len(errs) > 0 {
h.bump(path, metricValidationFailed)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: "validation",
Details: errs,
}); err != nil {
slog.Error("failed to encode json response", "err", err)
}
return
}
// Skip repeat newsletter subscriptions for an address that is
// already recorded: no second mail, no duplicate log line. The
// caller sees the same success response as first-timers.
if dup, ok := store.(duplicateChecker); ok && dup.Has(req.Email) {
h.bump(path, metricDuplicateSignup)
slog.Info("duplicate newsletter signup suppressed",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondSuccess(w, r, form.RedirectURL)
return
}
// Newsletter forms run the double opt-in flow: record a pending
// subscription and mail the subscriber a confirmation link. The
// owner is notified only once the link is redeemed, so bots that
// fill the form cannot flood the inbox.
if form.Type == "newsletter" {
rawToken, err := storage.RandomToken()
if err != nil {
h.bump(path, metricSendFailed)
slog.Error("token generation failed",
"err", err, "form", form.Name, "path", path)
respondError(w, http.StatusInternalServerError, "send_failed", "Could not start the subscription.")
return
}
pend := storage.PendingSubscription{
Email: req.Email,
IP: ClientIP(r, h.trustProxy),
}
pending := h.pendings[path]
if pending == nil {
// unreachable via New(): production always builds one
slog.Error("newsletter form without a pending store", "path", path)
respondError(w, http.StatusInternalServerError, "storage_failed", "Could not start the subscription.")
return
}
if err := pending.Issue(rawToken, pend); err != nil {
h.bump(path, metricPersistFailed)
slog.Error("pending subscription store failed",
"err", err, "form", form.Name, "path", path)
respondError(w, http.StatusInternalServerError, "storage_failed", "Could not start the subscription.")
return
}
link := h.confirmLink(r, path, rawToken)
var sendErr error
if cs, ok := sender.(confirmationSender); ok {
sendErr = cs.SendConfirmation(req.Email, link)
} else {
sendErr = sender.Send(req)
}
if sendErr != nil {
h.bump(path, metricSendFailed)
slog.Error("confirmation mail failed",
"err", sendErr, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send the confirmation email.")
return
}
h.bump(path, metricConfirmationSent)
slog.Info("confirmation mail sent",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondSuccess(w, r, form.RedirectURL)
return
}
// Archive before sending: the point of the log is that a failed
// SMTP round-trip loses nothing. A failed append fails the
// request without sending, so a retry cannot split the mail
// from its record.
if archive := h.archives[path]; archive != nil {
if err := archive.Append(storage.Submission{
Form: form.Name,
Name: req.Name,
Email: req.Email,
Service: req.Service,
Message: req.Message,
IP: ClientIP(r, h.trustProxy),
}); err != nil {
h.bump(path, metricPersistFailed)
slog.Error("submission archive append failed",
"err", err, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondError(w, http.StatusInternalServerError, "storage_failed",
"Could not record submission.")
return
}
}
// Deliver. The owner mail is the record and the Telegram
// notification the bell: the submission counts as delivered
// when either channel gets through, and only when both fail
// (or no bell is configured) does the caller see an error.
sendErr := sender.Send(req)
if sendErr != nil {
h.bump(path, metricSendFailed)
slog.Error("send failed",
"err", sendErr, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
}
delivered := sendErr == nil
if notifier := h.notifiers[path]; notifier != nil {
if err := notifier.Notify(form.Name, req); err != nil {
h.bump(path, metricTelegramFailed)
slog.Error("telegram notification failed",
"err", err, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
} else {
delivered = true
if sendErr != nil {
slog.Warn("telegram delivered after the mail failed",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
}
}
}
if !delivered {
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send email.")
return
}
if sendErr == nil {
h.bump(path, metricSent)
slog.Info("message sent",
"form", form.Name, "path", path,
"service", req.Service, "ip", ClientIP(r, h.trustProxy),
)
}
// The optional receipt to the submitter is best-effort: the
// submission is delivered, so a failed acknowledgement must
// not turn an accepted submission into an error.
if form.AutoReply {
if as, ok := sender.(acknowledgementSender); ok {
if err := as.SendAcknowledgement(req.Email); err != nil {
h.bump(path, metricAutoReplyFailed)
slog.Warn("acknowledgement mail failed",
"err", err, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
}
}
}
respondSuccess(w, r, form.RedirectURL)
}
}
// confirmLink builds the absolute opt-in URL for a token. The scheme is
// https whenever a trusted proxy reports X-Forwarded-Proto=https, matching
// the ClientIP trust model.
func (h *ContactHandler) confirmLink(r *http.Request, path, rawToken string) string {
scheme := "http"
if h.trustProxy && r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s%s/confirm?token=%s", scheme, r.Host, path, rawToken)
}
// makeConfirmHandler redeems a double opt-in token: the pending entry moves
// into the confirmed subscriber log and the owner is notified best-effort.
// The response is HTML because humans open these links in browsers.
func (h *ContactHandler) makeConfirmHandler(path string) http.HandlerFunc {
form := h.forms[path]
sender := h.senders[path]
store := h.stores[path]
pending := h.pendings[path]
return func(w http.ResponseWriter, r *http.Request) {
writePage := func(code int, title, detail string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
fmt.Fprintf(w, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"+
"<title>nuntius</title></head><body style=\"font-family:sans-serif;text-align:center;padding-top:3rem\">"+
"<h1>%s</h1><p>%s</p></body></html>", title, detail)
}
rawToken := r.URL.Query().Get("token")
sub, ok := pending.Peek(rawToken)
if !ok {
h.bump(path, metricConfirmFailed)
writePage(http.StatusGone, "Link expired",
"This confirmation link is invalid or has expired. Please sign up again.")
return
}
// The main record lands before the pending entry is dropped, so a
// storage failure keeps the token redeemable and nothing is lost.
alreadyRecorded := false
if dup, ok := store.(duplicateChecker); ok && dup.Has(sub.Email) {
alreadyRecorded = true
}
if !alreadyRecorded {
if err := store.Append(storage.Subscriber{
Email: sub.Email,
IP: sub.IP,
Form: form.Name,
}); err != nil {
h.bump(path, metricPersistFailed)
slog.Error("confirmed subscription append failed",
"err", err, "form", form.Name, "path", path)
writePage(http.StatusInternalServerError, "Almost there",
"The confirmation could not be saved. Please try the link again shortly.")
return
}
}
pending.Consume(rawToken)
h.bump(path, metricConfirmed)
slog.Info("newsletter subscription confirmed",
"form", form.Name, "path", path)
// Owner notification is best-effort and must not affect the
// subscriber's result.
if err := sender.Send(contactform.Request{
Name: "(nuntius)",
Email: sub.Email,
Message: "The address above confirmed its newsletter subscription.",
}); err != nil {
h.bump(path, metricSendFailed)
slog.Error("owner notification failed",
"err", err, "form", form.Name, "path", path)
}
writePage(http.StatusOK, "Subscription confirmed",
"The address "+sub.Email+" is now subscribed to \""+form.Name+"\".")
}
}
// Health handles GET /health.
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"forms": len(h.forms),
}); err != nil {
slog.Error("failed to encode health response", "err", err)
}
}
// --- internals ---
func formAllowed(form *config.Form, origin string) bool {
if origin == "" {
return true
}
return slices.Contains(form.AllowedOrigins, origin)
}
func writeCORS(w http.ResponseWriter, origin string, allowed []string) {
// Only echo the origin back if it is in the allowlist.
if slices.Contains(allowed, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
return
}
}
// rateLimiter is a per-IP token bucket. Its memory mechanics (the cap on
// distinct buckets, the cleanup tick, the age at which an idle bucket is
// dropped) come from the server configuration, not from constants here.
type rateLimiter struct {
mu sync.Mutex
perHour int
maxBuckets int
cleanupEvery time.Duration
maxBucketAge time.Duration
buckets map[string]*bucket
stopCh chan struct{}
}
type bucket struct {
tokens float64
last time.Time
}
// limiterSettings carries the configured mechanics of one rate limiter.
type limiterSettings struct {
perHour int
maxBuckets int
cleanupEvery time.Duration
maxBucketAge time.Duration
}
func newRateLimiter(s limiterSettings) *rateLimiter {
r := &rateLimiter{
perHour: s.perHour,
maxBuckets: s.maxBuckets,
cleanupEvery: s.cleanupEvery,
maxBucketAge: s.maxBucketAge,
buckets: make(map[string]*bucket),
stopCh: make(chan struct{}),
}
// Clean up entries older than the configured bucket age on every tick.
r.startCleanup()
return r
}
// startCleanup launches a background goroutine that periodically removes
// expired bucket entries to prevent unbounded memory growth.
func (r *rateLimiter) startCleanup() {
go func() {
ticker := time.NewTicker(r.cleanupEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.cleanup(r.maxBucketAge)
case <-r.stopCh:
return
}
}
}()
}
// stop terminates the cleanup goroutine.
func (r *rateLimiter) stop() {
close(r.stopCh)
}
// snapshot returns a copy of every live bucket so state can be written to
// disk without holding the lock while encoding.
func (r *rateLimiter) snapshot() map[string]bucket {
r.mu.Lock()
defer r.mu.Unlock()
out := make(map[string]bucket, len(r.buckets))
for ip, b := range r.buckets {
out[ip] = *b
}
return out
}
// restore merges persisted buckets, dropping entries older than the
// configured bucket age and stopping once the map cap is reached. Entries
// newer than the cutoff keep their remaining tokens.
func (r *rateLimiter) restore(entries map[string]bucket, now time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
for ip, b := range entries {
if len(r.buckets) >= r.maxBuckets {
return
}
cutoff := now.Add(-r.maxBucketAge)
if b.last.Before(cutoff) || b.last.After(now) {
continue
}
entry := b
entry.tokens = min(entry.tokens, float64(r.perHour))
r.buckets[ip] = &entry
}
}
func (r *rateLimiter) cleanup(maxAge time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
for ip, b := range r.buckets {
if b.last.Before(cutoff) {
delete(r.buckets, ip)
}
}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
b, ok := r.buckets[ip]
if !ok {
if len(r.buckets) >= r.maxBuckets {
return false
}
b = &bucket{tokens: float64(r.perHour), last: now}
r.buckets[ip] = b
}
rate := float64(r.perHour) / secondsPerHour
elapsed := now.Sub(b.last).Seconds()
b.tokens = min(b.tokens+elapsed*rate, float64(r.perHour))
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(contactform.Response{OK: true}); err != nil {
slog.Error("failed to encode ok response", "err", err)
}
}
// respondSuccess answers an accepted submission. A form with a
// redirect_url speaks browser: 303 See Other to the configured page, so a
// plain HTML form works without JavaScript and a bot hit is
// indistinguishable from a real one. The JSON contract is the default.
func respondSuccess(w http.ResponseWriter, r *http.Request, redirectURL string) {
if redirectURL != "" {
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
respondOK(w)
}
// parseFailed answers an unreadable, oversized or unparsable request body.
// An over-cap body is 413 regardless of the shape; everything else is a
// 400 invalid_body.
func (h *ContactHandler) parseFailed(w http.ResponseWriter, path string, err error) {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
h.bump(path, metricBodyTooLarge)
respondError(w, http.StatusRequestEntityTooLarge, "body_too_large", "Request body too large.")
return
}
h.bump(path, metricInvalidBody)
respondError(w, http.StatusBadRequest, "invalid_body", "Could not parse request body.")
}
// mediaTypeOf extracts the bare media type from a Content-Type header,
// lower-cased and without parameters.
func mediaTypeOf(header string) string {
mt, _, err := mime.ParseMediaType(header)
if err != nil {
return strings.ToLower(strings.TrimSpace(header))
}
return mt
}
// requestFromForm builds the request from posted form fields. The names
// are fixed for the plain HTML shape: name, email, service, message,
// plus the configured honeypot field, which the pipeline reads
// separately. File parts have no counterpart in the contract and are
// ignored.
func requestFromForm(v url.Values) contactform.Request {
return contactform.Request{
Name: v.Get("name"),
Email: v.Get("email"),
Service: v.Get("service"),
Message: v.Get("message"),
}
}
func respondError(w http.ResponseWriter, code int, err, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if encErr := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: err,
Message: msg,
}); encErr != nil {
slog.Error("failed to encode error response", "err", encErr)
}
}
// ClientIP returns the address used for rate limiting and logging.
//
// With trustProxy false only the connection peer address is considered;
// it cannot be forged by the caller. With trustProxy true, headers set by
// a trusted reverse proxy take precedence: the first X-Forwarded-For
// entry, then X-Real-IP. Enable it only when such a proxy sits directly
// in front of nuntius and overwrites those headers rather than appending
// to them.
func ClientIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if before, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(before)
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return xr
}
}
host := r.RemoteAddr
if i := strings.LastIndexByte(host, ':'); i >= 0 {
host = host[:i]
}
return host
}
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
"strings"
"sync"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
// Canonical metric names. The same identifiers serve as counter keys in
// bump() and as JSON field names in GET /metrics responses.
const (
metricReceived = "received"
metricHoneypotBlocked = "honeypot_blocked"
metricRateLimited = "rate_limited"
metricOriginBlocked = "origin_blocked"
metricBodyTooLarge = "body_too_large"
metricInvalidBody = "invalid_body"
metricValidationFailed = "validation_failed"
metricSendFailed = "send_failed"
metricPersistFailed = "persist_failed"
metricDuplicateSignup = "duplicate_signup"
metricConfirmationSent = "confirmation_sent"
metricConfirmed = "confirmed"
metricConfirmFailed = "confirmation_failed"
metricSent = "sent"
metricAutoReplyFailed = "auto_reply_failed"
metricTelegramFailed = "telegram_failed"
)
// metricNames lists every counter in stable order so totals and snapshots
// cannot drift from the struct fields.
var metricNames = []string{
metricReceived,
metricHoneypotBlocked,
metricRateLimited,
metricOriginBlocked,
metricBodyTooLarge,
metricInvalidBody,
metricValidationFailed,
metricSendFailed,
metricPersistFailed,
metricDuplicateSignup,
metricConfirmationSent,
metricConfirmed,
metricConfirmFailed,
metricSent,
metricAutoReplyFailed,
metricTelegramFailed,
}
// FormStats holds lifetime counters for one form. Every field counts
// outcomes of requests routed to that form's endpoints.
type FormStats struct {
Received int64 `json:"received"`
HoneypotBlocked int64 `json:"honeypot_blocked"`
RateLimited int64 `json:"rate_limited"`
OriginBlocked int64 `json:"origin_blocked"`
BodyTooLarge int64 `json:"body_too_large"`
InvalidBody int64 `json:"invalid_body"`
ValidationFailed int64 `json:"validation_failed"`
SendFailed int64 `json:"send_failed"`
PersistFailed int64 `json:"persist_failed"`
DuplicateSignup int64 `json:"duplicate_signup"`
ConfirmationSent int64 `json:"confirmation_sent"`
Confirmed int64 `json:"confirmed"`
ConfirmFailed int64 `json:"confirmation_failed"`
Sent int64 `json:"sent"`
AutoReplyFailed int64 `json:"auto_reply_failed"`
TelegramFailed int64 `json:"telegram_failed"`
}
// incByIndex increments the counter at metricNames[i]; indexes outside the
// known set are ignored.
func (f *FormStats) incByIndex(i int) {
switch i {
case 0:
f.Received++
case 1:
f.HoneypotBlocked++
case 2:
f.RateLimited++
case 3:
f.OriginBlocked++
case 4:
f.BodyTooLarge++
case 5:
f.InvalidBody++
case 6:
f.ValidationFailed++
case 7:
f.SendFailed++
case 8:
f.PersistFailed++
case 9:
f.DuplicateSignup++
case 10:
f.ConfirmationSent++
case 11:
f.Confirmed++
case 12:
f.ConfirmFailed++
case 13:
f.Sent++
case 14:
f.AutoReplyFailed++
case 15:
f.TelegramFailed++
}
}
// add sums another snapshot into f.
func (f *FormStats) add(other FormStats) {
for i := range metricNames {
switch i {
case 0:
f.Received += other.Received
case 1:
f.HoneypotBlocked += other.HoneypotBlocked
case 2:
f.RateLimited += other.RateLimited
case 3:
f.OriginBlocked += other.OriginBlocked
case 4:
f.BodyTooLarge += other.BodyTooLarge
case 5:
f.InvalidBody += other.InvalidBody
case 6:
f.ValidationFailed += other.ValidationFailed
case 7:
f.SendFailed += other.SendFailed
case 8:
f.PersistFailed += other.PersistFailed
case 9:
f.DuplicateSignup += other.DuplicateSignup
case 10:
f.ConfirmationSent += other.ConfirmationSent
case 11:
f.Confirmed += other.Confirmed
case 12:
f.ConfirmFailed += other.ConfirmFailed
case 13:
f.Sent += other.Sent
case 14:
f.AutoReplyFailed += other.AutoReplyFailed
case 15:
f.TelegramFailed += other.TelegramFailed
}
}
}
// formStatsRegistry guards the per-form counters shared between request
// goroutines and the /metrics endpoint.
type formStatsRegistry struct {
mu sync.Mutex
stats map[string]*FormStats
}
func newFormStatsRegistry(forms map[string]*config.Form) *formStatsRegistry {
r := &formStatsRegistry{stats: make(map[string]*FormStats, len(forms))}
for path := range forms {
r.stats[path] = &FormStats{}
}
return r
}
// bump increments the named counter for a form. Unknown paths or metrics
// are dropped silently so logging can never fail a request.
func (r *formStatsRegistry) bump(path, metric string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
fs, ok := r.stats[path]
if !ok {
return
}
for i, name := range metricNames {
if name == metric {
fs.incByIndex(i)
return
}
}
}
// snapshot returns a copy of every form's counters plus their sum.
func (r *formStatsRegistry) snapshot() (map[string]FormStats, FormStats) {
out := make(map[string]FormStats, len(r.stats))
var total FormStats
r.mu.Lock()
defer r.mu.Unlock()
for path, fs := range r.stats {
out[path] = *fs
total.add(*fs)
}
return out, total
}
// Metrics serves GET /metrics: lifetime counters per form and combined
// totals, as JSON. The endpoint is exempt from rate limiting and sends no
// CORS headers, so third-party pages cannot read submission volumes. With
// server.metrics_token set it requires that token as a bearer credential.
func (h *ContactHandler) Metrics(w http.ResponseWriter, r *http.Request) {
if h.metricsToken != "" {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(h.metricsToken)) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="nuntius metrics"`)
respondError(w, http.StatusUnauthorized, "unauthorized", "A valid bearer token is required.")
return
}
}
if h.stats == nil {
w.WriteHeader(http.StatusNotFound)
return
}
formSnapshots, total := h.stats.snapshot()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(map[string]any{
"totals": total,
"forms": formSnapshots,
}); err != nil {
slog.Error("failed to encode metrics response", "err", err)
}
}
// bump records one occurrence of metric for the given form path.
func (h *ContactHandler) bump(path, metric string) {
h.stats.bump(path, metric)
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"time"
)
// rateLimitSnapshot is the on-disk shape of persisted limiter buckets.
// Schema lets future formats be detected instead of misparsed.
type rateLimitSnapshot struct {
Schema int `json:"schema"`
Saved time.Time `json:"saved"`
Forms map[string]map[string]persistedBucket `json:"forms"`
}
// persistedBucket is the exported wire form of an internal bucket.
type persistedBucket struct {
Tokens float64 `json:"tokens"`
Last time.Time `json:"last"`
}
const snapshotSchema = 1
// statePath returns the snapshot file location under the data directory.
func (h *ContactHandler) statePath() string {
return filepath.Join(h.dataDir, "ratelimit-snapshot.json")
}
// PersistState writes every rate-limit bucket to a snapshot file next to
// the newsletter logs. The write is atomic (temp file plus rename) and
// best-effort: a failure is logged, never fatal, because losing buckets
// only resets limits to their startup defaults. Call it during shutdown,
// before Close.
func (h *ContactHandler) PersistState() {
if h.dataDir == "" {
return
}
snap := rateLimitSnapshot{
Schema: snapshotSchema,
Saved: time.Now().UTC(),
Forms: make(map[string]map[string]persistedBucket, len(h.rateLimits)),
}
for path, lim := range h.rateLimits {
live := lim.snapshot()
wire := make(map[string]persistedBucket, len(live))
for ip, b := range live {
wire[ip] = persistedBucket{Tokens: b.tokens, Last: b.last}
}
snap.Forms[path] = wire
}
line, err := json.Marshal(snap)
if err != nil {
slog.Warn("rate limit snapshot marshal failed", "err", err)
return
}
dir := filepath.Dir(h.statePath())
if err := os.MkdirAll(dir, 0o755); err != nil {
slog.Warn("rate limit snapshot mkdir failed", "dir", dir, "err", err)
return
}
tmp := h.statePath() + ".tmp"
if err := os.WriteFile(tmp, line, 0o600); err != nil {
slog.Warn("rate limit snapshot write failed", "err", err)
return
}
if err := os.Rename(tmp, h.statePath()); err != nil {
slog.Warn("rate limit snapshot rename failed", "err", err)
}
}
// restoreState loads the previous snapshot, if any, back into the fresh
// rate limiters. Each limiter drops entries older than its configured
// bucket age; corrupt files are ignored with a warning, and nothing here
// is fatal: a missing or broken snapshot behaves like an empty one.
func (h *ContactHandler) restoreState() {
if h.dataDir == "" {
return
}
raw, err := os.ReadFile(h.statePath())
if err != nil {
return // no snapshot yet: the common first-start path
}
var snap rateLimitSnapshot
if err := json.Unmarshal(raw, &snap); err != nil {
slog.Warn("ignoring corrupt rate limit snapshot", "path", h.statePath(), "err", err)
return
}
if snap.Schema != snapshotSchema {
slog.Warn("ignoring rate limit snapshot with unknown schema",
"path", h.statePath(), "schema", snap.Schema)
return
}
now := time.Now()
restoredForms := 0
for path, wire := range snap.Forms {
lim, ok := h.rateLimits[path]
if !ok {
continue // form removed from config since the snapshot
}
entries := make(map[string]bucket, len(wire))
for ip, pb := range wire {
entries[ip] = bucket{tokens: pb.Tokens, last: pb.Last}
}
lim.restore(entries, now)
restoredForms++
}
slog.Info("restored rate limit buckets", "file", h.statePath(), "forms", restoredForms)
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package storage provides file-based persistence for nuntius.
//
// Today it contains only the newsletter subscriber log. It is designed
// to be zero-dependency (stdlib only) and crash-safe: each Append writes
// a single JSON line to an append-only file, so partial writes do not
// corrupt earlier records.
package storage
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Subscriber is a single newsletter signup, one JSON object per line.
type Subscriber struct {
Email string `json:"email"`
IP string `json:"ip,omitempty"`
Form string `json:"form"`
CreatedAt time.Time `json:"created_at"`
}
// NewsletterStore is a file-based append-only log of subscribers.
// All methods are safe for concurrent use.
type NewsletterStore struct {
path string
mu sync.Mutex
}
// NewNewsletterStore returns a store that appends to path.
// The file and its parent directory are created lazily on first Append.
func NewNewsletterStore(path string) *NewsletterStore {
return &NewsletterStore{path: path}
}
// Path returns the file path this store writes to.
func (s *NewsletterStore) Path() string {
return s.path
}
// Append writes sub as a single JSON line to the log.
// The file and parent directory are created on first call.
func (s *NewsletterStore) Append(sub Subscriber) error {
if sub.CreatedAt.IsZero() {
sub.CreatedAt = time.Now().UTC()
}
line, err := json.Marshal(sub)
if err != nil {
return fmt.Errorf("marshal subscriber: %w", err)
}
line = append(line, '\n')
s.mu.Lock()
defer s.mu.Unlock()
return appendLine(s.path, line)
}
// Count returns the number of valid subscriber lines in the log.
// Malformed lines are silently skipped so a partial write does not
// brick the entire file.
func (s *NewsletterStore) Count() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.countLocked()
}
func (s *NewsletterStore) countLocked() (int, error) {
f, err := os.Open(s.path)
if os.IsNotExist(err) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
n := 0
scanner := bufio.NewScanner(f)
// Allow up to 1 MB per line in case a single record balloons.
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
// Skip malformed lines rather than failing the whole count.
continue
}
if sub.Email != "" {
n++
}
}
if err := scanner.Err(); err != nil {
return n, fmt.Errorf("scan %s: %w", s.path, err)
}
return n, nil
}
// List returns all valid subscribers in insertion order.
// Use with care on large files; it reads the whole log into memory.
func (s *NewsletterStore) List() ([]Subscriber, error) {
s.mu.Lock()
defer s.mu.Unlock()
f, err := os.Open(s.path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
var out []Subscriber
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
continue
}
if sub.Email != "" {
out = append(out, sub)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %s: %w", s.path, err)
}
return out, nil
}
// DedupeNewsletterStore wraps a NewsletterStore with an in-memory index of
// recorded addresses so callers can detect a repeat subscription before
// doing any user-visible work. The index is built lazily from the log on
// first use and kept in sync on successful appends.
//
// Addresses are compared case-insensitively. Strictly speaking the local
// part of an address may be case-sensitive, but every major provider treats
// mailbox names that way in practice, and bot submissions exploit
// exact-case variants to multiply signups.
type DedupeNewsletterStore struct {
store *NewsletterStore
mu sync.Mutex
seen map[string]struct{}
once sync.Once
}
// NewDedupeNewsletterStore wraps store.
func NewDedupeNewsletterStore(store *NewsletterStore) *DedupeNewsletterStore {
return &DedupeNewsletterStore{store: store}
}
// Path returns the wrapped store's file path.
func (d *DedupeNewsletterStore) Path() string {
return d.store.Path()
}
// Count returns the number of valid records in the log.
func (d *DedupeNewsletterStore) Count() (int, error) {
return d.store.Count()
}
// List returns all valid subscribers in insertion order.
func (d *DedupeNewsletterStore) List() ([]Subscriber, error) {
return d.store.List()
}
// seenKey normalises an address for comparison.
func seenKey(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// load populates the index once per process lifetime. An unreadable log
// behaves like an empty index: dedupe then only covers this run, which
// matches how the process would behave after a hard crash anyway.
func (d *DedupeNewsletterStore) load() {
d.once.Do(func() {
d.seen = make(map[string]struct{})
subs, err := d.store.List()
if err != nil {
return
}
for _, sub := range subs {
d.seen[seenKey(sub.Email)] = struct{}{}
}
})
}
// Has reports whether the address was already recorded.
func (d *DedupeNewsletterStore) Has(email string) bool {
d.load()
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.seen[seenKey(email)]
return ok
}
// Remember records an address after its append succeeded.
func (d *DedupeNewsletterStore) Remember(sub Subscriber) {
d.load()
d.mu.Lock()
defer d.mu.Unlock()
d.seen[seenKey(sub.Email)] = struct{}{}
}
// Append persists sub and records the address on success.
func (d *DedupeNewsletterStore) Append(sub Subscriber) error {
if err := d.store.Append(sub); err != nil {
return err
}
d.Remember(sub)
return nil
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewsletterStore_AppendCreatesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs", "subscribers.jsonl")
s := NewNewsletterStore(path)
if err := s.Append(Subscriber{Email: "a@example.com", Form: "newsletter"}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file to exist, got %v", err)
}
}
func TestNewsletterStore_AppendAndCount(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
for i, e := range []string{"a@x.com", "b@x.com", "c@x.com"} {
if err := s.Append(Subscriber{Email: e, Form: "newsletter"}); err != nil {
t.Fatalf("Append #%d: %v", i, err)
}
}
n, err := s.Count()
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 3 {
t.Errorf("expected 3 subscribers, got %d", n)
}
}
func TestNewsletterStore_AppendIsOneLinePerRecord(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
for _, e := range []string{"a@x.com", "b@x.com"} {
if err := s.Append(Subscriber{Email: e, Form: "n"}); err != nil {
t.Fatalf("Append: %v", err)
}
}
// Each line should be a self-contained JSON object.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
count := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
t.Fatalf("Unmarshal line: %v", err)
}
count++
}
if err := scanner.Err(); err != nil {
t.Fatalf("scanner.Err: %v", err)
}
if count != 2 {
t.Errorf("expected 2 records, decoded %d", count)
}
}
func TestNewsletterStore_CountOnMissingFileIsZero(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.jsonl")
s := NewNewsletterStore(path)
n, err := s.Count()
if err != nil {
t.Fatalf("Count on missing file: %v", err)
}
if n != 0 {
t.Errorf("expected 0 on missing file, got %d", n)
}
}
func TestNewsletterStore_ListReturnsInsertionOrder(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
want := []string{"a@x.com", "b@x.com", "c@x.com"}
for _, e := range want {
_ = s.Append(Subscriber{Email: e, Form: "n"})
}
subs, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(subs) != len(want) {
t.Fatalf("expected %d, got %d", len(want), len(subs))
}
for i, sub := range subs {
if sub.Email != want[i] {
t.Errorf("position %d: want %q, got %q", i, want[i], sub.Email)
}
}
}
func TestNewsletterStore_SkipsMalformedLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
// Write a mix of valid and garbage lines.
content := `{"email":"good1@x.com","form":"n","created_at":"2026-01-01T00:00:00Z"}
this is not valid json
{"email":"good2@x.com","form":"n","created_at":"2026-01-02T00:00:00Z"}
{not even close to json
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewNewsletterStore(path)
n, err := s.Count()
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 2 {
t.Errorf("expected 2 valid records (skipping malformed), got %d", n)
}
}
func TestNewsletterStore_PathReturnsConfiguredPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "subs.jsonl")
s := NewNewsletterStore(path)
if got := s.Path(); got != path {
t.Errorf("Path: want %q, got %q", path, got)
}
}
// The dedupe wrapper records delivered addresses and reports repeats
// case-insensitively; underlying Append semantics stay untouched.
func TestDedupeNewsletterStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "subs.jsonl")
store := NewNewsletterStore(path)
dedupe := NewDedupeNewsletterStore(store)
if dedupe.Has("Jane@Example.com") {
t.Fatal("fresh log should not contain the address")
}
if err := dedupe.Append(Subscriber{Email: "jane@example.com"}); err != nil {
t.Fatalf("append: %v", err)
}
if !dedupe.Has(" Jane@Example.COM ") {
t.Error("address should be found after append, ignoring case and spaces")
}
n, err := store.Count()
if err != nil || n != 1 {
t.Fatalf("log lines = %d (err %v), want exactly one record", n, err)
}
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Double opt-in support: addresses wait in a pending file until their
// confirmation token is redeemed, then move into the main subscriber log.
package storage
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// PendingTTL is how long an unconfirmed subscription stays actionable.
// After that it is purged and the address must be signed up again.
const PendingTTL = 72 * time.Hour
// RandomToken returns a 32-byte cryptographically random value hex-encoded
// for use inside URLs. Only its SHA-256 hash is persisted; the raw value
// lives exclusively in the confirmation link.
func RandomToken() (string, error) {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("generate confirmation token: %w", err)
}
return hex.EncodeToString(buf[:]), nil
}
type PendingSubscription struct {
Email string `json:"email"`
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type pendingFile struct {
Schema int `json:"schema"`
Entries map[string]PendingSubscription `json:"entries"`
}
const pendingSchema = 1
// PendingStore keeps unconfirmed newsletter subscriptions keyed by the hash
// of their confirmation token. The whole set is rewritten atomically on
// every change: pending files stay tiny (only signups within the TTL), so
// the append-only trick is not needed here.
type PendingStore struct {
mu sync.Mutex
path string
ttl time.Duration
}
// NewPendingStore wraps path with the given entry lifetime.
func NewPendingStore(path string, ttl time.Duration) *PendingStore {
if ttl <= 0 {
ttl = PendingTTL
}
return &PendingStore{path: path, ttl: ttl}
}
// Path returns the backing file location.
func (p *PendingStore) Path() string { return p.path }
// TTL returns the entry lifetime the store enforces.
func (p *PendingStore) TTL() time.Duration { return p.ttl }
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// load reads the file, prunes expired entries and persists the pruned set.
// A missing or corrupt file behaves like an empty store.
func (p *PendingStore) load(now time.Time) (map[string]PendingSubscription, error) {
f := pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{}}
raw, err := os.ReadFile(p.path)
switch {
case err == nil:
if err := json.Unmarshal(raw, &f); err != nil || f.Schema != pendingSchema {
return f.Entries, fmt.Errorf("unreadable pending store %s", p.path)
}
case os.IsNotExist(err):
default:
return f.Entries, fmt.Errorf("read pending store %s: %w", p.path, err)
}
dirty := false
for k, e := range f.Entries {
if now.Sub(e.CreatedAt) > p.ttl || e.CreatedAt.After(now.Add(time.Hour)) {
delete(f.Entries, k)
dirty = true
}
}
if dirty {
if werr := p.save(f); werr != nil {
return f.Entries, werr
}
}
return f.Entries, nil
}
func (p *PendingStore) save(f pendingFile) error {
line, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("marshal pending store: %w", err)
}
if err := os.MkdirAll(filepath.Dir(p.path), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(p.path), err)
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, line, 0o600); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
return os.Rename(tmp, p.path)
}
// Issue stores a new pending subscription keyed by the token hash,
// superseding any earlier entry for the same address. A load warning
// (unreadable file) is non-fatal: the new entry is still written.
func (p *PendingStore) Issue(rawToken string, sub PendingSubscription) error {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
for k, e := range entries {
if seenKey(e.Email) == seenKey(sub.Email) && k != hashToken(rawToken) {
delete(entries, k) // one live token per address
}
}
sub.CreatedAt = now.UTC()
entries[hashToken(rawToken)] = sub
return p.save(pendingFile{Schema: pendingSchema, Entries: entries})
}
// Consume redeems a token: a valid, unexpired entry is removed from the
// file and returned. Unknown tokens, already-redeemed tokens and expired
// entries all report false.
func (p *PendingStore) Consume(rawToken string) (PendingSubscription, bool) {
p.mu.Lock()
defer p.mu.Unlock()
key := hashToken(rawToken)
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[key]
if !ok {
return PendingSubscription{}, false
}
delete(entries, key)
_ = p.save(pendingFile{Schema: pendingSchema, Entries: entries})
if now.Sub(sub.CreatedAt) > p.ttl {
return PendingSubscription{}, false
}
return sub, true
}
// Peek returns the subscription behind rawToken without consuming it,
// reporting false when the token is unknown or expired.
func (p *PendingStore) Peek(rawToken string) (PendingSubscription, bool) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[hashToken(rawToken)]
if !ok || now.Sub(sub.CreatedAt) > p.ttl {
return PendingSubscription{}, false
}
return sub, true
}
// HasToken reports whether raw is still a live, redeemable token.
func (p *PendingStore) HasToken(rawToken string) bool {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[hashToken(rawToken)]
return ok && now.Sub(sub.CreatedAt) <= p.ttl
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPendingStoreRoundTrip(t *testing.T) {
p := NewPendingStore(filepath.Join(t.TempDir(), "pending.json"), 72*time.Hour)
if _, ok := p.Consume("unknown-token"); ok {
t.Fatal("unknown token must not consume")
}
if err := p.Issue("token-a", PendingSubscription{Email: "Jane@Example.COM", IP: "203.0.113.9"}); err != nil {
t.Fatalf("issue: %v", err)
}
sub, ok := p.Consume("token-a")
if !ok {
t.Fatal("valid token must consume")
}
if sub.Email != "Jane@Example.COM" || sub.IP != "203.0.113.9" {
t.Errorf("consumed entry = %+v", sub)
}
if _, ok := p.Consume("token-a"); ok {
t.Error("token must be single-use")
}
}
// An expired entry is never returned and is purged from the file.
func TestPendingStoreExpiry(t *testing.T) {
path := filepath.Join(t.TempDir(), "pending.json")
stale, _ := json.Marshal(pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{
hashToken("old"): {Email: "a@b.c", CreatedAt: time.Now().Add(-96 * time.Hour)},
}})
if err := os.WriteFile(path, stale, 0o600); err != nil {
t.Fatal(err)
}
if _, ok := NewPendingStore(path, 72*time.Hour).Consume("old"); ok {
t.Fatal("expired token must not consume")
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), `"a@b.c"`) {
t.Error("expired entry was not purged from the file")
}
}
// Issue replaces the previous live token for the same address.
func TestPendingStoreRotation(t *testing.T) {
path := filepath.Join(t.TempDir(), "pending.json")
p := NewPendingStore(path, 72*time.Hour)
if err := p.Issue("first", PendingSubscription{Email: "jane@example.com"}); err != nil {
t.Fatal(err)
}
if err := p.Issue("second", PendingSubscription{Email: "jane@example.com"}); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(path)
if strings.Count(string(raw), "@") != 1 {
t.Errorf("expected a single live entry after rotation, got %s", raw)
}
if _, ok := p.Consume("first"); ok {
t.Error("superseded token must no longer work")
}
if _, ok := p.Consume("second"); !ok {
t.Error("current token must still work")
}
}
func TestRandomTokenIsHexAndUnique(t *testing.T) {
a, err := RandomToken()
if err != nil {
t.Fatal(err)
}
b, err := RandomToken()
if err != nil {
t.Fatal(err)
}
if len(a) != 64 || a == b {
t.Errorf("tokens = %q %q, want unique 64-char hex", a, b)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Submission is one archived form submission, one JSON object per line.
// Newsletter forms do not write here: they persist through the double
// opt-in subscriber log instead.
type Submission struct {
Form string `json:"form"`
Name string `json:"name,omitempty"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ArchiveStore is a file-based append-only log of submissions. All methods
// are safe for concurrent use. The file and its parent directory are
// created lazily on first Append, so a form that archives nothing writes
// nothing.
type ArchiveStore struct {
path string
mu sync.Mutex
}
// NewArchiveStore returns a store that appends to path.
func NewArchiveStore(path string) *ArchiveStore {
return &ArchiveStore{path: path}
}
// Path returns the file path this store writes to.
func (s *ArchiveStore) Path() string {
return s.path
}
// Append writes sub as a single JSON line to the log.
func (s *ArchiveStore) Append(sub Submission) error {
if sub.CreatedAt.IsZero() {
sub.CreatedAt = time.Now().UTC()
}
line, err := json.Marshal(sub)
if err != nil {
return fmt.Errorf("marshal submission: %w", err)
}
line = append(line, '\n')
s.mu.Lock()
defer s.mu.Unlock()
return appendLine(s.path, line)
}
// appendLine writes one marshalled JSON line to an append-only log,
// creating the file and its parent directory on first use. The caller
// holds the store's lock.
func appendLine(path string, line []byte) (err error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
if _, err := f.Write(line); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestArchiveStoreRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "archive-test.jsonl")
store := NewArchiveStore(path)
first := Submission{
Form: "contact",
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss an engagement.",
IP: "192.0.2.1",
}
if err := store.Append(first); err != nil {
t.Fatalf("append: %v", err)
}
second := Submission{Form: "feedback", Email: "other@example.com", Message: "Short"}
if err := store.Append(second); err != nil {
t.Fatalf("append: %v", err)
}
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
var got []Submission
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var sub Submission
if err := json.Unmarshal(scanner.Bytes(), &sub); err != nil {
t.Fatalf("unmarshal line: %v", err)
}
got = append(got, sub)
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan: %v", err)
}
if len(got) != 2 {
t.Fatalf("lines = %d, want 2", len(got))
}
if got[0].CreatedAt.IsZero() {
t.Errorf("first created_at is zero, want a stamped time")
}
first.CreatedAt = got[0].CreatedAt
if got[0] != first {
t.Errorf("first = %+v, want %+v", got[0], first)
}
if got[1].CreatedAt.IsZero() {
t.Errorf("second created_at is zero, want a stamped time")
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package telegram delivers form submission summaries to a Telegram chat
// through the Bot API. One-way by design: nuntius posts a message and
// never reads anything back, so there are no conversations, commands or
// callbacks here, and no bot platform either.
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// Notifier posts submission summaries to one chat through one bot.
type Notifier struct {
botToken string
chatID string
timeout time.Duration
// apiURL is the Bot API origin; tests point it at a fake server.
apiURL string
}
// New returns a Notifier posting as the bot into the chat.
func New(botToken, chatID string, timeout time.Duration) *Notifier {
return &Notifier{
botToken: botToken,
chatID: chatID,
timeout: timeout,
apiURL: "https://api.telegram.org",
}
}
// Notify posts one submission summary to the chat. Plain text on purpose:
// a parse mode would turn submitted content into markup that has to be
// escaped, and plain text cannot be injected.
func (n *Notifier) Notify(formName string, req contactform.Request) error {
body, err := json.Marshal(map[string]any{
"chat_id": n.chatID,
"text": summary(formName, req),
"disable_web_page_preview": true,
})
if err != nil {
return fmt.Errorf("marshal telegram payload: %w", err)
}
httpReq, err := http.NewRequest(http.MethodPost,
n.apiURL+"/bot"+n.botToken+"/sendMessage", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build telegram request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: n.timeout}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("telegram call: %s", redact(err.Error(), n.botToken))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram sendMessage: HTTP %d", resp.StatusCode)
}
// The API answers {"ok":false,"description":...} on refusal, so the
// body decides, not the status code alone.
var payload struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return fmt.Errorf("telegram response: %w", err)
}
if !payload.OK {
return fmt.Errorf("telegram sendMessage: %s", payload.Description)
}
return nil
}
// summary renders the plain-text message posted to the chat.
func summary(formName string, req contactform.Request) string {
var b strings.Builder
fmt.Fprintf(&b, "New message on %s\n", formName)
fmt.Fprintf(&b, "From: %s <%s>\n", req.Name, req.Email)
if req.Service != "" {
fmt.Fprintf(&b, "Service interest: %s\n", req.Service)
}
b.WriteString("\n")
b.WriteString(req.Message)
b.WriteString("\n")
return b.String()
}
// redact keeps the bot token out of error text: an URL error carries the
// full request URL, token included, and credentials never reach a log.
func redact(s, secret string) string {
if secret == "" {
return s
}
return strings.ReplaceAll(s, secret, "[redacted]")
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package telegram
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
var testRequest = contactform.Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss an engagement.",
}
// apiCall carries what the fake API saw; the channel establishes the
// happens-before edge the HTTP response alone does not.
type apiCall struct {
req *http.Request
body string
}
// fakeAPI answers with the given payload and records the request; the
// returned function waits for the call and yields it.
func fakeAPI(t *testing.T, status int, payload string) (*Notifier, func() apiCall) {
t.Helper()
calls := make(chan apiCall, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
calls <- apiCall{req: r, body: string(b)}
w.WriteHeader(status)
_, _ = w.Write([]byte(payload))
}))
t.Cleanup(srv.Close)
n := New("123:secret", "-100200300", 5*time.Second)
n.apiURL = srv.URL
return n, func() apiCall {
select {
case c := <-calls:
return c
case <-time.After(5 * time.Second):
t.Fatal("the fake API never saw the request")
return apiCall{}
}
}
}
func TestNotifyPostsTheSummary(t *testing.T) {
n, call := fakeAPI(t, http.StatusOK, `{"ok":true}`)
if err := n.Notify("contact", testRequest); err != nil {
t.Fatalf("notify: %v", err)
}
got := call()
if p := got.req.URL.Path; p != "/bot123:secret/sendMessage" {
t.Errorf("path = %q, want the bot token and sendMessage", p)
}
var payload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(got.body), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.ChatID != "-100200300" {
t.Errorf("chat_id = %q, want the configured chat", payload.ChatID)
}
for _, want := range []string{
"New message on contact",
"From: Jane Doe <jane@example.com>",
"Service interest: architecture",
"Hello, I would like to discuss an engagement.",
} {
if !strings.Contains(payload.Text, want) {
t.Errorf("summary missing %q", want)
}
}
}
func TestNotifyReportsAPIRefusal(t *testing.T) {
// The API refuses with a non-200 status and an ok:false body; both
// shapes must surface as an error.
n, _ := fakeAPI(t, http.StatusUnauthorized, `{"ok":false,"description":"Unauthorized"}`)
if err := n.Notify("contact", testRequest); err == nil {
t.Errorf("notify over HTTP 401 succeeded, want an error")
}
n, _ = fakeAPI(t, http.StatusOK, `{"ok":false,"description":"chat not found"}`)
if err := n.Notify("contact", testRequest); err == nil {
t.Errorf("notify over an ok:false body succeeded, want an error")
}
}
// TestNotifyRedactsTheToken pins the credential rule: the token never
// reaches an error string, and error text is what the log carries.
func TestNotifyRedactsTheToken(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A connection reset surfaces as an URL error carrying the full
// request URL, token included.
srv.CloseClientConnections()
}))
t.Cleanup(srv.Close)
n := New("123:secret", "-100200300", 5*time.Second)
n.apiURL = srv.URL
err := n.Notify("contact", testRequest)
if err == nil {
t.Fatalf("notify over a killed connection succeeded, want an error")
}
if strings.Contains(err.Error(), "123:secret") {
t.Errorf("error text carries the bot token: %q", err.Error())
}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package version exposes the nuntius release identity.
package version
import "runtime/debug"
// Name is the program name reported by `--version` and in log lines.
const Name = "nuntius"
// Version reports the release the toolchain recorded for this build: the
// tag when the checkout is at one, a pseudo-version naming the commit
// otherwise, and (devel) outside version control. Nothing writes a version
// number, so the recorded value cannot go stale, and a dirty tree reports
// +dirty honestly.
func Version() string {
bi, ok := debug.ReadBuildInfo()
if !ok || bi.Main.Version == "" {
return "(devel)"
}
return bi.Main.Version
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package version
import "testing"
func TestName(t *testing.T) {
if Name != "nuntius" {
t.Errorf("Name = %q, want %q", Name, "nuntius")
}
}
func TestVersion(t *testing.T) {
// The recorded version is never empty: at a tag it is the tag, elsewhere
// a pseudo-version, and (devel) outside version control.
if Version() == "" {
t.Error("Version must not be empty")
}
}