first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
// 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
}