#4: implement F2 sub-project 1 - IMAP core (auth, LIST, SELECT, FETCH, STORE)
Adds a real :143 IMAP listener to the Go protocols service: a mailbox owner authenticates over IMAP (Argon2id against mailboxes.password_hash, enumeration-resistant per PLAN.md section 14.6), lists folders, selects INBOX, fetches messages F1's SMTP path already persisted - including real ENVELOPE/BODY[] built from the actual stored MIME bytes via go-imap/v2's own ExtractEnvelope/ExtractBodySection helpers, not reconstructed from denormalized columns - and sets/reads flags via STORE. - ImapSessionUseCase orchestrates auth, folder listing/selection, message querying, and flag mutation against new Postgres repositories (auth, folders, message query, flags) and a local-disk blob reader. - A github.com/emersion/go-imap/v2 (pinned v2.0.0-beta.8) Backend/Session implements 7 real methods (Login, List, Select, Unselect, Status, Fetch, Store) plus a correct no-op Poll; the other 10 of the interface's 18 methods (Create/Delete/Rename/Subscribe/Unsubscribe/Append/Idle/Expunge/ Search/Copy) are explicit stubs returning a clear "not yet implemented" IMAP error - SEARCH/EXPUNGE/COPY/MOVE, IDLE/CONDSTORE, mailbox management, and APPEND are separate follow-up sub-projects per the design doc's agreed ordering with the user. Verified end-to-end with a real go-imap/v2 imapclient (embedded Postgres, no Docker needed) driving Login -> Select -> Fetch -> Store -> List against a message seeded through F1's real SMTP use case, proving true SMTP-write/IMAP-read interop, not just isolated unit tests. Built via 8 sequential tasks under subagent-driven development, each independently reviewed, plus a final whole-branch review. Two real bugs were found and fixed before merge, both exactly the kind unit tests with fakes cannot catch: - Store's UID-set resolution silently no-op'd on dynamic ranges like "UID STORE 1:*" (the common "mark all as read" operation) after an earlier draft bypassed the folder's real message list; restored to resolve against real messages via the same pattern Fetch already used correctly, with tests proving both dynamic ranges and non-UID STORE. - go-imap/v2's server calls Session.Poll() implicitly before the status response of nearly every authenticated command (FETCH/STORE/SEARCH/ APPEND/COPY), not just IDLE as assumed - a hard-stubbed Poll broke every real command through this path; fixed to a correct no-op. - List had zero test coverage despite being committed scope; added unit tests for wildcard/delimiter matching plus a real E2E LIST assertion. Known follow-up, not blocking: a pre-existing test-fixture hygiene gap (hardcoded content_sha256 blob hashes with no cleanup, shared across several Postgres integration test files including one new to this branch) causes local-dev test collisions on repeated runs against a persistent database - invisible to CI (fresh Postgres per run) but worth a cleanup pass.
This commit is contained in:
1 parent
fc8bbaa3ce
commit
9fa19291d4
25 files changed
+1920
-1
No files matched your search
@@ -0,0 +1,32 @@
|
||||
package diskstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// LocalDiskBlobReader implements port.BlobReader by looking up the blob's
|
||||
// storage_path in Postgres, then reading the full file from local disk.
|
||||
type LocalDiskBlobReader struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewLocalDiskBlobReader(pool *pgxpool.Pool) *LocalDiskBlobReader {
|
||||
return &LocalDiskBlobReader{pool: pool}
|
||||
}
|
||||
|
||||
func (r *LocalDiskBlobReader) ReadByID(ctx context.Context, blobID uuid.UUID) ([]byte, error) {
|
||||
var path string
|
||||
if err := r.pool.QueryRow(ctx, `SELECT storage_path FROM message_blobs WHERE id = $1`, blobID).Scan(&path); err != nil {
|
||||
return nil, fmt.Errorf("find blob %s: %w", blobID, err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read blob file %s: %w", path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package diskstorage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestLocalDiskBlobReader_ReadByID_ReturnsStoredContent(t *testing.T) {
|
||||
pool := testPoolForBlobStorage(t)
|
||||
defer pool.Close()
|
||||
dir := t.TempDir()
|
||||
storage := NewLocalDiskBlobStorage(pool, dir)
|
||||
reader := NewLocalDiskBlobReader(pool)
|
||||
|
||||
content := []byte("Subject: read test\r\n\r\nhello reader")
|
||||
ref, err := storage.Store(context.Background(), bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Store: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `DELETE FROM message_blobs WHERE id = $1`, ref.ID)
|
||||
|
||||
got, err := reader.ReadByID(context.Background(), ref.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadByID: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("ReadByID content = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskBlobReader_ReadByID_ReturnsErrorForUnknownID(t *testing.T) {
|
||||
pool := testPoolForBlobStorage(t)
|
||||
defer pool.Close()
|
||||
reader := NewLocalDiskBlobReader(pool)
|
||||
|
||||
_, err := reader.ReadByID(context.Background(), uuid.New())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unknown (random) blob ID, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// AuthRepository implements port.AuthRepository against Postgres.
|
||||
type AuthRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
|
||||
return &AuthRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT m.id, m.password_hash
|
||||
FROM mailboxes m
|
||||
JOIN domains d ON d.id = m.domain_id
|
||||
WHERE m.email_address = $1
|
||||
AND m.is_active = true
|
||||
AND d.is_active = true
|
||||
`, address)
|
||||
|
||||
var rec port.MailboxAuth
|
||||
var id uuid.UUID
|
||||
if err := row.Scan(&id, &rec.PasswordHash); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rec.ID = id
|
||||
return &rec, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "authrepo-test-" + domainID.String() + ".invalid"
|
||||
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
||||
domainID, domainName, domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
mailboxID := uuid.New()
|
||||
address := "user@" + domainName
|
||||
hash := "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG"
|
||||
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, $4)`,
|
||||
mailboxID, domainID, address, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
|
||||
repo := NewAuthRepository(pool)
|
||||
rec, err := repo.FindByAddress(ctx, address)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("rec = nil, want a record")
|
||||
}
|
||||
if rec.ID != mailboxID || rec.PasswordHash != hash {
|
||||
t.Errorf("rec = %+v, want ID=%v PasswordHash=%q", rec, mailboxID, hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRepository_FindByAddress_ReturnsNilForUnknownAddress(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
repo := NewAuthRepository(pool)
|
||||
rec, err := repo.FindByAddress(context.Background(), "definitely-not-seeded@example.invalid")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec != nil {
|
||||
t.Errorf("rec = %+v, want nil", rec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// FlagRepository implements port.FlagRepository against Postgres.
|
||||
type FlagRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFlagRepository(pool *pgxpool.Pool) *FlagRepository {
|
||||
return &FlagRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *FlagRepository) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var messageID string
|
||||
var receivedAt any
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id, received_at FROM email_messages WHERE folder_id = $1 AND uid = $2
|
||||
`, folderID, uid).Scan(&messageID, &receivedAt); err != nil {
|
||||
return fmt.Errorf("find message folder=%s uid=%d: %w", folderID, uid, 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
|
||||
`, messageID, receivedAt, flag); err != nil {
|
||||
return 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
|
||||
`, messageID, receivedAt, flag); err != nil {
|
||||
return 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`, messageID, receivedAt); err != nil {
|
||||
return 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
|
||||
`, messageID, receivedAt, flag); err != nil {
|
||||
return fmt.Errorf("set flag %q: %w", flag, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
func TestFlagRepository_SetFlags_AddThenDelThenSet(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "flagrepo-test-" + domainID.String() + ".invalid"
|
||||
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
||||
domainID, domainName, domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
mailboxID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
|
||||
mailboxID, domainID, "user@"+domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
folderID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
|
||||
folderID, mailboxID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, "1111110000000000000000000000000000000000000000000000000000000000"[:64])
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
messageID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO email_messages (id, mailbox_id, folder_id, uid, blob_id, sender_address, recipient_addresses, size_bytes)
|
||||
VALUES ($1, $2, $3, 7, $4, 'sender@example.test', $5, 10)
|
||||
`, messageID, mailboxID, folderID, blobID, []string{"user@" + domainName})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
|
||||
readFlags := func() []string {
|
||||
t.Helper()
|
||||
rows, err := pool.Query(ctx, `SELECT flag FROM message_flags WHERE message_id = $1 ORDER BY flag`, messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("query flags: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var flags []string
|
||||
for rows.Next() {
|
||||
var f string
|
||||
if err := rows.Scan(&f); err != nil {
|
||||
t.Fatalf("scan flag: %v", err)
|
||||
}
|
||||
flags = append(flags, f)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
repo := NewFlagRepository(pool)
|
||||
|
||||
if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpAdd, []string{"\\Seen", "\\Flagged"}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
got := readFlags()
|
||||
sort.Strings(got)
|
||||
if len(got) != 2 || got[0] != "\\Flagged" || got[1] != "\\Seen" {
|
||||
t.Fatalf("after add: flags = %v, want [\\Flagged \\Seen]", got)
|
||||
}
|
||||
|
||||
if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpDel, []string{"\\Flagged"}); err != nil {
|
||||
t.Fatalf("del: %v", err)
|
||||
}
|
||||
got = readFlags()
|
||||
if len(got) != 1 || got[0] != "\\Seen" {
|
||||
t.Fatalf("after del: flags = %v, want [\\Seen]", got)
|
||||
}
|
||||
|
||||
if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpSet, []string{"\\Answered"}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
got = readFlags()
|
||||
if len(got) != 1 || got[0] != "\\Answered" {
|
||||
t.Fatalf("after set: flags = %v, want [\\Answered] (Set replaces the full flag set)", got)
|
||||
}
|
||||
|
||||
// A client sending "STORE 1 FLAGS (\Seen \Seen)" - a duplicate flag in a
|
||||
// single command - must not fail the whole STORE with a primary key
|
||||
// violation on the second INSERT for the same (message_id, received_at,
|
||||
// flag).
|
||||
if err := repo.SetFlags(ctx, folderID.String(), 7, port.FlagOpSet, []string{"\\Seen", "\\Seen"}); err != nil {
|
||||
t.Fatalf("set with duplicate flags in input: %v", err)
|
||||
}
|
||||
got = readFlags()
|
||||
if len(got) != 1 || got[0] != "\\Seen" {
|
||||
t.Fatalf("after set with duplicate input flags: flags = %v, want [\\Seen]", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// ImapFolderRepository implements port.ImapFolderRepository against Postgres.
|
||||
type ImapFolderRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewImapFolderRepository(pool *pgxpool.Pool) *ImapFolderRepository {
|
||||
return &ImapFolderRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *ImapFolderRepository) FindByName(ctx context.Context, mailboxID, name string) (*port.ImapFolderRecord, error) {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT f.id, f.name, f.uid_next, f.uid_validity, f.total_count
|
||||
FROM folders f
|
||||
WHERE f.mailbox_id = $1 AND f.name = $2
|
||||
`, mailboxID, name)
|
||||
|
||||
var rec port.ImapFolderRecord
|
||||
if err := row.Scan(&rec.ID, &rec.Name, &rec.UIDNext, &rec.UIDValidity, &rec.NumMessages); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (r *ImapFolderRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.ImapFolderRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT f.id, f.name, f.uid_next, f.uid_validity, f.total_count
|
||||
FROM folders f
|
||||
WHERE f.mailbox_id = $1
|
||||
ORDER BY f.name
|
||||
`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []port.ImapFolderRecord
|
||||
for rows.Next() {
|
||||
var rec port.ImapFolderRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.Name, &rec.UIDNext, &rec.UIDValidity, &rec.NumMessages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestImapFolderRepository_FindByName_ReturnsFolderWithUIDData(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "imapfolder-test-" + domainID.String() + ".invalid"
|
||||
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
||||
domainID, domainName, domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
mailboxID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
|
||||
mailboxID, domainID, "user@"+domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
|
||||
folderID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use, uid_next, uid_validity) VALUES ($1, $2, 'INBOX', 'inbox', 5, 12345)`,
|
||||
folderID, mailboxID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
|
||||
repo := NewImapFolderRepository(pool)
|
||||
rec, err := repo.FindByName(ctx, mailboxID.String(), "INBOX")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("rec = nil, want a folder record")
|
||||
}
|
||||
if rec.UIDNext != 5 || rec.UIDValidity != 12345 {
|
||||
t.Errorf("rec = %+v, want UIDNext=5 UIDValidity=12345", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImapFolderRepository_FindByName_ReturnsNilForUnknownFolder(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
repo := NewImapFolderRepository(pool)
|
||||
rec, err := repo.FindByName(context.Background(), uuid.New().String(), "Nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec != nil {
|
||||
t.Errorf("rec = %+v, want nil", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImapFolderRepository_ListFolders_ReturnsAllFoldersForMailbox(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "imapfolder-list-" + domainID.String() + ".invalid"
|
||||
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
||||
domainID, domainName, domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
mailboxID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
|
||||
mailboxID, domainID, "user@"+domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
|
||||
uuid.New(), mailboxID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed INBOX: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name) VALUES ($1, $2, 'Archive')`,
|
||||
uuid.New(), mailboxID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed Archive: %v", err)
|
||||
}
|
||||
|
||||
repo := NewImapFolderRepository(pool)
|
||||
recs, err := repo.ListFolders(ctx, mailboxID.String())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Errorf("len(recs) = %d, want 2", len(recs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// MessageQueryRepository implements port.MessageQueryRepository against Postgres.
|
||||
type MessageQueryRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewMessageQueryRepository(pool *pgxpool.Pool) *MessageQueryRepository {
|
||||
return &MessageQueryRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *MessageQueryRepository) ListMessages(ctx context.Context, folderID string) ([]port.MessageRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT m.uid, m.blob_id, m.size_bytes, m.received_at,
|
||||
COALESCE(array_agg(f.flag) FILTER (WHERE f.flag IS NOT NULL), '{}')
|
||||
FROM email_messages m
|
||||
LEFT JOIN message_flags f ON f.message_id = m.id AND f.received_at = m.received_at
|
||||
WHERE m.folder_id = $1 AND m.expunged_at IS NULL
|
||||
GROUP BY m.uid, m.blob_id, m.size_bytes, m.received_at
|
||||
ORDER BY m.uid ASC
|
||||
`, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []port.MessageRecord
|
||||
for rows.Next() {
|
||||
var rec port.MessageRecord
|
||||
if err := rows.Scan(&rec.UID, &rec.BlobID, &rec.SizeBytes, &rec.ReceivedAt, &rec.Flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMessageQueryRepository_ListMessages_ReturnsRowsOrderedByUID(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "msgquery-test-" + domainID.String() + ".invalid"
|
||||
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
||||
domainID, domainName, domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
mailboxID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
|
||||
mailboxID, domainID, "user@"+domainName)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
folderID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
|
||||
folderID, mailboxID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 42)`,
|
||||
blobID, "abcdef0000000000000000000000000000000000000000000000000000000000"[:64])
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
for _, uidVal := range []int64{2, 1} { // insert out of order to prove the query sorts
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO email_messages (mailbox_id, folder_id, uid, blob_id, sender_address, recipient_addresses, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, 'sender@example.test', $5, 42)
|
||||
`, mailboxID, folderID, uidVal, blobID, []string{"user@" + domainName})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message uid=%d: %v", uidVal, err)
|
||||
}
|
||||
}
|
||||
|
||||
repo := NewMessageQueryRepository(pool)
|
||||
recs, err := repo.ListMessages(ctx, folderID.String())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("len(recs) = %d, want 2", len(recs))
|
||||
}
|
||||
if recs[0].UID != 1 || recs[1].UID != 2 {
|
||||
t.Errorf("UIDs = [%d, %d], want [1, 2] (ascending order)", recs[0].UID, recs[1].UID)
|
||||
}
|
||||
if recs[0].BlobID != blobID {
|
||||
t.Errorf("BlobID = %v, want %v", recs[0].BlobID, blobID)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user