This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+50
View File
@@ -6,8 +6,10 @@ package mailstore
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/mail"
"os"
"path/filepath"
"strings"
@@ -33,6 +35,27 @@ func New(root string, mk *crypto.MasterKey, database *db.DB) *Store {
return &Store{root: root, mk: mk, db: database}
}
// CachedHeader is the small subset of a message's headers worth caching
// separately from the full body, so a folder listing can decrypt a few
// hundred bytes instead of the whole message just to show a list of
// subjects — see Deliver and DecryptHeaderCache.
type CachedHeader struct {
From, To, Subject, Date string
}
func parseCachedHeader(raw []byte) CachedHeader {
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
return CachedHeader{}
}
return CachedHeader{
From: msg.Header.Get("From"),
To: msg.Header.Get("To"),
Subject: msg.Header.Get("Subject"),
Date: msg.Header.Get("Date"),
}
}
// 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.
@@ -47,6 +70,18 @@ func (s *Store) Deliver(userID, userEmail, mailbox string, raw []byte) (uid int,
return 0, fmt.Errorf("encrypt message: %w", err)
}
// Encrypted separately under its own purpose so folder listing can
// decrypt this small blob instead of the full message body — same
// per-record HKDF scheme, so "everything encrypted at rest" still
// holds. A failure here isn't fatal to delivery: ListMessages falls
// back to a full read when header_enc is absent.
var headerEnc []byte
if hdrJSON, err := json.Marshal(parseCachedHeader(raw)); err == nil {
if enc, err := crypto.Encrypt(s.mk, messageID, "message-header", hdrJSON); err == nil {
headerEnc = enc
}
}
filename := maildirFilename(messageID)
tmpPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "tmp", filename)
finalPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "cur", filename)
@@ -76,6 +111,7 @@ func (s *Store) Deliver(userID, userEmail, mailbox string, raw []byte) (uid int,
SizeBytes: int64(len(raw)),
ReceivedAt: time.Now().UTC(),
InternalDate: time.Now().UTC(),
HeaderEnc: headerEnc,
}
if err := s.db.InsertMailboxEntry(entry); err != nil {
// Best-effort cleanup of the file we just wrote — DB is the source of
@@ -104,6 +140,20 @@ func (s *Store) Read(path string) ([]byte, error) {
return plaintext, nil
}
// DecryptHeaderCache decrypts a header_enc blob written by Deliver.
// messageID must be the same ID used at Deliver time (mailbox_index.id).
func (s *Store) DecryptHeaderCache(messageID string, headerEnc []byte) (CachedHeader, error) {
plaintext, err := crypto.Decrypt(s.mk, messageID, "message-header", headerEnc)
if err != nil {
return CachedHeader{}, fmt.Errorf("decrypt header cache: %w", err)
}
var h CachedHeader
if err := json.Unmarshal(plaintext, &h); err != nil {
return CachedHeader{}, fmt.Errorf("unmarshal header cache: %w", err)
}
return h, nil
}
func (s *Store) ensureMailboxDirs(userEmail, mailbox string) error {
base := s.mailboxDir(userEmail, mailbox)
for _, sub := range []string{"cur", "new", "tmp"} {