#5: F2 sub-project 2 - IMAP SEARCH, EXPUNGE, COPY, MOVE
Adds the second F2 sub-project on top of the merged IMAP core: full SEARCH criteria matching (flags, dates, size, headers, body/text, NOT/OR), EXPUNGE (soft-delete via expunged_at), COPY, and MOVE (RFC 6851, advertised via CapMove/CapUIDPlus). Verified end-to-end with a real go-imap/v2 client against embedded Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de082a190c
commit
0ec0698079
15 files changed
+1378
-21
No files matched your search
@@ -0,0 +1,92 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ExpungeRepository implements port.ExpungeRepository against Postgres.
|
||||
type ExpungeRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewExpungeRepository(pool *pgxpool.Pool) *ExpungeRepository {
|
||||
return &ExpungeRepository{pool: 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
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user