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
+542
-585
No files matched your search
@@ -2,133 +2,21 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CopyRepository implements port.CopyRepository against Postgres. One
|
// CopyRepository implements port.CopyRepository (IMAP COPY) over MailStore.
|
||||||
// transaction per copied message, mirroring InboundMessageRepository's
|
|
||||||
// established per-message transactional pattern (PLAN.md section 6.1).
|
|
||||||
type CopyRepository struct {
|
type CopyRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCopyRepository(pool *pgxpool.Pool) *CopyRepository {
|
func NewCopyRepository(pool *pgxpool.Pool) *CopyRepository {
|
||||||
return &CopyRepository{pool: pool}
|
return &CopyRepository{store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CopyRepository) CopyMessages(ctx context.Context, sourceFolderID string, uids []uint32, destFolderID string) ([]port.CopiedMessage, error) {
|
func (r *CopyRepository) CopyMessages(ctx context.Context, sourceFolderID string, uids []uint32, destFolderID string) ([]port.CopiedMessage, error) {
|
||||||
results := make([]port.CopiedMessage, 0, len(uids))
|
return r.store.Copy(ctx, sourceFolderID, uids, destFolderID)
|
||||||
for _, uid := range uids {
|
|
||||||
destUID, err := r.copyOne(ctx, sourceFolderID, uid, destFolderID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("copy uid %d: %w", uid, err)
|
|
||||||
}
|
|
||||||
results = append(results, port.CopiedMessage{SourceUID: uid, DestUID: destUID})
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *CopyRepository) copyOne(ctx context.Context, sourceFolderID string, sourceUID uint32, destFolderID string) (uint32, error) {
|
|
||||||
tx, err := r.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var sourceMessageID string
|
|
||||||
var blobID, senderAddress string
|
|
||||||
var recipientAddresses []string
|
|
||||||
var sizeBytes int64
|
|
||||||
var spfResult, dkimResult, dmarcResult *string
|
|
||||||
var fromDisplayName, subject, snippet, messageIDHeader, inReplyTo *string
|
|
||||||
var threadID *uuid.UUID
|
|
||||||
var hasAttachments bool
|
|
||||||
var arcResult, daneStatus, tlsVersion, spamVerdict, virusName *string
|
|
||||||
var spamScore float64
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
SELECT id, blob_id, sender_address, recipient_addresses, size_bytes, spf_result, dkim_result, dmarc_result,
|
|
||||||
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
|
||||||
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
|
||||||
FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
`, sourceFolderID, sourceUID).Scan(&sourceMessageID, &blobID, &senderAddress, &recipientAddresses, &sizeBytes, &spfResult, &dkimResult, &dmarcResult,
|
|
||||||
&fromDisplayName, &subject, &snippet, &messageIDHeader, &inReplyTo, &threadID, &hasAttachments,
|
|
||||||
&arcResult, &daneStatus, &tlsVersion, &spamScore, &spamVerdict, &virusName)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("find source message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uid_next is BIGINT; the destination UID we allocate for this message
|
|
||||||
// is the folder's current uid_next value before advancing it.
|
|
||||||
var destUIDNext int64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
SELECT uid_next FROM folders WHERE id = $1 FOR UPDATE
|
|
||||||
`, destFolderID).Scan(&destUIDNext); err != nil {
|
|
||||||
return 0, fmt.Errorf("lock destination folder: %w", err)
|
|
||||||
}
|
|
||||||
destUID := uint32(destUIDNext)
|
|
||||||
|
|
||||||
var destModSeq int64
|
|
||||||
if err := tx.QueryRow(ctx, `UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1, total_count = total_count + 1 WHERE id = $1 RETURNING highest_modseq`, destFolderID).Scan(&destModSeq); err != nil {
|
|
||||||
return 0, fmt.Errorf("advance destination uid_next and highest_modseq: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var wasSeen bool
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
SELECT EXISTS (SELECT 1 FROM message_flags WHERE message_id = $1 AND flag = '\Seen')
|
|
||||||
`, sourceMessageID).Scan(&wasSeen); err != nil {
|
|
||||||
return 0, fmt.Errorf("check source seen flag: %w", err)
|
|
||||||
}
|
|
||||||
if !wasSeen {
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE folders SET unread_count = unread_count + 1 WHERE id = $1`, destFolderID); err != nil {
|
|
||||||
return 0, fmt.Errorf("increment destination unread_count: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var destMailboxID string
|
|
||||||
if err := tx.QueryRow(ctx, `SELECT mailbox_id FROM folders WHERE id = $1`, destFolderID).Scan(&destMailboxID); err != nil {
|
|
||||||
return 0, fmt.Errorf("find destination mailbox: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var destMessageID string
|
|
||||||
var destReceivedAt any
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
INSERT INTO email_messages (
|
|
||||||
mailbox_id, folder_id, uid, modseq, blob_id,
|
|
||||||
sender_address, recipient_addresses, size_bytes,
|
|
||||||
spf_result, dkim_result, dmarc_result,
|
|
||||||
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
|
||||||
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
|
||||||
RETURNING id, received_at
|
|
||||||
`, destMailboxID, destFolderID, destUID, destModSeq, blobID, senderAddress, recipientAddresses, sizeBytes, spfResult, dkimResult, dmarcResult,
|
|
||||||
fromDisplayName, subject, snippet, messageIDHeader, inReplyTo, threadID, hasAttachments,
|
|
||||||
arcResult, daneStatus, tlsVersion, spamScore, spamVerdict, virusName).Scan(&destMessageID, &destReceivedAt)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("insert copied message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = $1`, blobID); err != nil {
|
|
||||||
return 0, fmt.Errorf("increment blob ref_count: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = used_bytes + $1 WHERE id = $2`, sizeBytes, destMailboxID); err != nil {
|
|
||||||
return 0, fmt.Errorf("increment destination mailbox used_bytes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
SELECT $1, $2, flag FROM message_flags WHERE message_id = $3
|
|
||||||
`, destMessageID, destReceivedAt, sourceMessageID); err != nil {
|
|
||||||
return 0, fmt.Errorf("copy flags: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return destUID, nil
|
|
||||||
}
|
}
|
||||||
@@ -2,91 +2,21 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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 {
|
type ExpungeRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExpungeRepository(pool *pgxpool.Pool) *ExpungeRepository {
|
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 {
|
func (r *ExpungeRepository) Expunge(ctx context.Context, folderID string, uids []uint32) error {
|
||||||
if len(uids) == 0 {
|
_, err := r.store.Expunge(ctx, folderID, uids)
|
||||||
return nil
|
return err
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
@@ -2,95 +2,23 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FlagRepository implements port.FlagRepository against Postgres.
|
// FlagRepository implements port.FlagRepository (IMAP STORE). The work is done
|
||||||
|
// by MailStore, which every path shares; this only names the message the way
|
||||||
|
// IMAP does, by folder and UID.
|
||||||
type FlagRepository struct {
|
type FlagRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFlagRepository(pool *pgxpool.Pool) *FlagRepository {
|
func NewFlagRepository(pool *pgxpool.Pool) *FlagRepository {
|
||||||
return &FlagRepository{pool: pool}
|
return &FlagRepository{store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FlagRepository) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
|
func (r *FlagRepository) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
|
||||||
tx, err := r.pool.Begin(ctx)
|
return r.store.SetFlags(ctx, folderID, uid, op, flags, unchangedSince)
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var currentModSeq uint64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
SELECT modseq FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
`, folderID, uid).Scan(¤tModSeq); err != nil {
|
|
||||||
return false, fmt.Errorf("find message folder=%s uid=%d: %w", folderID, uid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if unchangedSince > 0 && currentModSeq > unchangedSince {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var newModSeq uint64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
UPDATE folders SET highest_modseq = highest_modseq + 1 WHERE id = $1 RETURNING highest_modseq
|
|
||||||
`, folderID).Scan(&newModSeq); err != nil {
|
|
||||||
return false, fmt.Errorf("advance highest_modseq for folder %s: %w", folderID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE email_messages SET modseq = $1 WHERE folder_id = $2 AND uid = $3 AND expunged_at IS NULL
|
|
||||||
`, newModSeq, folderID, uid); err != nil {
|
|
||||||
return false, fmt.Errorf("update message modseq: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch op {
|
|
||||||
case port.FlagOpAdd:
|
|
||||||
for _, flag := range flags {
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
SELECT id, received_at, $3 FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`, folderID, uid, flag); err != nil {
|
|
||||||
return false, fmt.Errorf("add flag %q: %w", flag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case port.FlagOpDel:
|
|
||||||
for _, flag := range flags {
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
DELETE FROM message_flags WHERE (message_id, received_at) IN (
|
|
||||||
SELECT id, received_at FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
) AND flag = $3
|
|
||||||
`, folderID, uid, flag); err != nil {
|
|
||||||
return false, fmt.Errorf("delete flag %q: %w", flag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case port.FlagOpSet:
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
DELETE FROM message_flags WHERE (message_id, received_at) IN (
|
|
||||||
SELECT id, received_at FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
)
|
|
||||||
`, folderID, uid); err != nil {
|
|
||||||
return false, fmt.Errorf("clear flags before set: %w", err)
|
|
||||||
}
|
|
||||||
for _, flag := range flags {
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
SELECT id, received_at, $3 FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`, folderID, uid, flag); err != nil {
|
|
||||||
return false, fmt.Errorf("set flag %q: %w", flag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return false, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailStore is the one place that changes the state of messages and folders.
|
||||||
|
//
|
||||||
|
// IMAP, POP3, the webmail and the server's own filing all end up changing the
|
||||||
|
// same rows, and those rows carry cached numbers that have to stay true: the
|
||||||
|
// folder's unread and total counters, its highest modseq (what a CONDSTORE
|
||||||
|
// client uses to learn that something changed), the next UID, and the
|
||||||
|
// mailbox's used bytes. When each path implemented this on its own they
|
||||||
|
// drifted apart - reading over IMAP never moved the unread counter, deleting in
|
||||||
|
// the webmail never gave the quota back, a repeated expunge could push a
|
||||||
|
// counter below zero - and every fix had to be made twice, which is how one of
|
||||||
|
// the two went missing.
|
||||||
|
//
|
||||||
|
// So each state change is written here, once, in one transaction, and every
|
||||||
|
// path calls it. The repositories IMAP and the webmail used are now thin
|
||||||
|
// adapters over this: they translate their way of naming things (a folder id
|
||||||
|
// and a UID, or a mailbox, a folder name and a UID) and nothing else.
|
||||||
|
//
|
||||||
|
// Rules held for every method:
|
||||||
|
//
|
||||||
|
// - A counter never goes below zero, so a double operation cannot corrupt it.
|
||||||
|
// - An operation that changes what a client can observe raises the folder's
|
||||||
|
// modseq; one that changes nothing does not.
|
||||||
|
// - A message is located by folder, so another mailbox's mail cannot be
|
||||||
|
// reached by guessing a UID.
|
||||||
|
type MailStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMailStore(pool *pgxpool.Pool) *MailStore {
|
||||||
|
return &MailStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageRow is the slice of a message every operation needs.
|
||||||
|
type messageRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
ReceivedAt any
|
||||||
|
MailboxID string
|
||||||
|
Size int64
|
||||||
|
ModSeq uint64
|
||||||
|
Seen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMessage finds a live message by folder and UID and locks it, so two
|
||||||
|
// concurrent changes to the same message cannot both read the old state.
|
||||||
|
func loadMessage(ctx context.Context, tx pgx.Tx, folderID string, uid uint32) (*messageRow, error) {
|
||||||
|
var m messageRow
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT e.id, e.received_at, e.mailbox_id::text, e.size_bytes, e.modseq,
|
||||||
|
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
|
||||||
|
WHERE e.folder_id = $1 AND e.uid = $2 AND e.expunged_at IS NULL
|
||||||
|
FOR UPDATE
|
||||||
|
`, folderID, int64(uid)).Scan(&m.ID, &m.ReceivedAt, &m.MailboxID, &m.Size, &m.ModSeq, &m.Seen)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find message folder=%s uid=%d: %w", folderID, uid, err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFolder finds a folder of a mailbox by its special-use role or by its
|
||||||
|
// name, the way the webmail and IMAP clients both address one ("trash" and
|
||||||
|
// "Trash" reach the same place; a folder the user made has no role and is
|
||||||
|
// reached by name). port.ErrFolderMissing when there is none.
|
||||||
|
func (s *MailStore) ResolveFolder(ctx context.Context, mailboxID, ref string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text 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
|
||||||
|
`, mailboxID, ref).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", port.ErrFolderMissing
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFlags applies op to one message's flags. Add and Del touch individual
|
||||||
|
// flags, Set replaces the whole set. If unchangedSince > 0 and the message has
|
||||||
|
// changed since, nothing is written and updated is false (RFC 7162
|
||||||
|
// UNCHANGEDSINCE).
|
||||||
|
//
|
||||||
|
// \Seen is the flag with a side effect: the folder's unread counter follows it,
|
||||||
|
// in the same transaction, by looking at the flag before and after rather than
|
||||||
|
// at which operation was asked for. That is what keeps STORE +FLAGS, STORE
|
||||||
|
// -FLAGS, STORE FLAGS (replace) and the webmail's mark-as-read all counting the
|
||||||
|
// same way.
|
||||||
|
func (s *MailStore) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
msg, err := loadMessage(ctx, tx, folderID, uid)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if unchangedSince > 0 && msg.ModSeq > unchangedSince {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := flagSet(ctx, tx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case port.FlagOpAdd:
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING`, msg.ID, msg.ReceivedAt, flag); err != nil {
|
||||||
|
return false, fmt.Errorf("add flag %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case port.FlagOpDel:
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = $3`,
|
||||||
|
msg.ID, msg.ReceivedAt, flag); err != nil {
|
||||||
|
return false, fmt.Errorf("delete flag %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case port.FlagOpSet:
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2`, msg.ID, msg.ReceivedAt); err != nil {
|
||||||
|
return false, fmt.Errorf("clear flags before set: %w", err)
|
||||||
|
}
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING`, msg.ID, msg.ReceivedAt, flag); err != nil {
|
||||||
|
return false, fmt.Errorf("set flag %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := flagSet(ctx, tx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed: no modseq, no counter. A client that stores a flag the
|
||||||
|
// message already has must not be told the folder changed.
|
||||||
|
if sameFlags(before, after) {
|
||||||
|
return true, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadDelta := 0
|
||||||
|
switch {
|
||||||
|
case before[`\Seen`] && !after[`\Seen`]:
|
||||||
|
unreadDelta = 1
|
||||||
|
case !before[`\Seen`] && after[`\Seen`]:
|
||||||
|
unreadDelta = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var modseq int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET highest_modseq = highest_modseq + 1,
|
||||||
|
unread_count = GREATEST(0, unread_count + $2)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING highest_modseq`, folderID, unreadDelta).Scan(&modseq); err != nil {
|
||||||
|
return false, fmt.Errorf("advance folder modseq: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE email_messages SET modseq = $3 WHERE id = $1 AND received_at = $2`,
|
||||||
|
msg.ID, msg.ReceivedAt, modseq); err != nil {
|
||||||
|
return false, fmt.Errorf("update message modseq: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagSet(ctx context.Context, tx pgx.Tx, msg *messageRow) (map[string]bool, error) {
|
||||||
|
rows, err := tx.Query(ctx, `SELECT flag FROM message_flags WHERE message_id = $1 AND received_at = $2`, msg.ID, msg.ReceivedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read flags: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
set := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var f string
|
||||||
|
if err := rows.Scan(&f); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set[f] = true
|
||||||
|
}
|
||||||
|
return set, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameFlags(a, b map[string]bool) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k := range a {
|
||||||
|
if !b[k] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expunge removes messages from a folder (soft delete: the row stays, marked,
|
||||||
|
// and the blob stays referenced until the retention sweep, so a mistake is
|
||||||
|
// recoverable and a client holding the UID still resolves it).
|
||||||
|
//
|
||||||
|
// For each message that was really there it lowers the folder's total, lowers
|
||||||
|
// unread if the message was unread, and gives its bytes back to the mailbox's
|
||||||
|
// quota. A message that is already gone is skipped, touching nothing, which is
|
||||||
|
// what makes a repeated expunge safe. It returns how many messages were
|
||||||
|
// removed.
|
||||||
|
func (s *MailStore) Expunge(ctx context.Context, folderID string, uids []uint32) (int, error) {
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
removed := 0
|
||||||
|
for _, uid := range uids {
|
||||||
|
msg, err := loadMessage(ctx, tx, folderID, uid)
|
||||||
|
if errors.Is(err, ErrMessageNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE email_messages SET expunged_at = NOW() WHERE id = $1 AND received_at = $2`,
|
||||||
|
msg.ID, msg.ReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("expunge uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadDelta := 0
|
||||||
|
if !msg.Seen {
|
||||||
|
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 0, fmt.Errorf("lower folder counters for uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = GREATEST(0, used_bytes - $1) WHERE id = $2`,
|
||||||
|
msg.Size, msg.MailboxID); err != nil {
|
||||||
|
return 0, fmt.Errorf("give quota back for uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move relocates one message to another folder of the same mailbox and returns
|
||||||
|
// the UID it has there. The message keeps its identity, its flags and its blob;
|
||||||
|
// only the folder and the UID change, because a UID means something only inside
|
||||||
|
// one folder. The quota does not move: the bytes stay in the same mailbox.
|
||||||
|
func (s *MailStore) Move(ctx context.Context, sourceFolderID string, uid uint32, destFolderID string) (uint32, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
msg, err := loadMessage(ctx, tx, sourceFolderID, uid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var destUID int64
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT uid_next FROM folders WHERE id = $1 FOR UPDATE`, destFolderID).Scan(&destUID); err != nil {
|
||||||
|
return 0, fmt.Errorf("lock destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unread := 0
|
||||||
|
if !msg.Seen {
|
||||||
|
unread = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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`, destFolderID, unread).Scan(&modseq); err != nil {
|
||||||
|
return 0, fmt.Errorf("advance destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE email_messages SET folder_id = $3, uid = $4, modseq = $5 WHERE id = $1 AND received_at = $2`,
|
||||||
|
msg.ID, msg.ReceivedAt, destFolderID, destUID, modseq); err != nil {
|
||||||
|
return 0, fmt.Errorf("move message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, unread); err != nil {
|
||||||
|
return 0, fmt.Errorf("lower source folder counters: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint32(destUID), tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy duplicates messages into another folder. Each copy is its own message
|
||||||
|
// (own UID, own flags copied from the original) that shares the original's
|
||||||
|
// blob, and it counts against the quota like any other message. One transaction
|
||||||
|
// per message, so a failure part-way leaves the earlier copies whole.
|
||||||
|
func (s *MailStore) Copy(ctx context.Context, sourceFolderID string, uids []uint32, destFolderID string) ([]port.CopiedMessage, error) {
|
||||||
|
results := make([]port.CopiedMessage, 0, len(uids))
|
||||||
|
for _, uid := range uids {
|
||||||
|
destUID, err := s.copyOne(ctx, sourceFolderID, uid, destFolderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copy uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
results = append(results, port.CopiedMessage{SourceUID: uid, DestUID: destUID})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MailStore) copyOne(ctx context.Context, sourceFolderID string, sourceUID uint32, destFolderID string) (uint32, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
src, err := loadMessage(ctx, tx, sourceFolderID, sourceUID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var destUID int64
|
||||||
|
var destMailboxID string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT uid_next, mailbox_id::text FROM folders WHERE id = $1 FOR UPDATE`, destFolderID).Scan(&destUID, &destMailboxID); err != nil {
|
||||||
|
return 0, fmt.Errorf("lock destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unread := 0
|
||||||
|
if !src.Seen {
|
||||||
|
unread = 1
|
||||||
|
}
|
||||||
|
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`, destFolderID, unread).Scan(&modseq); err != nil {
|
||||||
|
return 0, fmt.Errorf("advance destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newID uuid.UUID
|
||||||
|
var newReceivedAt any
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO email_messages (
|
||||||
|
mailbox_id, folder_id, uid, modseq, blob_id,
|
||||||
|
sender_address, recipient_addresses, size_bytes,
|
||||||
|
spf_result, dkim_result, dmarc_result,
|
||||||
|
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
||||||
|
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
||||||
|
)
|
||||||
|
SELECT $2::uuid, $3::uuid, $4::bigint, $5::bigint, blob_id,
|
||||||
|
sender_address, recipient_addresses, size_bytes,
|
||||||
|
spf_result, dkim_result, dmarc_result,
|
||||||
|
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
||||||
|
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
||||||
|
FROM email_messages WHERE id = $1 AND received_at = $6
|
||||||
|
RETURNING id, received_at`,
|
||||||
|
src.ID, destMailboxID, destFolderID, destUID, modseq, src.ReceivedAt).Scan(&newID, &newReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("insert copied message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = (SELECT blob_id FROM email_messages WHERE id = $1 AND received_at = $2)`,
|
||||||
|
newID, newReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("share the blob: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = used_bytes + $1 WHERE id = $2`, src.Size, destMailboxID); err != nil {
|
||||||
|
return 0, fmt.Errorf("count the copy against the quota: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag)
|
||||||
|
SELECT $1, $2, flag FROM message_flags WHERE message_id = $3 AND received_at = $4`,
|
||||||
|
newID, newReceivedAt, src.ID, src.ReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("copy flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint32(destUID), tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder removes a folder the user made, with the mail in it. The
|
||||||
|
// messages are expunged rather than deleted (recoverable, like any expunge) and
|
||||||
|
// their bytes go back to the quota.
|
||||||
|
func (s *MailStore) DeleteFolder(ctx context.Context, folderID string) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var mailboxID string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT mailbox_id::text FROM folders WHERE id = $1 FOR UPDATE`, folderID).Scan(&mailboxID); err != nil {
|
||||||
|
return fmt.Errorf("lock folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var freed int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
WITH gone AS (
|
||||||
|
UPDATE email_messages SET expunged_at = NOW()
|
||||||
|
WHERE folder_id = $1 AND expunged_at IS NULL
|
||||||
|
RETURNING size_bytes)
|
||||||
|
SELECT COALESCE(SUM(size_bytes), 0) FROM gone`, folderID).Scan(&freed); err != nil {
|
||||||
|
return fmt.Errorf("expunge the folder's messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = GREATEST(0, used_bytes - $1) WHERE id = $2`, freed, mailboxID); err != nil {
|
||||||
|
return fmt.Errorf("give quota back: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folderID); err != nil {
|
||||||
|
return fmt.Errorf("delete folder: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
@@ -20,10 +20,11 @@ var ErrMessageNotFound = errors.New("webmail: message not found")
|
|||||||
// WebmailRepository implements the webmail read side against Postgres.
|
// WebmailRepository implements the webmail read side against Postgres.
|
||||||
type WebmailRepository struct {
|
type WebmailRepository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository {
|
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) {
|
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
|
||||||
@@ -157,318 +158,96 @@ func (r *WebmailRepository) GetMessageBlob(
|
|||||||
return blobID, err
|
return blobID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSeen keeps the \Seen flag and the folder's unread counter in step. They
|
// The methods below change state. They do not do it themselves: the webmail
|
||||||
// are updated together in one transaction, because a counter that disagrees
|
// names a message by mailbox, folder name and UID, MailStore by folder id and
|
||||||
// with the flags is what makes an inbox show unread mail that is not there.
|
// UID, and these translate. Every counter, modseq and quota rule lives in
|
||||||
func (r *WebmailRepository) MarkSeen(
|
// MailStore, which IMAP uses too, so the two can no longer disagree.
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32, seen bool,
|
|
||||||
) error {
|
// messageFolder resolves the folder a message is addressed in. A folder that is
|
||||||
tx, err := r.pool.Begin(ctx)
|
// 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 {
|
if err != nil {
|
||||||
return err
|
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
|
// Expunge removes one message for good (soft delete) and gives its bytes back.
|
||||||
var receivedAt any
|
func (r *WebmailRepository) Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error {
|
||||||
var folderID string
|
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||||
err = tx.QueryRow(ctx, `
|
if err != nil {
|
||||||
SELECT e.id, e.received_at, f.id::text FROM email_messages e
|
return err
|
||||||
JOIN folders f ON f.id = e.folder_id
|
}
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
removed, err := r.store.Expunge(ctx, folderID, []uint32{uid})
|
||||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
if err != nil {
|
||||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
|
return err
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
}
|
||||||
|
if removed == 0 {
|
||||||
return ErrMessageNotFound
|
return ErrMessageNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
return 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
|
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
|
||||||
// callers cannot drift.
|
// callers cannot drift.
|
||||||
const trashTarget = "trash"
|
const trashTarget = "trash"
|
||||||
|
|
||||||
func (r *WebmailRepository) moveInto(
|
// MoveToTrash is what "delete" means in a mail client: the message goes to the
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
|
// Trash folder and keeps its identity. Deleting from Trash itself is Expunge's
|
||||||
) (uint32, error) {
|
// job, so asking to move out of Trash reports port.ErrAlreadyInTrash.
|
||||||
tx, err := r.pool.Begin(ctx)
|
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, 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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
destID, err := r.store.ResolveFolder(ctx, mailboxID, target)
|
||||||
|
if errors.Is(err, port.ErrFolderMissing) {
|
||||||
var messageID uuid.UUID
|
if target == trashTarget {
|
||||||
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
|
return 0, port.ErrNoTrashFolder
|
||||||
}
|
}
|
||||||
|
return 0, port.ErrFolderMissing
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if trashFolderID == sourceFolderID {
|
if destID == sourceID {
|
||||||
return 0, port.ErrAlreadyInTrash
|
return 0, port.ErrAlreadyInTrash
|
||||||
}
|
}
|
||||||
|
return r.store.Move(ctx, sourceID, uid, destID)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
|
||||||
if b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- folders the mailbox owner creates for themselves --------------------
|
// --- folders the mailbox owner creates for themselves --------------------
|
||||||
@@ -507,20 +286,12 @@ func (r *WebmailRepository) RenameFolder(ctx context.Context, mailboxID, from, t
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFolder removes a folder and the mail filed in it.
|
// 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,
|
||||||
// The messages are expunged rather than deleted outright, matching what the
|
// both inside MailStore.
|
||||||
// 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.
|
|
||||||
func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name string) error {
|
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
|
var folderID string
|
||||||
err = tx.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id::text FROM folders
|
SELECT id::text FROM folders
|
||||||
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
|
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
|
||||||
`, mailboxID, name).Scan(&folderID)
|
`, mailboxID, name).Scan(&folderID)
|
||||||
@@ -530,16 +301,5 @@ func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return r.store.DeleteFolder(ctx, folderID)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user