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