#2: F1 sub-project 1 - inbound SMTP listener with EML storage

Merges the first F1 sub-project: a real :25 SMTP listener for the Go
protocols service with STARTTLS, Postgres-backed recipient resolution,
content-hash-deduplicated blob storage, and transactional persistence.
See commit e501cdc for full details.
This commit is contained in:
Lucas Rayan committed 2026-07-23 11:16:47 -03:00
commit 6980a08516
27 files changed
+1468 -17

No files matched your search

+3
View File
@@ -21,6 +21,9 @@ PUBLIC_IPV6=
# --- TLS (F0 has no real certs — protocols stub reports degraded, never crashes) --- # --- TLS (F0 has no real certs — protocols stub reports degraded, never crashes) ---
TLS_MODE=traefik TLS_MODE=traefik
COOLIFY_PROXY_DIR=./local/acme COOLIFY_PROXY_DIR=./local/acme
PROTOCOLS_CERT_PATH=/etc/lambdamail/certs/cert.pem
PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
# --- Webmail --- # --- Webmail ---
WEBMAIL_HOST=localhost:3000 WEBMAIL_HOST=localhost:3000
+25 -1
View File
@@ -14,15 +14,39 @@ jobs:
go-protocols: go-protocols:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: lambdamail_test
POSTGRES_USER: lambdamail_test
POSTGRES_PASSWORD: lambdamail_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults: defaults:
run: run:
working-directory: services/protocols working-directory: services/protocols
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: { go-version: "1.22" } with: { go-version-file: services/protocols/go.mod }
- name: Run migrations
run: |
docker run --rm --network host \
-v "$(pwd)/../../migrations:/migrations" \
migrate/migrate:v4.17.1 \
-path=/migrations \
-database "postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable" \
up
- run: go vet ./... - run: go vet ./...
- run: go test ./... -v - run: go test ./... -v
env:
TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable
ts-auth: ts-auth:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1
View File
@@ -17,6 +17,7 @@ build/
.env .env
.env.local .env.local
acme.json acme.json
local/
# Go # Go
*.test *.test
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: up down logs test lint migrate-up migrate-down .PHONY: up down logs test lint migrate-up migrate-down gen-dev-cert seed
up: up:
docker compose --env-file .env up --build docker compose --env-file .env up --build
@@ -25,3 +25,12 @@ migrate-up:
migrate-down: migrate-down:
docker run --rm -v $(PWD)/migrations:/migrations --network host \ docker run --rm -v $(PWD)/migrations:/migrations --network host \
migrate/migrate -path=/migrations -database "$$DATABASE_URL" down 1 migrate/migrate -path=/migrations -database "$$DATABASE_URL" down 1
gen-dev-cert:
mkdir -p local/certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout local/certs/key.pem -out local/certs/cert.pem \
-days 365 -nodes -subj "/CN=mail.localhost"
seed:
docker compose exec -T db psql -U $${POSTGRES_USER:-lambdamail} -d $${POSTGRES_DB:-lambdamail} < scripts/dev-seed.sql
+9 -1
View File
@@ -92,9 +92,16 @@ services:
build: { context: ./services/protocols } build: { context: ./services/protocols }
restart: unless-stopped restart: unless-stopped
logging: *default-logging logging: *default-logging
ports: ["8080:8080"] ports: ["8080:8080", "25:25"]
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost}
PROTOCOLS_CERT_PATH: /etc/lambdamail/certs/cert.pem
PROTOCOLS_KEY_PATH: /etc/lambdamail/certs/key.pem
PROTOCOLS_SPOOL_DIR: /var/lambdamail/spool
volumes:
- ./local/certs:/etc/lambdamail/certs:ro
- protocols_spool:/var/lambdamail/spool
depends_on: depends_on:
db: { condition: service_healthy } db: { condition: service_healthy }
migrate: { condition: service_completed_successfully } migrate: { condition: service_completed_successfully }
@@ -127,3 +134,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
protocols_spool:
+27
View File
@@ -0,0 +1,27 @@
-- Dev-only fixture data for local SMTP testing. Not a schema migration -
-- never applied in production, and safe to re-run (idempotent upserts).
INSERT INTO domains (id, name, punycode_name, is_active)
VALUES ('00000000-0000-0000-0000-000000000001', 'example.test', 'example.test', true)
ON CONFLICT (name) DO NOTHING;
-- Password: "dev-password-only" hashed with Argon2id - never use in production.
INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, is_active)
VALUES (
'00000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000001',
'postmaster',
'postmaster@example.test',
'$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG',
true
)
ON CONFLICT (domain_id, local_part) DO NOTHING;
INSERT INTO folders (id, mailbox_id, name, special_use, parent_id)
VALUES (
'00000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000002',
'INBOX',
'inbox',
NULL
)
ON CONFLICT (mailbox_id, name) WHERE (parent_id IS NULL) DO NOTHING;
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.22-alpine AS build FROM golang:1.25-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -1,30 +1,92 @@
// Command lambdamail-protocols is the entry point for the protocols service. // Command lambdamail-protocols is the entry point for the protocols service.
// F0 boots only the health endpoint; SMTP/IMAP/POP3/ManageSieve listeners land in F1/F2.
package main package main
import ( import (
"database/sql" "context"
"crypto/tls"
"log" "log"
"net/http" "net/http"
"os" "os"
_ "github.com/lib/pq" gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/health" "lambdamail/protocols/internal/health"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
) )
func main() { func main() {
dbURL := os.Getenv("DATABASE_URL") ctx := context.Background()
db, err := sql.Open("postgres", dbURL)
pool, err := postgres.NewPool(ctx, os.Getenv("DATABASE_URL"))
if err != nil { if err != nil {
log.Fatalf("open database: %v", err) log.Fatalf("open database pool: %v", err)
} }
defer db.Close() defer pool.Close()
handler := health.Handler(func() error { return db.Ping() }) spoolDir := envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool")
if err := os.MkdirAll(spoolDir, 0o755); err != nil {
log.Fatalf("create spool dir %s: %v", spoolDir, err)
}
mailboxes := postgres.NewMailboxRepository(pool)
blobs := diskstorage.NewLocalDiskBlobStorage(pool, spoolDir)
messages := postgres.NewInboundMessageRepository(pool)
useCase := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
certProvider, err := tlsprovider.NewLocalFileCertProvider(
os.Getenv("PROTOCOLS_CERT_PATH"),
os.Getenv("PROTOCOLS_KEY_PATH"),
)
if err != nil {
log.Printf("WARNING: could not load configured TLS certificate (%v) - falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for real deployments", err)
certProvider, err = tlsprovider.NewEphemeralSelfSignedCertProvider()
if err != nil {
log.Fatalf("generate fallback self-signed certificate: %v", err)
}
}
backend := smtppresentation.NewBackend(useCase)
smtpServer := gosmtp.NewServer(backend)
smtpServer.Addr = ":25"
smtpServer.Domain = envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost")
smtpServer.MaxMessageBytes = 52428800 // 50 MB default; per-domain limit is a future iteration
smtpServer.MaxRecipients = 100
smtpServer.TLSConfig = &tls.Config{
GetCertificate: certProvider.GetCertificate,
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
}
go func() {
log.Printf("lambdamail-protocols SMTP listening on %s", smtpServer.Addr)
if err := smtpServer.ListenAndServe(); err != nil {
log.Fatalf("smtp serve: %v", err)
}
}()
handler := health.Handler(func() error { return pool.Ping(ctx) })
addr := ":8080" addr := ":8080"
log.Printf("lambdamail-protocols listening on %s (health only - F0 scaffold)", addr) log.Printf("lambdamail-protocols health listening on %s", addr)
if err := http.ListenAndServe(addr, handler); err != nil { if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("serve: %v", err) log.Fatalf("serve: %v", err)
} }
} }
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+12 -2
View File
@@ -1,8 +1,18 @@
module lambdamail/protocols module lambdamail/protocols
go 1.22 go 1.25.0
require ( require (
github.com/emersion/go-smtp v0.24.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9 github.com/jackc/pgx/v5 v5.10.0
)
require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
) )
+30 -2
View File
@@ -1,4 +1,32 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -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))
}
}