// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd // Package storage provides file-based persistence for nuntius. // // Today it contains only the newsletter subscriber log. It is designed // to be zero-dependency (stdlib only) and crash-safe: each Append writes // a single JSON line to an append-only file, so partial writes do not // corrupt earlier records. package storage import ( "bufio" "encoding/json" "fmt" "os" "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 }