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
+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)
}
}