// 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" "strings" "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() return appendLine(s.path, line) } // 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 } // DedupeNewsletterStore wraps a NewsletterStore with an in-memory index of // recorded addresses so callers can detect a repeat subscription before // doing any user-visible work. The index is built lazily from the log on // first use and kept in sync on successful appends. // // Addresses are compared case-insensitively. Strictly speaking the local // part of an address may be case-sensitive, but every major provider treats // mailbox names that way in practice, and bot submissions exploit // exact-case variants to multiply signups. type DedupeNewsletterStore struct { store *NewsletterStore mu sync.Mutex seen map[string]struct{} once sync.Once } // NewDedupeNewsletterStore wraps store. func NewDedupeNewsletterStore(store *NewsletterStore) *DedupeNewsletterStore { return &DedupeNewsletterStore{store: store} } // Path returns the wrapped store's file path. func (d *DedupeNewsletterStore) Path() string { return d.store.Path() } // Count returns the number of valid records in the log. func (d *DedupeNewsletterStore) Count() (int, error) { return d.store.Count() } // List returns all valid subscribers in insertion order. func (d *DedupeNewsletterStore) List() ([]Subscriber, error) { return d.store.List() } // seenKey normalises an address for comparison. func seenKey(email string) string { return strings.ToLower(strings.TrimSpace(email)) } // load populates the index once per process lifetime. An unreadable log // behaves like an empty index: dedupe then only covers this run, which // matches how the process would behave after a hard crash anyway. func (d *DedupeNewsletterStore) load() { d.once.Do(func() { d.seen = make(map[string]struct{}) subs, err := d.store.List() if err != nil { return } for _, sub := range subs { d.seen[seenKey(sub.Email)] = struct{}{} } }) } // Has reports whether the address was already recorded. func (d *DedupeNewsletterStore) Has(email string) bool { d.load() d.mu.Lock() defer d.mu.Unlock() _, ok := d.seen[seenKey(email)] return ok } // Remember records an address after its append succeeded. func (d *DedupeNewsletterStore) Remember(sub Subscriber) { d.load() d.mu.Lock() defer d.mu.Unlock() d.seen[seenKey(sub.Email)] = struct{}{} } // Append persists sub and records the address on success. func (d *DedupeNewsletterStore) Append(sub Subscriber) error { if err := d.store.Append(sub); err != nil { return err } d.Remember(sub) return nil }