// 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 }