#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,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")
}
}