first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user