243 lines
8.0 KiB
Go
243 lines
8.0 KiB
Go
// Package mailstore implements Maildir++-style on-disk message storage with
|
|
// every message encrypted at rest (AES-256-GCM, per-message key derived via
|
|
// HKDF from the master key — see internal/crypto).
|
|
package mailstore
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gomail/internal/crypto"
|
|
"gomail/internal/db"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Store writes and reads encrypted messages in a Maildir++ layout:
|
|
//
|
|
// {root}/{user-email}/{mailbox}/cur/{filename}.eml.enc
|
|
// {root}/{user-email}/{mailbox}/new/
|
|
// {root}/{user-email}/{mailbox}/tmp/
|
|
type Store struct {
|
|
root string
|
|
mk *crypto.MasterKey
|
|
db *db.DB
|
|
}
|
|
|
|
func New(root string, mk *crypto.MasterKey, database *db.DB) *Store {
|
|
return &Store{root: root, mk: mk, db: database}
|
|
}
|
|
|
|
// Deliver writes a raw message into a user's mailbox, encrypting it at rest,
|
|
// allocates the next IMAP UID, and records the mailbox_index row. Returns the
|
|
// assigned UID.
|
|
func (s *Store) Deliver(userID, userEmail, mailbox string, raw []byte) (uid int, err error) {
|
|
if err := s.ensureMailboxDirs(userEmail, mailbox); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
messageID := uuid.NewString()
|
|
encrypted, err := crypto.Encrypt(s.mk, messageID, "message", raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encrypt message: %w", err)
|
|
}
|
|
|
|
filename := maildirFilename(messageID)
|
|
tmpPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "tmp", filename)
|
|
finalPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "cur", filename)
|
|
|
|
// Write to tmp/ then atomically rename into cur/ — standard Maildir delivery
|
|
// guarantee: a reader never observes a partially-written file.
|
|
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
|
return 0, fmt.Errorf("write tmp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return 0, fmt.Errorf("atomic rename: %w", err)
|
|
}
|
|
|
|
allocatedUID, err := s.db.NextMailboxUID(userID, mailbox)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("allocate uid: %w", err)
|
|
}
|
|
|
|
entry := &db.MailboxEntry{
|
|
ID: messageID,
|
|
UserID: userID,
|
|
Mailbox: mailbox,
|
|
UID: allocatedUID,
|
|
EMLPath: finalPath,
|
|
Flags: "",
|
|
SizeBytes: int64(len(raw)),
|
|
ReceivedAt: time.Now().UTC(),
|
|
InternalDate: time.Now().UTC(),
|
|
}
|
|
if err := s.db.InsertMailboxEntry(entry); err != nil {
|
|
// Best-effort cleanup of the file we just wrote — DB is the source of
|
|
// truth for what "exists"; an orphaned encrypted file with no index
|
|
// row is inert and harmless, but we try to avoid leaving one anyway.
|
|
os.Remove(finalPath)
|
|
return 0, fmt.Errorf("index mailbox entry: %w", err)
|
|
}
|
|
|
|
return allocatedUID, nil
|
|
}
|
|
|
|
// Read decrypts and returns the raw message bytes for a given encrypted file path.
|
|
// messageID must match the ID used at Deliver time (it's embedded in the filename).
|
|
func (s *Store) Read(path string) ([]byte, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read encrypted file: %w", err)
|
|
}
|
|
|
|
messageID := messageIDFromFilename(filepath.Base(path))
|
|
plaintext, err := crypto.Decrypt(s.mk, messageID, "message", data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt message: %w", err)
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
func (s *Store) ensureMailboxDirs(userEmail, mailbox string) error {
|
|
base := s.mailboxDir(userEmail, mailbox)
|
|
for _, sub := range []string{"cur", "new", "tmp"} {
|
|
if err := os.MkdirAll(filepath.Join(base, sub), 0700); err != nil {
|
|
return fmt.Errorf("creating maildir %s/%s: %w", mailbox, sub, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) mailboxDir(userEmail, mailbox string) string {
|
|
safeUser := sanitizePathComponent(userEmail)
|
|
safeMailbox := sanitizePathComponent(mailbox)
|
|
return filepath.Join(s.root, safeUser, safeMailbox)
|
|
}
|
|
|
|
// sanitizePathComponent prevents path traversal via crafted mailbox names or
|
|
// email addresses — strips any path separators or parent-directory references.
|
|
func sanitizePathComponent(s string) string {
|
|
s = strings.ReplaceAll(s, "/", "_")
|
|
s = strings.ReplaceAll(s, "\\", "_")
|
|
s = strings.ReplaceAll(s, "..", "_")
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
s = "_"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// maildirFilename builds a Maildir-spec-ish unique filename embedding the
|
|
// message ID (needed later to re-derive the decryption key) plus a random
|
|
// suffix for readability/uniqueness under concurrent delivery.
|
|
func maildirFilename(messageID string) string {
|
|
suffix := make([]byte, 4)
|
|
rand.Read(suffix)
|
|
return fmt.Sprintf("%d.%s.%s.eml.enc", time.Now().UnixNano(), messageID, hex.EncodeToString(suffix))
|
|
}
|
|
|
|
func messageIDFromFilename(filename string) string {
|
|
parts := strings.Split(filename, ".")
|
|
if len(parts) >= 2 {
|
|
return parts[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WriteQueueFile encrypts and stores a message destined for outbound
|
|
// delivery, separately from any user's Maildir (it's not "in" a mailbox
|
|
// until/unless it becomes a Sent-folder copy after successful delivery —
|
|
// that wiring lands with the webmail Sent view in a later phase). Returns
|
|
// the messageID (used as both the encryption record ID and to find the file
|
|
// again) and the file path to record in outbound_queue.eml_path.
|
|
func (s *Store) WriteQueueFile(raw []byte) (messageID, path string, err error) {
|
|
queueDir := filepath.Join(s.root, ".queue")
|
|
if err := os.MkdirAll(queueDir, 0700); err != nil {
|
|
return "", "", fmt.Errorf("creating queue dir: %w", err)
|
|
}
|
|
|
|
messageID = uuid.NewString()
|
|
encrypted, err := crypto.Encrypt(s.mk, messageID, "message", raw)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("encrypt queued message: %w", err)
|
|
}
|
|
|
|
filename := maildirFilename(messageID)
|
|
tmpPath := filepath.Join(queueDir, "tmp-"+filename)
|
|
finalPath := filepath.Join(queueDir, filename)
|
|
|
|
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
|
return "", "", fmt.Errorf("write queue tmp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return "", "", fmt.Errorf("atomic rename: %w", err)
|
|
}
|
|
|
|
return messageID, finalPath, nil
|
|
}
|
|
|
|
// DeleteQueueFile removes a queue file after successful delivery or a
|
|
// generated bounce — called by the queue worker.
|
|
func (s *Store) DeleteQueueFile(path string) error {
|
|
return os.Remove(path)
|
|
}
|
|
|
|
// WriteQuarantineFile encrypts and stores a held message using the given
|
|
// messageID (the same ID as its `messages` audit row) rather than generating
|
|
// a new one — so release-time decryption can key off the ID already on hand
|
|
// from the quarantine/messages tables without needing to parse it back out
|
|
// of a filename.
|
|
func (s *Store) WriteQuarantineFile(messageID string, raw []byte) (path string, err error) {
|
|
qDir := filepath.Join(s.root, ".quarantine")
|
|
if err := os.MkdirAll(qDir, 0700); err != nil {
|
|
return "", fmt.Errorf("creating quarantine dir: %w", err)
|
|
}
|
|
|
|
encrypted, err := crypto.Encrypt(s.mk, messageID, "message", raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encrypt quarantined message: %w", err)
|
|
}
|
|
|
|
filename := messageID + ".eml.enc"
|
|
tmpPath := filepath.Join(qDir, "tmp-"+filename)
|
|
finalPath := filepath.Join(qDir, filename)
|
|
|
|
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
|
return "", fmt.Errorf("write quarantine tmp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return "", fmt.Errorf("atomic rename: %w", err)
|
|
}
|
|
return finalPath, nil
|
|
}
|
|
|
|
// ReadQuarantineFile decrypts a quarantined message given its messageID
|
|
// (needed because quarantine files are keyed by ID directly, not embedded
|
|
// in the filename the way Deliver's maildirFilename embeds it).
|
|
func (s *Store) ReadQuarantineFile(messageID, path string) ([]byte, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read quarantine file: %w", err)
|
|
}
|
|
return crypto.Decrypt(s.mk, messageID, "message", data)
|
|
}
|
|
// a large message is needed (future IMAP partial FETCH support) — for now it
|
|
// simply decrypts and returns the full body since GCM doesn't support
|
|
// streaming partial decryption without the full ciphertext.
|
|
func (s *Store) ReadAt(path string, w io.Writer) error {
|
|
data, err := s.Read(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(data)
|
|
return err
|
|
}
|