Files
LambdaMail/services/protocols/internal/infrastructure/postgres/webmail_repository.go
T
Lucas Rayan 67bbe7a50a refactor(mail): IMAP and the webmail read folders and messages through one MailStore
The folder list and the message list were two queries over the same tables. IMAP
took a folder's message count from a cached column and the webmail counted rows,
so the two could disagree. Both now ask MailStore, which counts the messages.
2026-10-16 13:22:05 -03:00

254 lines
8.7 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"sort"
"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)}
}
// The methods below only read. They do not query on their own: the webmail asks
// MailStore the same questions IMAP does and shapes the answers for the
// browser, so a folder shows the same numbers in both.
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
return r.store.MailboxIDByAddress(ctx, address)
}
// roleOrder is how the folder list is arranged for people: the folders they use
// every day first, in a fixed order, then their own by name.
var roleOrder = map[string]int{"inbox": 0, "drafts": 1, "sent": 2, "archive": 3, "junk": 4, "trash": 5}
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
folders, err := r.store.Folders(ctx, mailboxID)
if err != nil {
return nil, err
}
out := make([]port.WebmailFolder, 0, len(folders))
for _, f := range folders {
out = append(out, port.WebmailFolder{ID: f.ID, Name: f.Name, SpecialUse: f.SpecialUse, UnreadCount: f.Unread, TotalCount: f.Total})
}
rank := func(f port.WebmailFolder) int {
if n, ok := roleOrder[f.SpecialUse]; ok {
return n
}
return 6
}
sort.SliceStable(out, func(i, j int) bool {
if ri, rj := rank(out[i]), rank(out[j]); ri != rj {
return ri < rj
}
return out[i].Name < out[j].Name
})
return out, nil
}
func (r *WebmailRepository) ListMessages(
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
) ([]port.WebmailMessage, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
folderID, err := r.store.ResolveFolder(ctx, mailboxID, folderName)
if errors.Is(err, port.ErrFolderMissing) {
return []port.WebmailMessage{}, nil
}
if err != nil {
return nil, err
}
messages, err := r.store.Messages(ctx, folderID, MessageQuery{NewestFirst: true, Search: search, Limit: limit, Offset: offset})
if err != nil {
return nil, err
}
out := make([]port.WebmailMessage, 0, len(messages))
for _, m := range messages {
out = append(out, port.WebmailMessage{
UID: m.UID, Subject: m.Subject, SenderAddress: m.SenderAddress, FromName: m.FromName, Snippet: m.Snippet,
SizeBytes: m.Size, ReceivedAt: m.ReceivedAt, HasAttachments: m.HasAttachments,
Seen: m.HasFlag(`\Seen`), Flagged: m.HasFlag(`\Flagged`), Answered: m.HasFlag(`\Answered`),
SpamVerdict: m.SpamVerdict, DmarcResult: m.DmarcResult,
})
}
return out, nil
}
func (r *WebmailRepository) GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error) {
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
if err != nil {
return uuid.Nil, err
}
return r.store.MessageBlob(ctx, folderID, uid)
}
// 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)
}