#2: implement F1 sub-project 1 - inbound SMTP listener with EML storage
Adds a real :25 SMTP listener to the Go protocols service, giving F1's "Nucleo SMTP" milestone its first working sub-project: application, infrastructure, and presentation layers on top of F0's domain-only Clean Architecture. - ProcessInboundEmailUseCase resolves recipients against Postgres and persists accepted mail (blob dedup by content hash, transactional UID allocation + outbox event, never sending 250 OK before durable commit per PLAN.md section 6.1). - Postgres repositories via pgx/v5 (replacing lib/pq entirely). - Local-disk blob storage with content-hash dedup, rename-before-commit ordering so a DB row can never reference a file that isn't actually on disk. - A local-file TLS cert provider for STARTTLS, with an ephemeral self-signed fallback so a missing/misconfigured cert degrades gracefully instead of crash-looping the service (PLAN.md section 8.4). - A github.com/emersion/go-smtp Backend/Session adapting real SMTP traffic to the use case: unknown recipients get 550 5.1.1 before DATA, duplicate RCPT TO for the same address is deduplicated, TLS is hardened to the project's required PFS-only cipher/curve policy. - A dev-seed script and Makefile target for local testing. Explicitly out of scope (separate follow-up sub-projects): alias resolution, SPF/DKIM/DMARC/ARC verification, Rspamd/ClamAV, Submission ports with SASL auth, DKIM signing, outbound delivery, and the real Traefik acme.json cert watcher. Verified live end-to-end via WSL Docker: the full compose stack (db, redis, migrate, auth, storage, protocols, web) reaches healthy, and a real swaks SMTP session is accepted and durably persisted with correct sequential UIDs, while an unknown recipient is correctly rejected with 550 5.1.1 and zero rows persisted. Built via 10 sequential tasks under subagent-driven development, each independently reviewed, plus a final whole-branch review that caught and fixed a TLS-crash bug, a blob commit-ordering bug, missing TLS hardening, and missing CI integration coverage before merge. Known follow-up, not blocking: email_messages' partition only covers July 2026 and needs a partition-management job before 2026-08-01.
This commit is contained in:
1 parent
7c91182111
commit
e501cdc69e
27 files changed
+1468
-17
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// InboundMessageRepository implements port.InboundMessageRepository against
|
||||
// Postgres: one transaction per Persist call, matching PLAN.md section 6.1's
|
||||
// "250 OK only after fsync + COMMIT" rule (the fsync happens in BlobStorage;
|
||||
// this repository's COMMIT is the second half of that durability guarantee).
|
||||
type InboundMessageRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewInboundMessageRepository(pool *pgxpool.Pool) *InboundMessageRepository {
|
||||
return &InboundMessageRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *InboundMessageRepository) Persist(ctx context.Context, input port.PersistInboundMessageInput) (int64, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx) // no-op if Commit succeeds
|
||||
|
||||
var folderID string
|
||||
var uid int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, uid_next FROM folders
|
||||
WHERE mailbox_id = $1 AND special_use = 'inbox'
|
||||
FOR UPDATE
|
||||
`, input.MailboxID).Scan(&folderID, &uid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find inbox folder for mailbox %s: %w", input.MailboxID, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE folders SET uid_next = uid_next + 1 WHERE id = $1`, folderID); err != nil {
|
||||
return 0, fmt.Errorf("advance uid_next: %w", err)
|
||||
}
|
||||
|
||||
// email_messages.id defaults to gen_random_uuid() per the migration - no explicit value needed.
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO email_messages (
|
||||
mailbox_id, folder_id, uid, blob_id,
|
||||
sender_address, recipient_addresses, size_bytes,
|
||||
spf_result, dkim_result, dmarc_result
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, input.MailboxID, folderID, uid, input.Blob.ID,
|
||||
input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
||||
input.SPFResult, input.DKIMResult, input.DMARCResult)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert email_messages: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = $1`, input.Blob.ID); err != nil {
|
||||
return 0, fmt.Errorf("increment blob ref_count: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO domain_events_outbox (event_type, aggregate_id, payload)
|
||||
VALUES ('EmailReceived', $1, $2)
|
||||
`, input.MailboxID, fmt.Sprintf(`{"recipient":%q,"uid":%d}`, input.RecipientAddress, uid)); err != nil {
|
||||
return 0, fmt.Errorf("insert outbox event: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "msgrepo-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@msgrepo-test-"+domainID.String()+".invalid")
|
||||
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, "deadbeef00000000000000000000000000000000000000000000000000000000"[:64])
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
repo := NewInboundMessageRepository(pool)
|
||||
blob := port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10}
|
||||
|
||||
firstUID, err := repo.Persist(ctx, port.PersistInboundMessageInput{
|
||||
MailboxID: mailboxID, Blob: blob,
|
||||
SenderAddress: "sender@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid",
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Persist: %v", err)
|
||||
}
|
||||
secondUID, err := repo.Persist(ctx, port.PersistInboundMessageInput{
|
||||
MailboxID: mailboxID, Blob: blob,
|
||||
SenderAddress: "sender2@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid",
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Persist: %v", err)
|
||||
}
|
||||
if secondUID <= firstUID {
|
||||
t.Errorf("secondUID (%d) must be strictly greater than firstUID (%d) - RFC 3501 section 2.3.1.1", secondUID, firstUID)
|
||||
}
|
||||
|
||||
var refCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT ref_count FROM message_blobs WHERE id = $1`, blobID).Scan(&refCount); err != nil {
|
||||
t.Fatalf("query ref_count: %v", err)
|
||||
}
|
||||
if refCount != 2 {
|
||||
t.Errorf("ref_count = %d, want 2 after two Persist calls referencing the same blob", refCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMessageRepository_Persist_ReturnsErrorWhenNoInboxFolderExists(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "msgrepo-nofolder-" + 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, "nofolder@msgrepo-nofolder-"+domainID.String()+".invalid")
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
// Deliberately no folder row inserted.
|
||||
|
||||
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, "cafebabe00000000000000000000000000000000000000000000000000000000"[:64])
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
repo := NewInboundMessageRepository(pool)
|
||||
_, err = repo.Persist(ctx, port.PersistInboundMessageInput{
|
||||
MailboxID: mailboxID, Blob: port.BlobRef{ID: blobID, SizeBytes: 10},
|
||||
SenderAddress: "sender@example.test", RecipientAddress: "nofolder@example.test",
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the mailbox has no INBOX folder, 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"
|
||||
)
|
||||
|
||||
// MailboxRepository implements port.MailboxRepository against Postgres.
|
||||
type MailboxRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewMailboxRepository(pool *pgxpool.Pool) *MailboxRepository {
|
||||
return &MailboxRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *MailboxRepository) FindActiveByAddress(ctx context.Context, address string) (*port.MailboxRecord, error) {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT m.id, d.max_message_bytes
|
||||
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.MailboxRecord
|
||||
var id uuid.UUID
|
||||
if err := row.Scan(&id, &rec.MaxMessageBytes); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rec.ID = id
|
||||
return &rec, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// testPool returns a pool for TEST_DATABASE_URL, or skips the test if that
|
||||
// env var is unset or the database is unreachable - these are integration
|
||||
// tests against a real Postgres, not unit tests.
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test")
|
||||
}
|
||||
pool, err := NewPool(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
t.Skipf("cannot connect to TEST_DATABASE_URL: %v", err)
|
||||
}
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
t.Skipf("cannot ping TEST_DATABASE_URL: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestMailboxRepository_FindActiveByAddress_ReturnsNilForUnknownAddress(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
repo := NewMailboxRepository(pool)
|
||||
|
||||
rec, err := repo.FindActiveByAddress(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 for an address with no matching mailbox", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxRepository_FindActiveByAddress_ReturnsRecordForSeededMailbox(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "repo-test-" + domainID.String() + ".invalid"
|
||||
// NOTE: reusing a single placeholder ($2) for both `name` (CITEXT) and
|
||||
// `punycode_name` (VARCHAR) makes Postgres's extended-protocol parameter
|
||||
// type inference fail with "inconsistent types deduced for parameter $2"
|
||||
// (SQLSTATE 42P08), confirmed via isolated repro against this same DB -
|
||||
// see task-3-report.md. Using distinct placeholders for the same value
|
||||
// avoids that false-negative without changing what's inserted.
|
||||
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name, max_message_bytes) VALUES ($1, $2, $3, 12345)`,
|
||||
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@repo-test-" + domainID.String() + ".invalid"
|
||||
_, 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, address)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
|
||||
repo := NewMailboxRepository(pool)
|
||||
rec, err := repo.FindActiveByAddress(ctx, address)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("rec = nil, want a record for the seeded active mailbox")
|
||||
}
|
||||
if rec.ID != mailboxID {
|
||||
t.Errorf("ID = %v, want %v", rec.ID, mailboxID)
|
||||
}
|
||||
if rec.MaxMessageBytes != 12345 {
|
||||
t.Errorf("MaxMessageBytes = %d, want 12345 (from the domain row)", rec.MaxMessageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxRepository_FindActiveByAddress_ExcludesInactiveMailbox(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "repo-test-inactive-" + domainID.String() + ".invalid"
|
||||
// See NOTE above TestMailboxRepository_FindActiveByAddress_ReturnsRecordForSeededMailbox
|
||||
// re: distinct placeholders for `name`/`punycode_name`.
|
||||
_, 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)
|
||||
|
||||
address := "user@repo-test-inactive-" + domainID.String() + ".invalid"
|
||||
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, is_active) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG', false)`,
|
||||
uuid.New(), domainID, address)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
|
||||
repo := NewMailboxRepository(pool)
|
||||
rec, err := repo.FindActiveByAddress(ctx, address)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec != nil {
|
||||
t.Errorf("rec = %+v, want nil - mailbox is_active=false must be excluded", rec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package postgres contains pgx-based implementations of the application's
|
||||
// repository ports (PLAN.md section 3's ADR: pgx for Go repositories).
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NewPool builds a connection pool for databaseURL (a postgres:// URL).
|
||||
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
return pgxpool.New(ctx, databaseURL)
|
||||
}
|
||||
Reference in new issue
Block a user