feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -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}"
|
||||
`)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user