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:
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
|
||||
}
|
||||
Reference in new issue
Block a user