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.
306 lines
11 KiB
Go
306 lines
11 KiB
Go
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"
|
|
)
|
|
|
|
// ErrMessageNotFound is returned when a message does not exist, or exists but
|
|
// belongs to another mailbox. The two are deliberately indistinguishable:
|
|
// telling them apart would let a caller probe for other people's UIDs.
|
|
var ErrMessageNotFound = errors.New("webmail: message not found")
|
|
|
|
// WebmailRepository implements the webmail read side against Postgres.
|
|
type WebmailRepository struct {
|
|
pool *pgxpool.Pool
|
|
store *MailStore
|
|
}
|
|
|
|
func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository {
|
|
return &WebmailRepository{pool: pool, store: NewMailStore(pool)}
|
|
}
|
|
|
|
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
|
|
var id string
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT m.id::text FROM mailboxes m
|
|
JOIN domains d ON d.id = m.domain_id
|
|
WHERE m.email_address = $1 AND m.is_active AND d.is_active
|
|
`, address).Scan(&id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrMessageNotFound
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
|
|
// The counts are derived from the messages themselves rather than read out
|
|
// of folders.unread_count/total_count.
|
|
//
|
|
// Those columns are a denormalised cache maintained by every write path,
|
|
// and once any one of them was wrong nothing brought it back: the number
|
|
// the user saw stayed wrong through a reload, a logout and a fresh login,
|
|
// because the wrongness was stored. Deriving here is authoritative and
|
|
// self-healing - a folder can only ever report what is actually in it.
|
|
//
|
|
// The columns stay maintained for IMAP's sake (STATUS answers from them),
|
|
// so this is a correction at the read side, not their removal.
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT f.id::text, f.name, COALESCE(f.special_use, ''),
|
|
COUNT(e.id) FILTER (
|
|
WHERE 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')
|
|
)::int AS unread_count,
|
|
COUNT(e.id)::int AS total_count
|
|
FROM folders f
|
|
LEFT JOIN email_messages e
|
|
ON e.folder_id = f.id AND e.expunged_at IS NULL
|
|
WHERE f.mailbox_id = $1
|
|
GROUP BY f.id, f.name, f.special_use
|
|
ORDER BY CASE f.special_use
|
|
WHEN 'inbox' THEN 0 WHEN 'drafts' THEN 1 WHEN 'sent' THEN 2
|
|
WHEN 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5
|
|
ELSE 6 END, f.name
|
|
`, mailboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list folders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
folders := []port.WebmailFolder{}
|
|
for rows.Next() {
|
|
var f port.WebmailFolder
|
|
if err := rows.Scan(&f.ID, &f.Name, &f.SpecialUse, &f.UnreadCount, &f.TotalCount); err != nil {
|
|
return nil, err
|
|
}
|
|
folders = append(folders, f)
|
|
}
|
|
return folders, rows.Err()
|
|
}
|
|
|
|
// folderCondition matches either the special-use role ("inbox") or the literal
|
|
// folder name, so the UI can address a folder the same way IMAP does.
|
|
const folderCondition = `(f.special_use = LOWER($2) OR LOWER(f.name) = LOWER($2))`
|
|
|
|
func (r *WebmailRepository) ListMessages(
|
|
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
|
|
) ([]port.WebmailMessage, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT e.uid, COALESCE(e.subject, ''), e.sender_address::text,
|
|
COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''),
|
|
e.size_bytes, e.received_at, e.has_attachments,
|
|
EXISTS (SELECT 1 FROM message_flags mf
|
|
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'),
|
|
EXISTS (SELECT 1 FROM message_flags mf
|
|
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Flagged'),
|
|
EXISTS (SELECT 1 FROM message_flags mf
|
|
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Answered'),
|
|
COALESCE(e.spam_verdict, ''), COALESCE(e.dmarc_result, '')
|
|
FROM email_messages e
|
|
JOIN folders f ON f.id = e.folder_id
|
|
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
|
AND e.expunged_at IS NULL
|
|
AND ($3 = '' OR e.subject ILIKE '%%' || $3 || '%%'
|
|
OR e.sender_address::text ILIKE '%%' || $3 || '%%'
|
|
OR e.snippet ILIKE '%%' || $3 || '%%')
|
|
ORDER BY e.received_at DESC, e.uid DESC
|
|
LIMIT $4 OFFSET $5
|
|
`, mailboxID, folderName, search, limit, offset)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list messages: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
messages := []port.WebmailMessage{}
|
|
for rows.Next() {
|
|
var m port.WebmailMessage
|
|
if err := rows.Scan(
|
|
&m.UID, &m.Subject, &m.SenderAddress, &m.FromName, &m.Snippet,
|
|
&m.SizeBytes, &m.ReceivedAt, &m.HasAttachments,
|
|
&m.Seen, &m.Flagged, &m.Answered, &m.SpamVerdict, &m.DmarcResult,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, m)
|
|
}
|
|
return messages, rows.Err()
|
|
}
|
|
|
|
func (r *WebmailRepository) GetMessageBlob(
|
|
ctx context.Context, mailboxID, folderName string, uid uint32,
|
|
) (uuid.UUID, error) {
|
|
var blobID uuid.UUID
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT e.blob_id 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(&blobID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return uuid.Nil, ErrMessageNotFound
|
|
}
|
|
return blobID, err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
op := port.FlagOpAdd
|
|
if !on {
|
|
op = port.FlagOpDel
|
|
}
|
|
_, err = r.store.SetFlags(ctx, folderID, uid, op, []string{flag}, 0)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
|
|
// callers cannot drift.
|
|
const trashTarget = "trash"
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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 --------------------
|
|
|
|
// CreateFolder adds a folder with no special-use role.
|
|
//
|
|
// No role, because IMAP defines roles only for the standard set: a folder a
|
|
// user invents is addressed by name, which is why the move and the rules match
|
|
// on names as well as roles.
|
|
func (r *WebmailRepository) CreateFolder(ctx context.Context, mailboxID, name string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, $2, NULL)
|
|
`, mailboxID, name)
|
|
if err != nil {
|
|
return fmt.Errorf("create folder %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RenameFolder changes a folder's name, keeping its messages and its UIDs.
|
|
//
|
|
// The messages are not touched: a UID belongs to a folder, and the folder is
|
|
// still the same one, so renaming must not renumber anything or an IMAP client
|
|
// holding those UIDs would lose track of every message in it.
|
|
func (r *WebmailRepository) RenameFolder(ctx context.Context, mailboxID, from, to string) error {
|
|
tag, err := r.pool.Exec(ctx, `
|
|
UPDATE folders SET name = $3
|
|
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
|
|
`, mailboxID, from, to)
|
|
if err != nil {
|
|
return fmt.Errorf("rename folder %q: %w", from, err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return port.ErrFolderMissing
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
var folderID string
|
|
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)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return port.ErrFolderMissing
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.store.DeleteFolder(ctx, folderID)
|
|
}
|