#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:
Lucas Rayan committed 2026-07-23 08:41:31 -03:00
1 parent 7c91182111
commit e501cdc69e
27 files changed
+1468 -17

No files matched your search

@@ -0,0 +1,22 @@
package port
import (
"context"
"io"
"github.com/google/uuid"
)
// BlobRef identifies a stored message blob (PLAN.md section 9, message_blobs).
type BlobRef struct {
ID uuid.UUID
SHA256 string
SizeBytes int64
}
// BlobStorage persists raw message bytes, deduplicated by content hash.
type BlobStorage interface {
// Store writes r to durable storage and returns its BlobRef. Calling
// Store twice with identical content returns the same BlobRef.ID.
Store(ctx context.Context, r io.Reader) (BlobRef, error)
}
@@ -0,0 +1,10 @@
package port
import "crypto/tls"
// CertProvider supplies the TLS certificate for STARTTLS. Its signature
// matches tls.Config.GetCertificate exactly so any implementation can be
// wired in directly: tls.Config{GetCertificate: certProvider.GetCertificate}.
type CertProvider interface {
GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
}
@@ -0,0 +1,29 @@
package port
import (
"context"
"github.com/google/uuid"
)
// PersistInboundMessageInput is everything needed to durably record one
// recipient's copy of an accepted inbound message.
type PersistInboundMessageInput struct {
MailboxID uuid.UUID
Blob BlobRef
SenderAddress string
RecipientAddress string
SPFResult string
DKIMResult string
DMARCResult string
}
// InboundMessageRepository durably records an accepted inbound message for
// one recipient: allocates the next folder UID, inserts the email_messages
// row, increments the blob's ref_count, and writes the transactional outbox
// event - all in a single transaction (PLAN.md section 6.1, section 9.4).
type InboundMessageRepository interface {
// Persist returns the allocated IMAP UID on success. The caller may only
// report success to the SMTP client after this returns without error.
Persist(ctx context.Context, input PersistInboundMessageInput) (uid int64, err error)
}
@@ -0,0 +1,22 @@
// Package port declares the interfaces the application layer depends on.
// Zero infrastructure imports: implementations live in internal/infrastructure.
package port
import (
"context"
"github.com/google/uuid"
)
// MailboxRecord is the subset of mailbox + domain data the inbound flow needs.
type MailboxRecord struct {
ID uuid.UUID
MaxMessageBytes int64
}
// MailboxRepository resolves a recipient address to an active mailbox.
type MailboxRepository interface {
// FindActiveByAddress returns nil, nil (not an error) when no active
// mailbox with an active domain matches address.
FindActiveByAddress(ctx context.Context, address string) (*MailboxRecord, error)
}
@@ -0,0 +1,79 @@
// Package usecase contains the application's use cases (Clean Architecture layer 2).
// Depends only on internal/domain and internal/application/port - no infrastructure.
package usecase
import (
"context"
"errors"
"io"
"lambdamail/protocols/internal/application/port"
)
// ErrRecipientNotFound is returned by ResolveRecipient when no active
// mailbox matches - the presentation layer maps this to SMTP 550 5.1.1.
var ErrRecipientNotFound = errors.New("process inbound email: recipient not found or inactive")
// ProcessInboundEmailInput carries one SMTP DATA transaction's worth of
// state: the accumulated recipients from one or more successful Rcpt calls,
// and the message body stream.
type ProcessInboundEmailInput struct {
Sender string
Recipients []port.MailboxRecord
RecipientAddresses []string
Body io.Reader
}
// ProcessInboundEmailUseCase implements the inbound half of PLAN.md section
// 3's ProcessInboundEmailUseCase (this sub-project's scope: recipient
// resolution + durable persistence; SPF/DKIM/DMARC/spam/virus checks land in
// later sub-projects).
type ProcessInboundEmailUseCase struct {
mailboxes port.MailboxRepository
blobs port.BlobStorage
messages port.InboundMessageRepository
}
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages}
}
// ResolveRecipient is called from the SMTP session's RCPT TO handler.
func (uc *ProcessInboundEmailUseCase) ResolveRecipient(ctx context.Context, address string) (*port.MailboxRecord, error) {
rec, err := uc.mailboxes.FindActiveByAddress(ctx, address)
if err != nil {
return nil, err
}
if rec == nil {
return nil, ErrRecipientNotFound
}
return rec, nil
}
// Handle is called once from the SMTP session's DATA handler, after one or
// more successful RCPT TO calls. It stores the message body exactly once
// and persists exactly one email_messages row per recipient, referencing
// the same blob (PLAN.md section 9's dedup design: 1 email to 50
// recipients = 1 file).
func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessInboundEmailInput) error {
blob, err := uc.blobs.Store(ctx, input.Body)
if err != nil {
return err
}
for i, recipient := range input.Recipients {
_, err := uc.messages.Persist(ctx, port.PersistInboundMessageInput{
MailboxID: recipient.ID,
Blob: blob,
SenderAddress: input.Sender,
RecipientAddress: input.RecipientAddresses[i],
SPFResult: "none",
DKIMResult: "none",
DMARCResult: "none",
})
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,127 @@
package usecase
import (
"bytes"
"context"
"errors"
"io"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
type fakeMailboxRepository struct {
byAddress map[string]port.MailboxRecord
}
func (f *fakeMailboxRepository) FindActiveByAddress(_ context.Context, address string) (*port.MailboxRecord, error) {
rec, ok := f.byAddress[address]
if !ok {
return nil, nil
}
return &rec, nil
}
func TestUseCase_ResolveRecipient_ReturnsRecordForActiveMailbox(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000},
}}
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
rec, err := uc.ResolveRecipient(context.Background(), "user@example.test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.ID != mailboxID {
t.Errorf("ID = %v, want %v", rec.ID, mailboxID)
}
}
func TestUseCase_ResolveRecipient_ReturnsErrRecipientNotFoundForUnknownAddress(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{}}
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
_, err := uc.ResolveRecipient(context.Background(), "nobody@example.test")
if !errors.Is(err, ErrRecipientNotFound) {
t.Fatalf("error = %v, want ErrRecipientNotFound", err)
}
}
type stubBlobStorage struct {
ref port.BlobRef
err error
}
func (s *stubBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
_, _ = io.Copy(io.Discard, r)
return s.ref, s.err
}
type recordingMessageRepository struct {
persisted []port.PersistInboundMessageInput
nextUID int64
}
func (r *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) {
r.persisted = append(r.persisted, input)
r.nextUID++
return r.nextUID, nil
}
func TestUseCase_Handle_StoresBlobOnceAndPersistsOncePerRecipient(t *testing.T) {
blobRef := port.BlobRef{ID: uuid.New(), SHA256: "abc123", SizeBytes: 42}
blobs := &stubBlobStorage{ref: blobRef}
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
recipients := []port.MailboxRecord{
{ID: uuid.New(), MaxMessageBytes: 1000},
{ID: uuid.New(), MaxMessageBytes: 1000},
}
addresses := []string{"a@example.test", "b@example.test"}
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "sender@example.test",
Recipients: recipients,
RecipientAddresses: addresses,
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody"),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(messages.persisted) != 2 {
t.Fatalf("persisted %d messages, want 2 (one per recipient, blob stored once)", len(messages.persisted))
}
for i, p := range messages.persisted {
if p.Blob.ID != blobRef.ID {
t.Errorf("persisted[%d].Blob.ID = %v, want %v (same blob for every recipient)", i, p.Blob.ID, blobRef.ID)
}
if p.MailboxID != recipients[i].ID {
t.Errorf("persisted[%d].MailboxID = %v, want %v", i, p.MailboxID, recipients[i].ID)
}
if p.RecipientAddress != addresses[i] {
t.Errorf("persisted[%d].RecipientAddress = %q, want %q", i, p.RecipientAddress, addresses[i])
}
if p.SPFResult != "none" || p.DKIMResult != "none" || p.DMARCResult != "none" {
t.Errorf("persisted[%d] auth results = %q/%q/%q, want \"none\"/\"none\"/\"none\" (verification lands in a later sub-project)", i, p.SPFResult, p.DKIMResult, p.DMARCResult)
}
}
}
func TestUseCase_Handle_PropagatesBlobStorageError(t *testing.T) {
blobs := &stubBlobStorage{err: errors.New("disk full")}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "sender@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"a@example.test"},
Body: bytes.NewBufferString("x"),
})
if err == nil {
t.Fatal("expected error when blob storage fails, got nil")
}
}
@@ -0,0 +1,101 @@
// Package diskstorage implements port.BlobStorage on the local filesystem.
package diskstorage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
)
// LocalDiskBlobStorage writes message bodies under baseDir, sharded into
// subdirectories by the first two hex chars of the content hash (avoids
// unbounded single-directory fanout).
type LocalDiskBlobStorage struct {
pool *pgxpool.Pool
baseDir string
}
func NewLocalDiskBlobStorage(pool *pgxpool.Pool, baseDir string) *LocalDiskBlobStorage {
return &LocalDiskBlobStorage{pool: pool, baseDir: baseDir}
}
func (s *LocalDiskBlobStorage) Store(ctx context.Context, r io.Reader) (port.BlobRef, error) {
tmp, err := os.CreateTemp(s.baseDir, "incoming-*.tmp")
if err != nil {
return port.BlobRef{}, fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath) // no-op once renamed to its final path
hasher := sha256.New()
size, err := io.Copy(io.MultiWriter(tmp, hasher), r)
if err != nil {
tmp.Close()
return port.BlobRef{}, fmt.Errorf("write blob: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return port.BlobRef{}, fmt.Errorf("fsync blob: %w", err)
}
if err := tmp.Close(); err != nil {
return port.BlobRef{}, fmt.Errorf("close blob: %w", err)
}
sum := hex.EncodeToString(hasher.Sum(nil))
shardDir := filepath.Join(s.baseDir, sum[:2])
if err := os.MkdirAll(shardDir, 0o755); err != nil {
return port.BlobRef{}, fmt.Errorf("create shard dir: %w", err)
}
finalPath := filepath.Join(shardDir, sum+".eml")
// Rename into place BEFORE inserting the DB row, so a committed row
// never references a file that isn't actually there yet (a rename
// failure after an insert would otherwise leave a durable row pointing
// at nothing, and any concurrent Store with identical content could
// dedup onto that row and report success for content that was never
// safely stored).
if _, statErr := os.Stat(finalPath); statErr == nil {
// Content-identical file already stored (same hash => same path,
// and file content is byte-identical); no need to overwrite it.
os.Remove(tmpPath)
} else if !os.IsNotExist(statErr) {
return port.BlobRef{}, fmt.Errorf("stat final blob path: %w", statErr)
} else {
if err := os.Rename(tmpPath, finalPath); err != nil {
return port.BlobRef{}, fmt.Errorf("rename blob into place: %w", err)
}
}
// Insert-or-fetch: identical content dedups to the existing row.
var id uuid.UUID
err = s.pool.QueryRow(ctx, `
INSERT INTO message_blobs (content_sha256, storage_driver, storage_path, size_bytes)
VALUES ($1, 'local', $2, $3)
ON CONFLICT (content_sha256) DO NOTHING
RETURNING id
`, sum, finalPath, size).Scan(&id)
if err != nil {
if err == pgx.ErrNoRows {
// Conflict: a row already exists for this hash.
err = s.pool.QueryRow(ctx, `SELECT id FROM message_blobs WHERE content_sha256 = $1`, sum).Scan(&id)
if err != nil {
return port.BlobRef{}, fmt.Errorf("fetch existing blob row: %w", err)
}
// Content already stored on disk from a prior call; discard this duplicate write.
return port.BlobRef{ID: id, SHA256: sum, SizeBytes: size}, nil
}
return port.BlobRef{}, fmt.Errorf("insert blob row: %w", err)
}
return port.BlobRef{ID: id, SHA256: sum, SizeBytes: size}, nil
}
@@ -0,0 +1,82 @@
package diskstorage
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func testPoolForBlobStorage(t *testing.T) *pgxpool.Pool {
t.Helper()
dbURL := os.Getenv("TEST_DATABASE_URL")
if dbURL == "" {
t.Skip("TEST_DATABASE_URL not set - skipping blob storage integration test")
}
pool, err := pgxpool.New(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 TestLocalDiskBlobStorage_Store_WritesFileAndInsertsBlobRow(t *testing.T) {
pool := testPoolForBlobStorage(t)
defer pool.Close()
dir := t.TempDir()
storage := NewLocalDiskBlobStorage(pool, dir)
content := []byte("Subject: test\r\n\r\nhello world")
ref, err := storage.Store(context.Background(), bytes.NewReader(content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer pool.Exec(context.Background(), `DELETE FROM message_blobs WHERE id = $1`, ref.ID)
if ref.SizeBytes != int64(len(content)) {
t.Errorf("SizeBytes = %d, want %d", ref.SizeBytes, len(content))
}
entries, err := filepath.Glob(filepath.Join(dir, "*", "*"))
if err != nil {
t.Fatalf("glob: %v", err)
}
if len(entries) != 1 {
t.Fatalf("found %d files under %s, want 1", len(entries), dir)
}
written, err := os.ReadFile(entries[0])
if err != nil {
t.Fatalf("read written file: %v", err)
}
if !bytes.Equal(written, content) {
t.Error("written file content does not match input")
}
}
func TestLocalDiskBlobStorage_Store_DedupsIdenticalContent(t *testing.T) {
pool := testPoolForBlobStorage(t)
defer pool.Close()
dir := t.TempDir()
storage := NewLocalDiskBlobStorage(pool, dir)
content := []byte("identical content for dedup test")
first, err := storage.Store(context.Background(), bytes.NewReader(content))
if err != nil {
t.Fatalf("first Store: %v", err)
}
defer pool.Exec(context.Background(), `DELETE FROM message_blobs WHERE id = $1`, first.ID)
second, err := storage.Store(context.Background(), bytes.NewReader(content))
if err != nil {
t.Fatalf("second Store: %v", err)
}
if second.ID != first.ID {
t.Errorf("second.ID = %v, want %v - identical content must dedup to the same blob (PLAN.md section 9)", second.ID, first.ID)
}
}
@@ -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
}
@@ -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)
}
@@ -0,0 +1,62 @@
// Package tlsprovider implements port.CertProvider by loading a static
// cert/key pair from disk - suitable for local development. The Traefik
// acme.json directory watcher (PLAN.md section 8) is a separate, later
// adapter behind the same port.CertProvider interface.
package tlsprovider
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"time"
)
// LocalFileCertProvider serves one certificate loaded once at construction.
type LocalFileCertProvider struct {
cert tls.Certificate
}
func NewLocalFileCertProvider(certPath, keyPath string) (*LocalFileCertProvider, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("load cert/key pair (cert=%s, key=%s): %w", certPath, keyPath, err)
}
return &LocalFileCertProvider{cert: cert}, nil
}
func (p *LocalFileCertProvider) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
return &p.cert, nil
}
// NewEphemeralSelfSignedCertProvider generates a throwaway self-signed
// certificate in memory. Used as a degraded-mode fallback when no real
// certificate is configured or loadable (PLAN.md section 8.4: never crash
// on a missing cert - serve degraded instead).
func NewEphemeralSelfSignedCertProvider() (*LocalFileCertProvider, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate ephemeral key: %w", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "ephemeral-self-signed.invalid"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("create ephemeral certificate: %w", err)
}
cert := tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: priv,
}
return &LocalFileCertProvider{cert: cert}, nil
}
@@ -0,0 +1,104 @@
package tlsprovider
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
// generateSelfSignedPEMForTest returns a minimal self-signed cert/key pair
// generated with Go's crypto/x509, so this test has no external dependency
// on openssl being installed.
func generateSelfSignedPEMForTest(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test.local"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM
}
// writeTestCertAndKey writes a self-signed cert/key pair to disk for testing
func writeTestCertAndKey(t *testing.T, dir string) (certPath, keyPath string) {
t.Helper()
certPEM, keyPEM := generateSelfSignedPEMForTest(t)
certPath = filepath.Join(dir, "cert.pem")
keyPath = filepath.Join(dir, "key.pem")
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certPath, keyPath
}
func TestNewLocalFileCertProvider_LoadsValidCertAndKey(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeTestCertAndKey(t, dir)
provider, err := NewLocalFileCertProvider(certPath, keyPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cert, err := provider.GetCertificate(&tls.ClientHelloInfo{})
if err != nil {
t.Fatalf("GetCertificate error: %v", err)
}
if cert == nil {
t.Fatal("GetCertificate returned nil cert")
}
}
func TestNewLocalFileCertProvider_FailsFastOnMissingFiles(t *testing.T) {
_, err := NewLocalFileCertProvider("/nonexistent/cert.pem", "/nonexistent/key.pem")
if err == nil {
t.Fatal("expected error for nonexistent cert/key paths, got nil")
}
}
func TestNewEphemeralSelfSignedCertProvider_GeneratesUsableCert(t *testing.T) {
provider, err := NewEphemeralSelfSignedCertProvider()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cert, err := provider.GetCertificate(&tls.ClientHelloInfo{})
if err != nil {
t.Fatalf("GetCertificate error: %v", err)
}
if cert == nil {
t.Fatal("GetCertificate returned nil cert")
}
if len(cert.Certificate) == 0 {
t.Fatal("expected at least one certificate DER blob")
}
}
@@ -0,0 +1,106 @@
// Package smtppresentation adapts github.com/emersion/go-smtp's Backend/
// Session interfaces to ProcessInboundEmailUseCase (Clean Architecture
// layer 4 - PLAN.md section 3).
package smtppresentation
import (
"context"
"errors"
"io"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
// Backend is the go-smtp entry point: one Session per connection.
type Backend struct {
useCase *usecase.ProcessInboundEmailUseCase
}
func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
return &Backend{useCase: uc}
}
func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase}, nil
}
// session holds one SMTP transaction's accumulated state (RFC 5321 section
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
type session struct {
useCase *usecase.ProcessInboundEmailUseCase
from string
recipients []port.MailboxRecord
recipientAddresses []string
}
func (s *session) Reset() {
s.from = ""
s.recipients = nil
s.recipientAddresses = nil
}
func (s *session) Logout() error {
return nil
}
func (s *session) Mail(from string, _ *gosmtp.MailOptions) error {
s.from = from
return nil
}
func (s *session) Rcpt(to string, _ *gosmtp.RcptOptions) error {
for _, existing := range s.recipientAddresses {
if existing == to {
// Duplicate RCPT TO for an address already accepted in this
// transaction (some MTAs retry): accept silently without
// persisting a second delivery.
return nil
}
}
rec, err := s.useCase.ResolveRecipient(context.Background(), to)
if err != nil {
if errors.Is(err, usecase.ErrRecipientNotFound) {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 1, 1},
Message: "User unknown",
}
}
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error resolving recipient",
}
}
s.recipients = append(s.recipients, *rec)
s.recipientAddresses = append(s.recipientAddresses, to)
return nil
}
func (s *session) Data(r io.Reader) error {
if len(s.recipients) == 0 {
return &gosmtp.SMTPError{
Code: 503,
EnhancedCode: gosmtp.EnhancedCode{5, 5, 1},
Message: "RCPT TO required before DATA",
}
}
err := s.useCase.Handle(context.Background(), usecase.ProcessInboundEmailInput{
Sender: s.from,
Recipients: s.recipients,
RecipientAddresses: s.recipientAddresses,
Body: r,
})
if err != nil {
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error storing message",
}
}
return nil
}
@@ -0,0 +1,168 @@
package smtppresentation
import (
"bytes"
"context"
"errors"
"io"
"testing"
gosmtp "github.com/emersion/go-smtp"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
type fakeMailboxRepository struct {
byAddress map[string]port.MailboxRecord
}
func (f *fakeMailboxRepository) FindActiveByAddress(_ context.Context, address string) (*port.MailboxRecord, error) {
rec, ok := f.byAddress[address]
if !ok {
return nil, nil
}
return &rec, nil
}
type recordingBlobStorage struct {
stored []byte
ref port.BlobRef
}
func (b *recordingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
data, err := io.ReadAll(r)
if err != nil {
return port.BlobRef{}, err
}
b.stored = data
return b.ref, nil
}
type recordingMessageRepository struct {
persisted []port.PersistInboundMessageInput
}
func (m *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) {
m.persisted = append(m.persisted, input)
return int64(len(m.persisted)), nil
}
func newTestSession(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorage, messages *recordingMessageRepository) gosmtp.Session {
uc := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
backend := NewBackend(uc)
session, err := backend.NewSession(nil)
if err != nil {
panic(err)
}
return session
}
func TestSession_Rcpt_AcceptsKnownActiveRecipient(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000},
}}
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
if err := session.Mail("sender@example.test", &gosmtp.MailOptions{}); err != nil {
t.Fatalf("Mail: %v", err)
}
if err := session.Rcpt("user@example.test", &gosmtp.RcptOptions{}); err != nil {
t.Fatalf("Rcpt: %v", err)
}
}
func TestSession_Rcpt_Rejects550ForUnknownRecipient(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{}}
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
err := session.Rcpt("nobody@example.test", &gosmtp.RcptOptions{})
if err == nil {
t.Fatal("expected an error for an unknown recipient, got nil")
}
var smtpErr *gosmtp.SMTPError
if !errors.As(err, &smtpErr) {
t.Fatalf("error is not a *gosmtp.SMTPError: %v", err)
}
if smtpErr.Code != 550 {
t.Errorf("Code = %d, want 550", smtpErr.Code)
}
}
func TestSession_Data_PersistsOnceForSingleRecipient(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000},
}}
blobs := &recordingBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc", SizeBytes: 5}}
messages := &recordingMessageRepository{}
session := newTestSession(mailboxes, blobs, messages)
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
_ = session.Rcpt("user@example.test", &gosmtp.RcptOptions{})
body := "Subject: hi\r\n\r\nhello"
if err := session.Data(bytes.NewBufferString(body)); err != nil {
t.Fatalf("Data: %v", err)
}
if string(blobs.stored) != body {
t.Errorf("stored blob = %q, want %q", blobs.stored, body)
}
if len(messages.persisted) != 1 {
t.Fatalf("persisted %d messages, want 1", len(messages.persisted))
}
if messages.persisted[0].MailboxID != mailboxID {
t.Errorf("persisted MailboxID = %v, want %v", messages.persisted[0].MailboxID, mailboxID)
}
}
func TestSession_Rcpt_DeduplicatesRepeatedAddress(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000},
}}
blobs := &recordingBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc", SizeBytes: 5}}
messages := &recordingMessageRepository{}
session := newTestSession(mailboxes, blobs, messages)
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
if err := session.Rcpt("user@example.test", &gosmtp.RcptOptions{}); err != nil {
t.Fatalf("first Rcpt: %v", err)
}
if err := session.Rcpt("user@example.test", &gosmtp.RcptOptions{}); err != nil {
t.Fatalf("duplicate Rcpt: %v", err)
}
if err := session.Data(bytes.NewBufferString("Subject: hi\r\n\r\nhello")); err != nil {
t.Fatalf("Data: %v", err)
}
if len(messages.persisted) != 1 {
t.Fatalf("persisted %d messages, want 1 (duplicate RCPT should not double-persist)", len(messages.persisted))
}
}
func TestSession_Reset_ClearsAccumulatedRecipients(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000},
}}
messages := &recordingMessageRepository{}
session := newTestSession(mailboxes, &recordingBlobStorage{}, messages)
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
_ = session.Rcpt("user@example.test", &gosmtp.RcptOptions{})
session.Reset()
// After Reset, Data with no prior Mail/Rcpt in this transaction must not
// silently persist anything.
err := session.Data(bytes.NewBufferString("Subject: x\r\n\r\nbody"))
if err == nil {
t.Fatal("expected an error calling Data after Reset with no recipients, got nil")
}
if len(messages.persisted) != 0 {
t.Errorf("persisted %d messages after Reset, want 0", len(messages.persisted))
}
}