13 KiB
Architecture
nuntius is a small HTTP service that accepts JSON form submissions, validates them, sends them via SMTP, and (for newsletter forms) appends the email to a JSONL log on disk. This document explains the layers, the request flow, and the design decisions that keep the binary ~6 MB and the dependency graph empty.
High-Level Overview
graph TD
Browser[Browser / Frontend]:::accent6
Caddy[Caddy reverse proxy]:::accent6
Server[cmd/server/main.go]:::accent0
Config[internal/config/]:::accent7
Handler[internal/handler/]:::accent0
RateLimit[rateLimiter per form]:::accent7
CORS[CORS allowlist per form]:::accent7
Honeypot[honeypot_field per form]:::accent7
Validate[pkg/contactform/Validate]:::accent3
Sender[internal/email/FormSender]:::accent1
Storage[internal/storage/NewsletterStore]:::accent1
SMTP[(SMTP server)]:::accent2
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
Browser -->|POST + Origin| Caddy
Caddy -->|X-Forwarded-For| Server
Server --> Config
Server --> Handler
Handler --> RateLimit
Handler --> CORS
Handler --> Honeypot
Handler --> Validate
Handler --> Sender
Handler --> Storage
Sender -->|SMTP PLAIN over net/smtp| SMTP
Storage -->|append JSONL| JSONL
Handler -->|200 / 4xx / 5xx| Caddy
Caddy --> Browser
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000
classDef accent3 fill:#A855F7,stroke:#7C3AED,color:#fff
classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
Layers
1. Entry point — cmd/server/main.go
- Wires up a JSON
sloghandler on stdout. - Loads the config from
config.ConfigPath()(default/etc/nuntius/config.toml). - Builds a
*handler.ContactHandlerviahandler.New(cfg). - Registers one
POST+ oneOPTIONShandler per form on a freshhttp.ServeMux, plusGET /health. - Wraps the mux in a request-logging middleware that emits a single
slog.Infoper request with method, path, status, duration, and client IP. - Starts an
http.Serverwith explicit read / write / idle timeouts. - Installs a
signal.NotifyContextlistener for SIGINT / SIGTERM and shuts the server down gracefully within 15 s.
The main package owns no business logic — it only orchestrates the four internal packages.
2. Configuration — internal/config/
The config package is the gatekeeper for everything that varies per deployment.
| Concern | Where |
|---|---|
| File path | config.ConfigPath() returns /etc/nuntius/config.toml |
| Default file | defaultConfig is a []byte constant embedded in the source; written on first start |
| Env-var expansion | os.Expand(string(raw), os.Getenv) runs before TOML is parsed by interpres |
| Strict decoding | interpres.NewDecoder(...).DisallowUnknownFields() rejects typos |
| Schema validation | (*Config).validate() enforces required fields, unique paths, valid form types, sensible defaults |
| Defaults | port = 8080, data_dir = "./data", rate_limit_per_hour = 10, honeypot_field = "website", from = smtp.user |
The Form type mirrors the TOML shape one-to-one. ValidFormTypes is the authoritative allow-list — adding a form type means editing one map in this package.
3. HTTP handler — internal/handler/
ContactHandler stores its dependencies behind small interfaces so the handler pipeline can be tested without a real SMTP server or filesystem:
type formSender interface {
Send(req contactform.Request) error
}
type subscriberStorer interface {
Append(sub storage.Subscriber) error
}
email.FormSender and storage.NewsletterStore satisfy these interfaces in production. Tests use mock implementations (mockSender, mockStore) that record calls and simulate errors.
ContactHandler is a single struct with four maps keyed by URL path:
type ContactHandler struct {
forms map[string]*config.Form
senders map[string]formSender
rateLimits map[string]*rateLimiter
stores map[string]subscriberStorer
}
New(cfg) builds all four maps in one pass. Register(mux) then mounts POST <path> and OPTIONS <path> per form, plus GET /health.
The per-form handler closure (makeHandler) runs this pipeline in order:
sequenceDiagram
participant Client
participant Handler as ContactHandler
participant CORS
participant Limiter as rateLimiter
participant Body
participant Honey as Honeypot
participant Validate as contactform.Validate
participant Sender as email.FormSender
participant Store as storage.NewsletterStore
Client->>Handler: POST /api/nuntius/contact
alt preflight
Handler->>CORS: formAllowed(origin)?
CORS-->>Handler: yes / no
Handler-->>Client: 204 No Content (or 403)
else actual
Handler->>CORS: formAllowed(origin)?
CORS-->>Handler: yes / no
alt origin present and not allowed
Handler-->>Client: 403 origin_not_allowed
end
Handler->>Limiter: allow(ip)
alt over limit
Handler-->>Client: 429 rate_limited
end
Handler->>Body: json.Decode
alt invalid JSON
Handler-->>Client: 400 invalid_json
end
Handler->>Honey: honeypot field non-empty?
alt bot
Handler-->>Client: 200 ok (silent)
end
Handler->>Validate: contactform.Validate(req, form.Type)
alt errors
Handler-->>Client: 400 validation + details
end
Handler->>Sender: Send(req)
alt SMTP error
Handler-->>Client: 500 send_failed
end
opt form.Type == "newsletter"
Handler->>Store: Append(Subscriber{...})
alt write error
Handler-->>Client: 500 storage_failed
end
end
Handler-->>Client: 200 ok
end
Rate limiter cleanup
The per-form rateLimiter type starts a background goroutine (startCleanup) that ticks once per hour and deletes IP entries older than twice the refill window. This prevents unbounded memory growth from one-off IPs. The cleanup goroutine is covered by TestRateLimiterCleanup in contact_test.go.
Notable behaviors:
- CORS is path-aware — Each form has its own
allowed_originslist; preflights and actual requests are checked against the form being hit, not the host. - Client IP honours reverse proxies —
clientIPreadsX-Forwarded-For(first hop), thenX-Real-IP, then falls back toRemoteAddr. Strip the port by hand because the stdlib does not give us anetip.AddrPortfor the remote. - Rate limiting is in-process —
rateLimiteris a per-IP token bucket. The bucket refills atperHour/3600tokens per second. The map is guarded by a singlesync.Mutex; in practice the lock is held for microseconds and the access pattern is dominated byAllowlookups, not insert / delete. - Honeypot is a positive control — A bot that fills the invisible field gets a 200 with no email sent and no log line. Humans are never blocked.
- Validation is the same function the public library uses —
contactform.Validateis the only validator in the codebase; the handler just calls it withform.Typeand forwards the returned[]FieldErrorto the client.
4. Email — internal/email/
Each form gets a FormSender constructed once in handler.New. Send(req) is the only public method:
smtp.PlainAuth("", user, password, host)— the empty identity means "use the supplied user as-is", which is what every modern SMTP provider expects.compose(from, to, req, formName, formType)builds amultipart/alternativemessage (text/plain + text/html).smtp.SendMail(addr, auth, from, []string{to}, msg).
compose picks one of four HTML templates (contact, feedback, newsletter, generic) and four matching plain-text subjects. The subject for contact includes the [<service>] tag if req.Service is set, so inbox filters can route by service interest.
Credentials are never logged — neither the password nor the full From / To headers appear in any slog line. The handler logs form, path, service (if any), and ip only.
5. Storage — internal/storage/
NewsletterStore is a thin wrapper over an append-only JSONL file:
| Method | Semantics |
|---|---|
Append(Subscriber) |
Creates the parent dir if missing, opens the file with O_APPEND | O_CREATE | O_WRONLY, writes one JSON line + \n, closes. Sets CreatedAt = time.Now().UTC() if zero. |
Count() |
Streams the file with a bufio.Scanner (1 MB max line), increments on each line that unmarshals into a non-empty Subscriber. Malformed lines are skipped. |
List() |
Same scan, returns all valid subscribers in insertion order. Reads the entire log into memory — do not call on multi-million-row files. |
Path() |
Returns the configured file path. |
A single sync.Mutex guards all three methods. The store is safe for concurrent use from the handler goroutines.
There is no rotation. Add a logrotate unit if the file grows beyond what you want to wc -l.
Request Flow (single form)
sequenceDiagram
participant Browser
participant Caddy
participant Nuntius as nuntius
participant SMTP
participant Disk
Browser->>Caddy: POST /api/nuntius/contact
Caddy->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
Nuntius->>Nuntius: CORS check
Nuntius->>Nuntius: rate-limit per IP
Nuntius->>Nuntius: parse JSON
Nuntius->>Nuntius: honeypot check
Nuntius->>Nuntius: contactform.Validate
Nuntius->>SMTP: SMTP PLAIN auth + multipart message
SMTP-->>Nuntius: 250 OK
opt newsletter form
Nuntius->>Disk: append one JSONL line
end
Nuntius-->>Caddy: 200 {"ok": true}
Caddy-->>Browser: 200 {"ok": true}
Config Merging
There is no merging in nuntius — the TOML file is the only source of runtime configuration. The file itself goes through three stages at startup:
- Default generation — If the file does not exist,
os.WriteFile(path, defaultConfig, 0644)writes the embedded three-form template. - Env-var expansion —
os.Expandsubstitutes${VAR}and$VARreferences in the raw text before parsing. The parsed struct never holds a literal like"${NUNTIUS_SMTP_PASSWORD}"— it holds the expanded value (or an empty string if the env var was unset). - Strict parsing + validation —
interpres.NewDecoder(...).DisallowUnknownFields()rejects typos;validate()enforces required fields, unique paths, valid form types, and applies defaults.
The order matters: expansion before parsing means env-var references work even for values that are not strings (e.g. an integer with a ${PORT} reference would fail, but you can still reference strings).
Error Handling
Errors are returned as a typed string in the JSON body, with the HTTP status code doing the heavy lifting:
| Status | Body shape | When |
|---|---|---|
200 |
{"ok": true} |
Submission accepted; mail sent (and subscriber persisted for newsletter) |
200 |
{"ok": true} |
Honeypot triggered (silent accept, no mail) |
400 |
{"error": "invalid_json", "message": "..."} |
Body is not valid JSON |
400 |
{"error": "validation", "details": [...]} |
One or more fields are invalid |
403 |
{"error": "origin_not_allowed"} |
Origin header is not in the form's allowed_origins |
429 |
{"error": "rate_limited", "message": "..."} |
Token bucket empty for this IP / form |
500 |
{"error": "send_failed", "message": "..."} |
SMTP round-trip failed |
500 |
{"error": "storage_failed", "message": "..."} |
newsletter only — could not write the JSONL line |
The message field is intentionally generic; field-level details for validation errors live in details[]. None of the error bodies leak SMTP credentials, file paths, or stack traces.
Why These Choices
- stdlib only —
net/smtp,net/http,net/mail,log/slog,bufio,os/signal,syncplus the first-partyinterpresTOML library are all that is needed. The build is reproducible, the binary is small, and the external supply chain is empty. - No global state outside the binary —
ContactHandlerowns the maps;slogis the only package-level default. Everything else is constructed explicitly inNew(cfg)so the handler is easy to instantiate from tests. - Append-only JSONL — Cheaper to implement than SQLite, easier to inspect than a custom binary format, easy to back up, and resilient to partial writes.
- Per-form isolation — One form's SMTP credentials never travel through another form's code path. A misconfigured
contactform cannot leak credentials from anewsletterform. - Strict config parsing — Catching
forms[0].smtp.port = "587"(string) orforms[0].type = "contatc"(typo) at startup is worth the small extra error-path code.