package storage import ( "crypto/sha256" "errors" "fmt" "io/fs" "os" "path/filepath" "time" ) // Store is a file-based email storage using SHA256 for deduplication. type Store struct { dir string } // StoreStats reports total mail count and size in bytes. type StoreStats struct { TotalMails int64 TotalBytes int64 } // New initialises the storage directory, creating required subdirectories. func New(dir string) (*Store, error) { for _, sub := range []string{"store", "attachments", "meta"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { return nil, fmt.Errorf("storage: mkdir %s: %w", sub, err) } } return &Store{dir: dir}, nil } // Save writes raw email bytes to storage. The ID is the hex-encoded SHA256 of // the content. If the file already exists, Save is a no-op (deduplication). func (s *Store) Save(raw []byte, _ time.Time) (string, error) { sum := sha256.Sum256(raw) id := fmt.Sprintf("%x", sum[:]) // 64 hex chars path := s.filePath(id) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return "", fmt.Errorf("storage: mkdir shard: %w", err) } // If file already exists, dedup: return same id without error. if _, err := os.Stat(path); err == nil { return id, nil } if err := os.WriteFile(path, raw, 0o644); err != nil { return "", fmt.Errorf("storage: write: %w", err) } return id, nil } // Load reads a stored email by its ID. func (s *Store) Load(id string) ([]byte, error) { path := s.filePath(id) data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("storage: not found: %s", id) } return nil, fmt.Errorf("storage: read: %w", err) } return data, nil } // Delete removes a stored email by its ID. func (s *Store) Delete(id string) error { path := s.filePath(id) if err := os.Remove(path); err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("storage: not found: %s", id) } return fmt.Errorf("storage: delete: %w", err) } return nil } // Stats walks the store directory and returns aggregate statistics. func (s *Store) Stats() (*StoreStats, error) { var stats StoreStats err := filepath.WalkDir(filepath.Join(s.dir, "store"), func(_ string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return err } stats.TotalMails++ stats.TotalBytes += info.Size() return nil }) if err != nil { return nil, fmt.Errorf("storage: stats: %w", err) } return &stats, nil } // MailRef holds the ID and modification time of a stored mail. type MailRef struct { ID string ModTime time.Time } // FirstAndLastMail walks the store and returns the oldest and newest mail by // file modification time. Returns nil for either if the store is empty. func (s *Store) FirstAndLastMail() (first, last *MailRef, err error) { err = filepath.WalkDir(filepath.Join(s.dir, "store"), func(path string, d fs.DirEntry, werr error) error { if werr != nil { return werr } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return err } ref := &MailRef{ID: d.Name(), ModTime: info.ModTime()} if first == nil || ref.ModTime.Before(first.ModTime) { first = ref } if last == nil || ref.ModTime.After(last.ModTime) { last = ref } return nil }) if err != nil { return nil, nil, fmt.Errorf("storage: first/last: %w", err) } return first, last, nil } // filePath returns the on-disk path for a given mail ID. // Uses 2-char prefix sharding: {dir}/store/{id[:2]}/{id} func (s *Store) filePath(id string) string { return filepath.Join(s.dir, "store", id[:2], id) }