#2: implement F1 sub-project 1 - inbound SMTP listener with EML storage
Adds a real :25 SMTP listener to the Go protocols service, giving F1's "Nucleo SMTP" milestone its first working sub-project: application, infrastructure, and presentation layers on top of F0's domain-only Clean Architecture. - ProcessInboundEmailUseCase resolves recipients against Postgres and persists accepted mail (blob dedup by content hash, transactional UID allocation + outbox event, never sending 250 OK before durable commit per PLAN.md section 6.1). - Postgres repositories via pgx/v5 (replacing lib/pq entirely). - Local-disk blob storage with content-hash dedup, rename-before-commit ordering so a DB row can never reference a file that isn't actually on disk. - A local-file TLS cert provider for STARTTLS, with an ephemeral self-signed fallback so a missing/misconfigured cert degrades gracefully instead of crash-looping the service (PLAN.md section 8.4). - A github.com/emersion/go-smtp Backend/Session adapting real SMTP traffic to the use case: unknown recipients get 550 5.1.1 before DATA, duplicate RCPT TO for the same address is deduplicated, TLS is hardened to the project's required PFS-only cipher/curve policy. - A dev-seed script and Makefile target for local testing. Explicitly out of scope (separate follow-up sub-projects): alias resolution, SPF/DKIM/DMARC/ARC verification, Rspamd/ClamAV, Submission ports with SASL auth, DKIM signing, outbound delivery, and the real Traefik acme.json cert watcher. Verified live end-to-end via WSL Docker: the full compose stack (db, redis, migrate, auth, storage, protocols, web) reaches healthy, and a real swaks SMTP session is accepted and durably persisted with correct sequential UIDs, while an unknown recipient is correctly rejected with 550 5.1.1 and zero rows persisted. Built via 10 sequential tasks under subagent-driven development, each independently reviewed, plus a final whole-branch review that caught and fixed a TLS-crash bug, a blob commit-ordering bug, missing TLS hardening, and missing CI integration coverage before merge. Known follow-up, not blocking: email_messages' partition only covers July 2026 and needs a partition-management job before 2026-08-01.
This commit is contained in:
1 parent
7c91182111
commit
e501cdc69e
27 files changed
+1468
-17
No files matched your search
@@ -0,0 +1,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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user