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
@@ -19,11 +19,12 @@ var ErrMessageNotFound = errors.New("webmail: message not found")
|
||||
|
||||
// WebmailRepository implements the webmail read side against Postgres.
|
||||
type WebmailRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
store *MailStore
|
||||
}
|
||||
|
||||
func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository {
|
||||
return &WebmailRepository{pool: pool}
|
||||
return &WebmailRepository{pool: pool, store: NewMailStore(pool)}
|
||||
}
|
||||
|
||||
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
|
||||
@@ -157,318 +158,96 @@ func (r *WebmailRepository) GetMessageBlob(
|
||||
return blobID, err
|
||||
}
|
||||
|
||||
// MarkSeen keeps the \Seen flag and the folder's unread counter in step. They
|
||||
// are updated together in one transaction, because a counter that disagrees
|
||||
// with the flags is what makes an inbox show unread mail that is not there.
|
||||
func (r *WebmailRepository) MarkSeen(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32, seen bool,
|
||||
) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
// The methods below change state. They do not do it themselves: the webmail
|
||||
// names a message by mailbox, folder name and UID, MailStore by folder id and
|
||||
// UID, and these translate. Every counter, modseq and quota rule lives in
|
||||
// MailStore, which IMAP uses too, so the two can no longer disagree.
|
||||
|
||||
// messageFolder resolves the folder a message is addressed in. A folder that is
|
||||
// not there and a message that is not there look the same to the caller, so a
|
||||
// stranger cannot probe which folders exist.
|
||||
func (r *WebmailRepository) messageFolder(ctx context.Context, mailboxID, folderName string) (string, error) {
|
||||
id, err := r.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||
if errors.Is(err, port.ErrFolderMissing) {
|
||||
return "", ErrMessageNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// MarkSeen sets or clears \Seen. The unread counter follows inside MailStore.
|
||||
func (r *WebmailRepository) MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error {
|
||||
return r.setFlag(ctx, mailboxID, folderName, uid, `\Seen`, seen)
|
||||
}
|
||||
|
||||
// MarkFlagged sets or clears \Flagged.
|
||||
func (r *WebmailRepository) MarkFlagged(ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool) error {
|
||||
return r.setFlag(ctx, mailboxID, folderName, uid, `\Flagged`, flagged)
|
||||
}
|
||||
|
||||
func (r *WebmailRepository) setFlag(ctx context.Context, mailboxID, folderName string, uid uint32, flag string, on bool) error {
|
||||
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
op := port.FlagOpAdd
|
||||
if !on {
|
||||
op = port.FlagOpDel
|
||||
}
|
||||
_, err = r.store.SetFlags(ctx, folderID, uid, op, []string{flag}, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
var messageID uuid.UUID
|
||||
var receivedAt any
|
||||
var folderID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT e.id, e.received_at, f.id::text FROM email_messages e
|
||||
JOIN folders f ON f.id = e.folder_id
|
||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Expunge removes one message for good (soft delete) and gives its bytes back.
|
||||
func (r *WebmailRepository) Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error {
|
||||
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := r.store.Expunge(ctx, folderID, []uint32{uid})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if removed == 0 {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var changed int64
|
||||
if seen {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO message_flags (message_id, received_at, flag)
|
||||
VALUES ($1, $2, '\Seen') ON CONFLICT DO NOTHING
|
||||
`, messageID, receivedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected()
|
||||
} else {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Seen'
|
||||
`, messageID, receivedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected()
|
||||
}
|
||||
|
||||
// Only a real transition moves the counter, so opening an already-read
|
||||
// message repeatedly cannot drive unread_count negative.
|
||||
if changed > 0 {
|
||||
delta := 1
|
||||
if seen {
|
||||
delta = -1
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE folders SET unread_count = GREATEST(0, unread_count + $2), highest_modseq = highest_modseq + 1
|
||||
WHERE id = $1
|
||||
`, folderID, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// MarkFlagged sets or clears \Flagged. Unlike \Seen it moves no counter, but a
|
||||
// real change still bumps the folder's modseq: that is how an IMAP client
|
||||
// holding the folder open learns the flag changed without a full resync.
|
||||
func (r *WebmailRepository) MarkFlagged(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool,
|
||||
) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var messageID uuid.UUID
|
||||
var receivedAt any
|
||||
var folderID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT e.id, e.received_at, f.id::text FROM email_messages e
|
||||
JOIN folders f ON f.id = e.folder_id
|
||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var changed int64
|
||||
if flagged {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO message_flags (message_id, received_at, flag)
|
||||
VALUES ($1, $2, '\Flagged') ON CONFLICT DO NOTHING
|
||||
`, messageID, receivedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected()
|
||||
} else {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Flagged'
|
||||
`, messageID, receivedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected()
|
||||
}
|
||||
|
||||
if changed > 0 {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE folders SET highest_modseq = highest_modseq + 1 WHERE id = $1`, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Expunge marks one message deleted and keeps the folder counters in step.
|
||||
//
|
||||
// Soft delete, matching the expunged_at column the rest of the schema uses: the
|
||||
// blob stays referenced until the retention sweep, so a mistaken delete is
|
||||
// recoverable and IMAP clients holding the UID still resolve it.
|
||||
//
|
||||
// The message is located by mailbox as well as UID, so a caller cannot expunge
|
||||
// somebody else's mail by guessing a number.
|
||||
func (r *WebmailRepository) Expunge(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32,
|
||||
) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var messageID uuid.UUID
|
||||
var folderID string
|
||||
var wasUnread bool
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT e.id, f.id::text,
|
||||
NOT EXISTS (SELECT 1 FROM message_flags mf
|
||||
WHERE mf.message_id = e.id
|
||||
AND mf.received_at = e.received_at
|
||||
AND mf.flag = '\Seen')
|
||||
FROM email_messages e
|
||||
JOIN folders f ON f.id = e.folder_id
|
||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &folderID, &wasUnread)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE email_messages SET expunged_at = NOW() WHERE id = $1`, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// GREATEST guards the counters against ever going negative, which a
|
||||
// double expunge of the same row would otherwise cause.
|
||||
unreadDelta := 0
|
||||
if wasUnread {
|
||||
unreadDelta = -1
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE folders
|
||||
SET total_count = GREATEST(0, total_count - 1),
|
||||
unread_count = GREATEST(0, unread_count + $2),
|
||||
highest_modseq = highest_modseq + 1
|
||||
WHERE id = $1
|
||||
`, folderID, unreadDelta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// MoveToTrash relocates one message into the mailbox's Trash folder and
|
||||
// returns the UID it was given there.
|
||||
//
|
||||
// This is what "delete" means in a mail client, and the webmail had no way to
|
||||
// do it at all: the only removal in the codebase was Expunge, reachable solely
|
||||
// when an autosave superseded a draft. A message the user wanted rid of - the
|
||||
// empty draft left behind by a sent message, most visibly - could not be
|
||||
// removed from any screen.
|
||||
//
|
||||
// The message keeps its identity and its blob; only the folder and the UID
|
||||
// change, because a UID is meaningful only within one folder. Deleting from
|
||||
// Trash itself is Expunge's job, not this one's.
|
||||
func (r *WebmailRepository) MoveToTrash(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32,
|
||||
) (uint32, error) {
|
||||
return r.moveInto(ctx, mailboxID, folderName, uid, trashTarget)
|
||||
}
|
||||
|
||||
// MoveToFolder files one message into a named folder.
|
||||
//
|
||||
// The target is matched on its special-use role or its name, the same way the
|
||||
// rest of this repository addresses folders, so "archive" and "Archive" both
|
||||
// reach the same place and a folder the user created themselves - which has no
|
||||
// role - is reachable by name.
|
||||
func (r *WebmailRepository) MoveToFolder(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
|
||||
) (uint32, error) {
|
||||
return r.moveInto(ctx, mailboxID, folderName, uid, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
|
||||
// callers cannot drift.
|
||||
const trashTarget = "trash"
|
||||
|
||||
func (r *WebmailRepository) moveInto(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
|
||||
) (uint32, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var messageID uuid.UUID
|
||||
var sourceFolderID string
|
||||
var wasUnread bool
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT e.id, f.id::text,
|
||||
NOT EXISTS (SELECT 1 FROM message_flags mf
|
||||
WHERE mf.message_id = e.id
|
||||
AND mf.received_at = e.received_at
|
||||
AND mf.flag = '\Seen')
|
||||
FROM email_messages e
|
||||
JOIN folders f ON f.id = e.folder_id
|
||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &sourceFolderID, &wasUnread)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Deleting something already in Trash has no further destination, so the
|
||||
// caller is told to expunge instead of the message being moved onto itself.
|
||||
var trashFolderID string
|
||||
var trashUID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id::text, uid_next FROM folders
|
||||
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
|
||||
ORDER BY special_use IS NOT NULL DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, mailboxID, target).Scan(&trashFolderID, &trashUID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, port.ErrNoTrashFolder
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if trashFolderID == sourceFolderID {
|
||||
return 0, port.ErrAlreadyInTrash
|
||||
}
|
||||
|
||||
var modseq int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1,
|
||||
total_count = total_count + 1, unread_count = unread_count + $2
|
||||
WHERE id = $1
|
||||
RETURNING highest_modseq
|
||||
`, trashFolderID, boolToInt(wasUnread)).Scan(&modseq); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE email_messages SET folder_id = $2, uid = $3, modseq = $4
|
||||
WHERE id = $1
|
||||
`, messageID, trashFolderID, trashUID, modseq); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
unreadDelta := 0
|
||||
if wasUnread {
|
||||
unreadDelta = -1
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE folders
|
||||
SET total_count = GREATEST(0, total_count - 1),
|
||||
unread_count = GREATEST(0, unread_count + $2),
|
||||
highest_modseq = highest_modseq + 1
|
||||
WHERE id = $1
|
||||
`, sourceFolderID, unreadDelta); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint32(trashUID), nil
|
||||
// MoveToTrash is what "delete" means in a mail client: the message goes to the
|
||||
// Trash folder and keeps its identity. Deleting from Trash itself is Expunge's
|
||||
// job, so asking to move out of Trash reports port.ErrAlreadyInTrash.
|
||||
func (r *WebmailRepository) MoveToTrash(ctx context.Context, mailboxID, folderName string, uid uint32) (uint32, error) {
|
||||
return r.moveInto(ctx, mailboxID, folderName, uid, trashTarget)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
// MoveToFolder files one message into a named folder, matched by role or name.
|
||||
func (r *WebmailRepository) MoveToFolder(ctx context.Context, mailboxID, folderName string, uid uint32, target string) (uint32, error) {
|
||||
return r.moveInto(ctx, mailboxID, folderName, uid, target)
|
||||
}
|
||||
|
||||
func (r *WebmailRepository) moveInto(ctx context.Context, mailboxID, folderName string, uid uint32, target string) (uint32, error) {
|
||||
sourceID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0
|
||||
destID, err := r.store.ResolveFolder(ctx, mailboxID, target)
|
||||
if errors.Is(err, port.ErrFolderMissing) {
|
||||
if target == trashTarget {
|
||||
return 0, port.ErrNoTrashFolder
|
||||
}
|
||||
return 0, port.ErrFolderMissing
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if destID == sourceID {
|
||||
return 0, port.ErrAlreadyInTrash
|
||||
}
|
||||
return r.store.Move(ctx, sourceID, uid, destID)
|
||||
}
|
||||
|
||||
// --- folders the mailbox owner creates for themselves --------------------
|
||||
@@ -507,20 +286,12 @@ func (r *WebmailRepository) RenameFolder(ctx context.Context, mailboxID, from, t
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFolder removes a folder and the mail filed in it.
|
||||
//
|
||||
// The messages are expunged rather than deleted outright, matching what the
|
||||
// rest of the schema does: the blobs stay referenced until the retention sweep,
|
||||
// so a folder deleted by mistake is still recoverable from the database.
|
||||
// DeleteFolder removes a folder the owner made, and the mail filed in it. The
|
||||
// messages are expunged (recoverable) and their bytes go back to the quota,
|
||||
// both inside MailStore.
|
||||
func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var folderID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id::text FROM folders
|
||||
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
|
||||
`, mailboxID, name).Scan(&folderID)
|
||||
@@ -530,16 +301,5 @@ func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE email_messages SET expunged_at = NOW()
|
||||
WHERE folder_id = $1 AND expunged_at IS NULL
|
||||
`, folderID); err != nil {
|
||||
return fmt.Errorf("expunge the folder's messages: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folderID); err != nil {
|
||||
return fmt.Errorf("delete folder %q: %w", name, err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
return r.store.DeleteFolder(ctx, folderID)
|
||||
}
|
||||
Reference in new issue
Block a user