197 lines
6.5 KiB
Go
197 lines
6.5 KiB
Go
package mailstore
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"mailgoserver/internal/mailview"
|
|
)
|
|
|
|
// extractHeaderValue reads a single header out of raw without parsing the body — used
|
|
// to compute StoreMessage's cached_to column cheaply (no MIME/multipart walk needed
|
|
// just to cache a header for fast folder-listing display). Returns "" on any parse
|
|
// failure or if the header is absent, never an error — this is a display convenience,
|
|
// not something delivery should ever fail over.
|
|
func extractHeaderValue(raw []byte, name string) string {
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return msg.Header.Get(name)
|
|
}
|
|
|
|
const previewSnippetLen = 150
|
|
|
|
// previewSnippet extracts up to previewSnippetLen characters of the plain-text body
|
|
// for the folder list's preview line — cached in plain text alongside cached_from/
|
|
// cached_subject (see schema.go's comment on cached_preview for why that's consistent
|
|
// with the existing cached_* columns, not a new exposure). HTML-only mail (no
|
|
// text/plain part) gets no preview rather than a crude tag-stripped approximation —
|
|
// an accepted scope limit, not a bug: most real mail includes a text/plain
|
|
// alternative regardless of whether the sender expects it to be shown.
|
|
func previewSnippet(raw []byte) string {
|
|
parsed, err := mailview.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
text := strings.Join(strings.Fields(parsed.TextBody), " ")
|
|
// Rune-safe truncation — a plain byte slice could split a multi-byte UTF-8
|
|
// character in half and produce invalid text.
|
|
if runes := []rune(text); len(runes) > previewSnippetLen {
|
|
text = string(runes[:previewSnippetLen])
|
|
}
|
|
return text
|
|
}
|
|
|
|
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
|
|
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
|
|
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
|
|
|
// StoreMessage encrypts raw with the mailbox's own data encryption key and persists
|
|
// it to disk, then indexes it in esrv_mailbox_messages and updates the mailbox's
|
|
// cached used_bytes. from/subject are cached in the DB in plain text by design (see
|
|
// schema.go) so IMAP LIST/basic SEARCH don't need to decrypt every message.
|
|
func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, messageIDHeader, from, subject string) (uid int64, err error) {
|
|
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if mbox == nil {
|
|
return 0, fmt.Errorf("mailstore: mailbox %d not found", mailboxID)
|
|
}
|
|
if mbox.UsedBytes+int64(len(raw)) > mbox.QuotaBytes {
|
|
return 0, ErrQuotaExceeded
|
|
}
|
|
|
|
dek, err := s.UnwrapDEK(mbox.DEKWrapped, mbox.DEKNonce)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
ciphertext, nonce, err := sealAESGCM(dek, raw)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
now := time.Now()
|
|
dir := filepath.Join(s.BasePath, sanitizePathSegment(mbox.Email), folder, now.Format("2006-02-Jan"))
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return 0, err
|
|
}
|
|
name := make([]byte, 8)
|
|
rand.Read(name)
|
|
storagePath := filepath.Join(dir, hex.EncodeToString(name)+".eml.enc")
|
|
if err := os.WriteFile(storagePath, ciphertext, 0o600); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject, previewSnippet(raw))
|
|
if err != nil {
|
|
os.Remove(storagePath)
|
|
return 0, err
|
|
}
|
|
if err := s.DB.AddMailboxUsedBytes(mailboxID, int64(len(raw))); err != nil {
|
|
return 0, err
|
|
}
|
|
return uid, nil
|
|
}
|
|
|
|
// RebuildMessageCache re-derives cached_from/cached_to/cached_subject/cached_preview
|
|
// for every message already stored in mailboxID, from each message's own decrypted
|
|
// content — these fields are otherwise only ever computed once, at delivery time
|
|
// (see StoreMessage), so mail stored before a caching fix or addition landed (e.g.
|
|
// caching the From: header's display name instead of the bare envelope address, or
|
|
// the cached_preview column itself) keeps showing the old/blank value forever unless
|
|
// something re-derives it. Returns how many rows actually changed; a message that
|
|
// fails to decrypt/parse is skipped (counted in the error map, not fatal to the rest).
|
|
func (s *Store) RebuildMessageCache(mailboxID int64) (updated int, skipped map[int64]error) {
|
|
skipped = map[int64]error{}
|
|
msgs, err := s.DB.ListMessagesForMailbox(mailboxID)
|
|
if err != nil {
|
|
skipped[0] = err
|
|
return 0, skipped
|
|
}
|
|
for _, m := range msgs {
|
|
raw, err := s.FetchMessage(mailboxID, m.ID)
|
|
if err != nil {
|
|
skipped[m.ID] = err
|
|
continue
|
|
}
|
|
from := extractHeaderValue(raw, "From")
|
|
if from == "" {
|
|
from = m.CachedFrom
|
|
}
|
|
to := extractHeaderValue(raw, "To")
|
|
if to == "" {
|
|
to = m.CachedTo
|
|
}
|
|
subject := extractHeaderValue(raw, "Subject")
|
|
if subject == "" {
|
|
subject = m.CachedSubject
|
|
}
|
|
preview := previewSnippet(raw)
|
|
if from == m.CachedFrom && to == m.CachedTo && subject == m.CachedSubject && preview == m.CachedPreview {
|
|
continue // already correct — don't churn a write for nothing
|
|
}
|
|
if err := s.DB.UpdateMessageCachedFields(m.ID, from, to, subject, preview); err != nil {
|
|
skipped[m.ID] = err
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
return updated, skipped
|
|
}
|
|
|
|
// FetchMessage decrypts a stored message on demand. Plaintext is never written to disk
|
|
// or cached — only returned to the caller.
|
|
func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) {
|
|
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if msg == nil {
|
|
return nil, fmt.Errorf("mailstore: message %d not found in mailbox %d", uid, mailboxID)
|
|
}
|
|
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mbox == nil {
|
|
return nil, fmt.Errorf("mailstore: mailbox %d not found", mailboxID)
|
|
}
|
|
dek, err := s.UnwrapDEK(mbox.DEKWrapped, mbox.DEKNonce)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ciphertext, err := os.ReadFile(msg.StoragePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return openAESGCM(dek, ciphertext, msg.Nonce)
|
|
}
|
|
|
|
// DeleteMessage removes the on-disk ciphertext, the index row, and frees the quota.
|
|
func (s *Store) DeleteMessage(mailboxID, uid int64) error {
|
|
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
if err := os.Remove(msg.StoragePath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err := s.DB.DeleteMessage(mailboxID, uid); err != nil {
|
|
return err
|
|
}
|
|
return s.DB.AddMailboxUsedBytes(mailboxID, -msg.SizeBytes)
|
|
}
|