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
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:
@@ -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
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user