refactor(mail): one MailStore changes message state for every path

Flags, expunge, move, copy and folder deletion each existed twice: once for IMAP
(FlagRepository, ExpungeRepository, CopyRepository) and once for the webmail
(WebmailRepository). They kept the cached numbers true on their own and drifted:

- reading a message over IMAP never moved the folder's unread counter
- deleting in the webmail, or deleting a folder, never gave the quota back
- an IMAP expunge could push a counter below zero

MailStore now holds each state change once, in one transaction, with the same
rules everywhere: counters never go negative, a change that alters what a client
can observe raises the folder's modseq (one that alters nothing does not), the
quota follows the bytes, and a message is located by folder. The four repositories
are thin adapters that only translate how each path names a message.

The contract test added before this change (which failed on 8 of its cases)
now passes, as does the full suite.
This commit is contained in:
Lucas Rayan committed 2026-10-16 13:15:32 -03:00
1 parent e63b68acb2
commit caf52c0225
5 files changed
+550 -593

No files matched your search

@@ -2,91 +2,21 @@ package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExpungeRepository implements port.ExpungeRepository against Postgres.
// ExpungeRepository implements port.ExpungeRepository (IMAP EXPUNGE). MailStore
// does the work - counters, modseq and the quota all move there, for every path.
type ExpungeRepository struct {
pool *pgxpool.Pool
store *MailStore
}
func NewExpungeRepository(pool *pgxpool.Pool) *ExpungeRepository {
return &ExpungeRepository{pool: pool}
return &ExpungeRepository{store: NewMailStore(pool)}
}
// Expunge marks each message identified by (folderID, uid) in uids as
// expunged, decrements the folder's total_count (and unread_count for any
// message that lacked \Seen), and decrements the owning mailbox's
// used_bytes by the message's size_bytes. used_bytes is logical,
// message-level accounting (mirroring how delivery and COPY add to it per
// message even when the underlying blob is deduplicated), so it is
// decremented here independently of message_blobs.ref_count, whose
// reclamation is deferred to a future GC task. One transaction per call;
// each uid is processed sequentially within it.
func (r *ExpungeRepository) Expunge(ctx context.Context, folderID string, uids []uint32) error {
if len(uids) == 0 {
return nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
for _, uid := range uids {
var wasSeen bool
var mailboxID string
var sizeBytes int64
err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM message_flags mf
JOIN email_messages m ON m.id = mf.message_id AND m.received_at = mf.received_at
WHERE m.folder_id = $1 AND m.uid = $2 AND mf.flag = '\Seen'
), m.mailbox_id, m.size_bytes
FROM email_messages m
WHERE m.folder_id = $1 AND m.uid = $2
`, folderID, uid).Scan(&wasSeen, &mailboxID, &sizeBytes)
if err != nil {
return fmt.Errorf("check seen flag for uid %d: %w", uid, err)
}
tag, err := tx.Exec(ctx, `
UPDATE email_messages SET expunged_at = NOW()
WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
`, folderID, uid)
if err != nil {
return fmt.Errorf("expunge uid %d: %w", uid, err)
}
if tag.RowsAffected() == 0 {
// Nothing to expunge (already expunged or never existed) - the
// caller already verified \Deleted, so don't touch counters for
// a message that wasn't actually there.
continue
}
unreadDelta := 0
if !wasSeen {
unreadDelta = 1
}
if _, err := tx.Exec(ctx, `
UPDATE folders SET total_count = total_count - 1, unread_count = unread_count - $1, highest_modseq = highest_modseq + 1
WHERE id = $2
`, unreadDelta, folderID); err != nil {
return fmt.Errorf("decrement folder counters for uid %d: %w", uid, err)
}
if _, err := tx.Exec(ctx, `
UPDATE mailboxes SET used_bytes = used_bytes - $1
WHERE id = $2
`, sizeBytes, mailboxID); err != nil {
return fmt.Errorf("decrement mailbox used_bytes for uid %d: %w", uid, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
_, err := r.store.Expunge(ctx, folderID, uids)
return err
}