feat: initialize nuntius project with full server implementation

This commit is contained in:
2026-06-20 20:48:09 +02:00
commit 1b700f7975
33 changed files with 4313 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
//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. 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"
"codeberg.org/petrbalvin/interpres"
)
// ValidFormTypes lists the form types nuntius knows how to handle.
// Each form's `type` field is validated against this list in validate().
var ValidFormTypes = map[string]bool{
"contact": true,
"feedback": true,
"newsletter": true,
"generic": true,
}
// 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"`
}
// Form is one contact / feedback / newsletter endpoint.
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"`
}
// SMTPConfig holds SMTP credentials and connection details.
type SMTPConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
User string `toml:"user"`
Password string `toml:"password"`
}
// 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)
}
if err := os.WriteFile(path, defaultConfig, 0644); err != nil {
return nil, fmt.Errorf("write default config to %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.
expanded := os.Expand(string(raw), os.Getenv)
// Step 2: parse TOML, rejecting unknown fields to catch typos early.
var cfg Config
if err := interpres.NewDecoder().DisallowUnknownFields().Decode([]byte(expanded), &cfg); 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 = 8080
}
if c.DataDir == "" {
c.DataDir = "./data"
}
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 f.Path == "" {
return fmt.Errorf("form %q: path is required", f.Name)
}
if f.Type == "" {
c.Forms[i].Type = "contact"
}
if !ValidFormTypes[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 !strings.HasPrefix(f.Path, "/") {
return fmt.Errorf("form %q: path must start with /", f.Name)
}
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.RateLimitPerHour == 0 {
c.Forms[i].RateLimitPerHour = 10
}
if f.HoneypotField == "" {
c.Forms[i].HoneypotField = "website"
}
}
return nil
}
// ConfigPath returns the canonical path for nuntius configuration.
func ConfigPath() string {
return "/etc/nuntius/config.toml"
}
// 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.
data_dir = "./data"
[server]
port = 8080
[[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"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
[[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"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
`)
+94
View File
@@ -0,0 +1,94 @@
//go:build linux || freebsd
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadWritesAndParsesDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
// The first Load writes the default template, then parses it back.
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 8080 {
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
}
if cfg.DataDir != "./data" {
t.Errorf("DataDir = %q, want ./data", cfg.DataDir)
}
if len(cfg.Forms) != 3 {
t.Fatalf("len(Forms) = %d, want 3", len(cfg.Forms))
}
if cfg.Forms[0].Name != "contact" || cfg.Forms[0].SMTP.Host != "smtp.example.com" {
t.Errorf("Forms[0] = %#v", cfg.Forms[0])
}
if cfg.Forms[2].RateLimitPerHour != 100 {
t.Errorf("newsletter rate_limit_per_hour = %d, want 100", cfg.Forms[2].RateLimitPerHour)
}
}
func TestLoadExpandsEnvVars(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
doc := `
data_dir = "./data"
[server]
port = 9000
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "u@example.com"
password = "${NUNTIUS_TEST_PW}"
`
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("NUNTIUS_TEST_PW", "s3cret")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Forms[0].SMTP.Password != "s3cret" {
t.Errorf("password = %q, want s3cret", cfg.Forms[0].SMTP.Password)
}
}
func TestLoadRejectsUnknownField(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
doc := `
bogus = true
[server]
port = 8080
[[forms]]
name = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "u@example.com"
`
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("expected an error for an unknown field")
}
}
+292
View File
@@ -0,0 +1,292 @@
//go:build linux || freebsd
// Package email handles SMTP message composition and delivery.
package email
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"time"
"codeberg.org/petrbalvin/nuntius/internal/config"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
// 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
}
// 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 the SMTP round-trip fails.
func (s *FormSender) Send(req contactform.Request) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg := compose(s.form.From, s.form.To, req, s.form.Name, s.form.Type)
return smtp.SendMail(s.form.SMTP.AddrFor(), auth, s.form.From, []string{s.form.To}, msg)
}
// 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 -->
<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">nuntius</strong> — a contact form backend for Linux servers.
</p>
</td>
</tr>
</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>
<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>
<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">nuntius</strong></p>
</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>
<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">nuntius</strong></p>
</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>
<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>
<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">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
func compose(from, to string, req contactform.Request, formName, formType string) []byte {
received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC")
// --- HTML part ---
var htmlBuf bytes.Buffer
tmpl := emailTemplateContact
switch formType {
case "newsletter":
tmpl = emailTemplateNewsletter
case "feedback":
tmpl = emailTemplateFeedback
case "generic":
tmpl = emailTemplateGeneric
}
_ = tmpl.Execute(&htmlBuf, map[string]string{
"FormName": formName,
"Name": req.Name,
"Email": req.Email,
"Service": req.Service,
"Message": req.Message,
"Received": received,
})
// --- Subject + plain-text body per form type ---
var subject, text string
switch formType {
case "newsletter":
subject = fmt.Sprintf("[nuntius/%s] New newsletter subscriber", formName)
text = fmt.Sprintf(
"New Newsletter Subscriber: %s\r\n"+
"---\r\n"+
"Email: %s\r\n"+
"Received: %s\r\n"+
"\r\n"+
"Delivered by nuntius\r\n",
formName, req.Email, received,
)
case "feedback":
subject = fmt.Sprintf("[nuntius/%s] New feedback", formName)
text = fmt.Sprintf(
"New Feedback: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, received, req.Message,
)
case "generic":
subject = fmt.Sprintf("[nuntius/%s] New submission", formName)
text = fmt.Sprintf(
"New Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, received, req.Message,
)
default: // contact
subject = fmt.Sprintf("[nuntius/%s] Contact form submission", formName)
if req.Service != "" {
subject = fmt.Sprintf("[nuntius/%s][%s] Contact form submission", formName, req.Service)
}
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"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, req.Service, received, req.Message,
)
}
// Assemble multipart/alternative message.
boundary := "nuntius-boundary"
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", 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()
}
+269
View File
@@ -0,0 +1,269 @@
//go:build linux || freebsd
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"time"
"codeberg.org/petrbalvin/nuntius/internal/config"
"codeberg.org/petrbalvin/nuntius/internal/email"
"codeberg.org/petrbalvin/nuntius/internal/storage"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
// 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 {
forms map[string]*config.Form
senders map[string]*email.FormSender
rateLimits map[string]*rateLimiter
stores map[string]*storage.NewsletterStore
}
// New constructs a ContactHandler that serves all forms defined in cfg.
func New(cfg *config.Config) *ContactHandler {
h := &ContactHandler{
forms: make(map[string]*config.Form, len(cfg.Forms)),
senders: make(map[string]*email.FormSender, len(cfg.Forms)),
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
stores: make(map[string]*storage.NewsletterStore, 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(f.RateLimitPerHour)
if f.Type == "newsletter" {
storePath := filepath.Join(cfg.DataDir, "newsletter-"+f.Name+".jsonl")
h.stores[f.Path] = storage.NewNewsletterStore(storePath)
}
}
return h
}
// Register mounts one POST + OPTIONS handler per form, plus a single
// GET /health handler.
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))
}
mux.HandleFunc("GET /health", h.Health)
}
// 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 != "" {
respondError(w, http.StatusForbidden, "origin_not_allowed", "")
return
}
// Rate limit.
if form.RateLimitPerHour > 0 {
ip := clientIP(r)
if !limiter.allow(ip) {
respondError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests, please try again later.")
return
}
}
// Parse body.
defer r.Body.Close()
var req contactform.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.")
return
}
// Honeypot: silently accept but never send.
if form.HoneypotField != "" && req.Honeypot != "" {
slog.Info("honeypot triggered, dropping silently",
"form", form.Name, "path", path, "ip", clientIP(r))
respondOK(w)
return
}
// Validate.
if errs := contactform.Validate(&req, form.Type); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: "validation",
Details: errs,
})
return
}
// Send email.
if err := sender.Send(req); err != nil {
slog.Error("send failed",
"err", err, "form", form.Name, "path", path, "ip", clientIP(r))
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send email.")
return
}
// For newsletter forms, also persist the subscriber to disk.
if store != nil {
if err := store.Append(storage.Subscriber{
Email: req.Email,
IP: clientIP(r),
Form: form.Name,
}); err != nil {
slog.Error("newsletter store append failed",
"err", err, "form", form.Name, "path", path, "ip", clientIP(r))
respondError(w, http.StatusInternalServerError, "storage_failed",
"Could not record subscription.")
return
}
}
slog.Info("message sent",
"form", form.Name, "path", path,
"service", req.Service, "ip", clientIP(r),
)
respondOK(w)
}
}
// Health handles GET /health.
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"forms": len(h.forms),
})
}
// --- internals ---
func formAllowed(form *config.Form, origin string) bool {
if origin == "" {
return true
}
for _, a := range form.AllowedOrigins {
if a == origin {
return true
}
}
return false
}
func writeCORS(w http.ResponseWriter, origin string, allowed []string) {
// Only echo the origin back if it is in the allowlist.
for _, a := range allowed {
if a == 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.
type rateLimiter struct {
mu sync.Mutex
perHour int
buckets map[string]*bucket
}
type bucket struct {
tokens float64
last time.Time
}
func newRateLimiter(perHour int) *rateLimiter {
return &rateLimiter{
perHour: perHour,
buckets: make(map[string]*bucket),
}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
b, ok := r.buckets[ip]
if !ok {
b = &bucket{tokens: float64(r.perHour), last: now}
r.buckets[ip] = b
}
rate := float64(r.perHour) / 3600.0
elapsed := now.Sub(b.last).Seconds()
b.tokens = minF(b.tokens+elapsed*rate, float64(r.perHour))
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func minF(a, b float64) float64 {
if a < b {
return a
}
return b
}
func respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(contactform.Response{OK: true})
}
func respondError(w http.ResponseWriter, code int, err, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: err,
Message: msg,
})
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
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
}
+154
View File
@@ -0,0 +1,154 @@
//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"
"path/filepath"
"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()
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
}
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
if _, err := f.Write(line); err != nil {
return fmt.Errorf("write %s: %w", s.path, err)
}
return nil
}
// 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
}
+153
View File
@@ -0,0 +1,153 @@
//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)
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build linux || freebsd
// Package version exposes the nuntius release identity.
package version
// Name is the program name reported by `--version` and in log lines.
const Name = "nuntius"
// Version is the nuntius release version. Follows SemVer.
var Version = "1.0.0"