#3: close 3 known F1 gaps - folder/mailbox counters, quota-at-RCPT, alias fan-out

Per feature/3-f0-f1-validation's own gap analysis (commit 097fa37):

- InboundMessageRepository.Persist now updates folders.unread_count,
  folders.total_count, and mailboxes.used_bytes in the same transaction
  as the message insert (PLAN.md section 6.1 "++counters" was a no-op
  before this).
- MailboxRecord carries QuotaBytes/UsedBytes; ResolveRecipient rejects
  RCPT with 452 4.2.2 when a target mailbox has already reached its
  quota (used_bytes >= quota_bytes), before DATA - never silently
  accepting mail into a full mailbox.
- MailboxRepository.ResolveDeliveryTargets tries a direct mailbox match
  first, then falls back to the aliases table (exact source_address
  match takes precedence over a domain catch-all). One RCPT TO can now
  fan out to multiple mailboxes; alias destinations that don't resolve
  to an internal active mailbox are silently skipped, since outbound/
  external forwarding doesn't exist yet. postmaster@/abuse@/dmarc@-style
  aliases now actually deliver.

The E2E test's counters_updated subtest flips from a documented KNOWN
GAP (t.Skipf) to a hard assertion now that it passes, and gains two new
subtests (quota_rejected_when_mailbox_full, alias_resolves_to_destination_mailbox)
covering the other two fixes end-to-end. New unit and Postgres
integration test coverage added for all three fixes at every layer.

Verified: full suite green on Windows (unit + E2E via embedded
Postgres, no Docker needed) and via WSL Docker against the real
docker-compose Postgres (all infrastructure/postgres and diskstorage
integration tests, not just E2E). lang-lint clean.
This commit is contained in:
Lucas Rayan committed 2026-07-23 11:36:03 -03:00
1 parent 097fa373e1
commit 2f7389841d
10 files changed
+466 -25

No files matched your search

@@ -12,11 +12,23 @@ import (
type MailboxRecord struct {
ID uuid.UUID
MaxMessageBytes int64
QuotaBytes int64
UsedBytes int64
}
// MailboxRepository resolves a recipient address to an active mailbox.
// MailboxRepository resolves a recipient address to the active mailbox(es)
// mail for it should be delivered to.
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)
// ResolveDeliveryTargets returns every active mailbox mail for address
// should be delivered to: the mailbox itself when address matches one
// directly, or the mailbox(es) reached through a matching alias
// (source_address exact match takes precedence over a domain
// catch-all). Returns an empty, non-nil slice (not an error) when
// nothing matches - a direct mailbox miss, an alias with no active
// destination, or no alias at all are all "not found", not failures.
ResolveDeliveryTargets(ctx context.Context, address string) ([]MailboxRecord, error)
}
@@ -14,6 +14,13 @@ import (
// 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")
// ErrMailboxQuotaExceeded is returned by ResolveRecipient when a resolved
// target mailbox has already reached its storage quota - the presentation
// layer maps this to SMTP 452 4.2.2. If a single RCPT resolves to multiple
// mailboxes (alias fan-out) and any one of them is full, the whole RCPT is
// rejected rather than partially delivering.
var ErrMailboxQuotaExceeded = errors.New("process inbound email: mailbox has reached its storage quota")
// 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.
@@ -38,16 +45,23 @@ func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.
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)
// ResolveRecipient is called from the SMTP session's RCPT TO handler. It
// returns every mailbox address should be delivered to (more than one for
// an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded.
func (uc *ProcessInboundEmailUseCase) ResolveRecipient(ctx context.Context, address string) ([]port.MailboxRecord, error) {
targets, err := uc.mailboxes.ResolveDeliveryTargets(ctx, address)
if err != nil {
return nil, err
}
if rec == nil {
if len(targets) == 0 {
return nil, ErrRecipientNotFound
}
return rec, nil
for _, t := range targets {
if t.UsedBytes >= t.QuotaBytes {
return nil, ErrMailboxQuotaExceeded
}
}
return targets, nil
}
// Handle is called once from the SMTP session's DATA handler, after one or
@@ -23,19 +23,27 @@ func (f *fakeMailboxRepository) FindActiveByAddress(_ context.Context, address s
return &rec, nil
}
func (f *fakeMailboxRepository) ResolveDeliveryTargets(_ context.Context, address string) ([]port.MailboxRecord, error) {
rec, ok := f.byAddress[address]
if !ok {
return []port.MailboxRecord{}, nil
}
return []port.MailboxRecord{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},
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000, QuotaBytes: 1000},
}}
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
rec, err := uc.ResolveRecipient(context.Background(), "user@example.test")
targets, 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)
if len(targets) != 1 || targets[0].ID != mailboxID {
t.Errorf("targets = %+v, want single record with ID %v", targets, mailboxID)
}
}
@@ -49,6 +57,44 @@ func TestUseCase_ResolveRecipient_ReturnsErrRecipientNotFoundForUnknownAddress(t
}
}
func TestUseCase_ResolveRecipient_ReturnsErrMailboxQuotaExceededWhenFull(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"full@example.test": {ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 1000},
}}
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
_, err := uc.ResolveRecipient(context.Background(), "full@example.test")
if !errors.Is(err, ErrMailboxQuotaExceeded) {
t.Fatalf("error = %v, want ErrMailboxQuotaExceeded", err)
}
}
func TestUseCase_ResolveRecipient_ReturnsErrMailboxQuotaExceededWhenAnyFanOutTargetIsFull(t *testing.T) {
mailboxes := &fakeAliasFanOutRepository{targets: []port.MailboxRecord{
{ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 0},
{ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 1000},
}}
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
_, err := uc.ResolveRecipient(context.Background(), "alias@example.test")
if !errors.Is(err, ErrMailboxQuotaExceeded) {
t.Fatalf("error = %v, want ErrMailboxQuotaExceeded when any fan-out target is full", err)
}
}
// fakeAliasFanOutRepository simulates an alias resolving to multiple mailboxes.
type fakeAliasFanOutRepository struct {
targets []port.MailboxRecord
}
func (f *fakeAliasFanOutRepository) FindActiveByAddress(_ context.Context, _ string) (*port.MailboxRecord, error) {
return nil, nil
}
func (f *fakeAliasFanOutRepository) ResolveDeliveryTargets(_ context.Context, _ string) ([]port.MailboxRecord, error) {
return f.targets, nil
}
type stubBlobStorage struct {
ref port.BlobRef
err error
@@ -175,9 +175,7 @@ func TestInboundSMTPEndToEnd(t *testing.T) {
t.Errorf("max uid = %d, want 2 (sequential per folder)", maxUID)
}
// --- 8. PLAN gaps this test documents deliberately --------------------
// The following FAIL today and are expected to: they mark the F1 scope
// that is not implemented yet. Remove t.Skip as each lands.
// --- 8. Folder/mailbox counters kept in sync with persisted messages --
t.Run("counters_updated", func(t *testing.T) {
var unread, total int
var used int64
@@ -190,7 +188,75 @@ func TestInboundSMTPEndToEnd(t *testing.T) {
t.Fatalf("counters query: %v", err)
}
if unread != 2 || total != 2 || used != sizeBytes*2 {
t.Skipf("KNOWN GAP (PLAN 6.1 '++counters'): unread=%d total=%d used=%d — repository does not update folder counters or mailboxes.used_bytes yet", unread, total, used)
t.Errorf("unread=%d total=%d used=%d, want unread=2 total=2 used=%d (PLAN section 6.1 '++counters')", unread, total, used, sizeBytes*2)
}
})
// --- 9. A full mailbox rejects RCPT with 452 4.2.2, in-session --------
t.Run("quota_rejected_when_mailbox_full", func(t *testing.T) {
domainID := "00000000-0000-0000-0000-000000000001"
fullMailboxID := "10000000-0000-0000-0000-000000000099"
fullAddress := "full@example.test"
if _, err := pool.Exec(ctx, `
INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, quota_bytes, used_bytes)
VALUES ($1, $2, 'full', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG', 1000, 1000)
`, fullMailboxID, domainID, fullAddress); err != nil {
t.Fatalf("seed full mailbox: %v", err)
}
c, err := smtp.Dial(addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
if err := c.Mail("sender@remote.example"); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
err = c.Rcpt(fullAddress)
if err == nil {
t.Fatal("RCPT to a full mailbox succeeded; want 452 4.2.2")
}
if !strings.Contains(err.Error(), "452") {
t.Errorf("full-mailbox RCPT error = %q, want SMTP 452", err)
}
})
// --- 10. An alias fans out to its destination mailbox -----------------
t.Run("alias_resolves_to_destination_mailbox", func(t *testing.T) {
domainID := "00000000-0000-0000-0000-000000000001"
aliasDestMailboxID := "10000000-0000-0000-0000-000000000098"
aliasDestAddress := "alias-dest@example.test"
aliasAddress := "hello@example.test"
if _, err := pool.Exec(ctx, `
INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
VALUES ($1, $2, 'alias-dest', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')
`, aliasDestMailboxID, domainID, aliasDestAddress); err != nil {
t.Fatalf("seed alias destination mailbox: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')
`, "10000000-0000-0000-0000-000000000097", aliasDestMailboxID); err != nil {
t.Fatalf("seed alias destination inbox folder: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO aliases (id, domain_id, source_address, destination_addresses)
VALUES ($1, $2, $3, $4)
`, "10000000-0000-0000-0000-000000000096", domainID, aliasAddress, []string{aliasDestAddress}); err != nil {
t.Fatalf("seed alias: %v", err)
}
if err := smtp.SendMail(addr, nil, "sender@remote.example",
[]string{aliasAddress}, []byte(testMessage)); err != nil {
t.Fatalf("SendMail to alias: %v", err)
}
var count int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM email_messages WHERE mailbox_id = $1`, aliasDestMailboxID).Scan(&count); err != nil {
t.Fatalf("query alias destination messages: %v", err)
}
if count != 1 {
t.Errorf("messages persisted for alias destination mailbox = %d, want 1", count)
}
})
@@ -39,8 +39,11 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
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)
if _, err := tx.Exec(ctx, `
UPDATE folders SET uid_next = uid_next + 1, unread_count = unread_count + 1, total_count = total_count + 1
WHERE id = $1
`, folderID); err != nil {
return 0, fmt.Errorf("advance uid_next and folder counters: %w", err)
}
// email_messages.id defaults to gen_random_uuid() per the migration - no explicit value needed.
@@ -61,6 +64,10 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
return 0, fmt.Errorf("increment blob ref_count: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = used_bytes + $1 WHERE id = $2`, input.Blob.SizeBytes, input.MailboxID); err != nil {
return 0, fmt.Errorf("increment mailbox used_bytes: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO domain_events_outbox (event_type, aggregate_id, payload)
VALUES ('EmailReceived', $1, $2)
@@ -73,6 +73,25 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
if refCount != 2 {
t.Errorf("ref_count = %d, want 2 after two Persist calls referencing the same blob", refCount)
}
var unreadCount, totalCount int
if err := pool.QueryRow(ctx, `SELECT unread_count, total_count FROM folders WHERE id = $1`, folderID).Scan(&unreadCount, &totalCount); err != nil {
t.Fatalf("query folder counters: %v", err)
}
if unreadCount != 2 {
t.Errorf("folder unread_count = %d, want 2 after two Persist calls", unreadCount)
}
if totalCount != 2 {
t.Errorf("folder total_count = %d, want 2 after two Persist calls", totalCount)
}
var usedBytes int64
if err := pool.QueryRow(ctx, `SELECT used_bytes FROM mailboxes WHERE id = $1`, mailboxID).Scan(&usedBytes); err != nil {
t.Fatalf("query mailbox used_bytes: %v", err)
}
if usedBytes != blob.SizeBytes*2 {
t.Errorf("mailbox used_bytes = %d, want %d (two Persist calls of %d bytes each)", usedBytes, blob.SizeBytes*2, blob.SizeBytes)
}
}
func TestInboundMessageRepository_Persist_ReturnsErrorWhenNoInboxFolderExists(t *testing.T) {
@@ -21,7 +21,7 @@ func NewMailboxRepository(pool *pgxpool.Pool) *MailboxRepository {
func (r *MailboxRepository) FindActiveByAddress(ctx context.Context, address string) (*port.MailboxRecord, error) {
row := r.pool.QueryRow(ctx, `
SELECT m.id, d.max_message_bytes
SELECT m.id, d.max_message_bytes, m.quota_bytes, m.used_bytes
FROM mailboxes m
JOIN domains d ON d.id = m.domain_id
WHERE m.email_address = $1
@@ -31,7 +31,7 @@ func (r *MailboxRepository) FindActiveByAddress(ctx context.Context, address str
var rec port.MailboxRecord
var id uuid.UUID
if err := row.Scan(&id, &rec.MaxMessageBytes); err != nil {
if err := row.Scan(&id, &rec.MaxMessageBytes, &rec.QuotaBytes, &rec.UsedBytes); err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
@@ -40,3 +40,49 @@ func (r *MailboxRepository) FindActiveByAddress(ctx context.Context, address str
rec.ID = id
return &rec, nil
}
// ResolveDeliveryTargets tries a direct mailbox match first, then falls back
// to aliases: an exact source_address match takes precedence over a
// domain-wide catch-all. Destination addresses that don't resolve to an
// internal active mailbox (e.g. external forwarding) are silently skipped -
// outbound delivery doesn't exist yet in this sub-project's scope.
func (r *MailboxRepository) ResolveDeliveryTargets(ctx context.Context, address string) ([]port.MailboxRecord, error) {
direct, err := r.FindActiveByAddress(ctx, address)
if err != nil {
return nil, err
}
if direct != nil {
return []port.MailboxRecord{*direct}, nil
}
var destinations []string
err = r.pool.QueryRow(ctx, `
SELECT a.destination_addresses
FROM aliases a
JOIN domains d ON d.id = a.domain_id
WHERE d.is_active = true
AND a.is_active = true
AND d.name = split_part($1, '@', 2)
AND (a.source_address = $1 OR (a.is_catch_all AND a.source_address = '*@' || split_part($1, '@', 2)))
ORDER BY a.is_catch_all ASC
LIMIT 1
`, address).Scan(&destinations)
if err != nil {
if err == pgx.ErrNoRows {
return []port.MailboxRecord{}, nil
}
return nil, err
}
targets := []port.MailboxRecord{}
for _, dest := range destinations {
rec, err := r.FindActiveByAddress(ctx, dest)
if err != nil {
return nil, err
}
if rec != nil {
targets = append(targets, *rec)
}
}
return targets, nil
}
@@ -118,3 +118,194 @@ func TestMailboxRepository_FindActiveByAddress_ExcludesInactiveMailbox(t *testin
t.Errorf("rec = %+v, want nil - mailbox is_active=false must be excluded", rec)
}
}
func TestMailboxRepository_FindActiveByAddress_ReturnsQuotaAndUsedBytes(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
domainID := uuid.New()
domainName := "repo-test-quota-" + 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)
address := "user@" + domainName
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, quota_bytes, used_bytes) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG', 5000, 4000)`,
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.Fatal("rec = nil, want a record for the seeded mailbox")
}
if rec.QuotaBytes != 5000 {
t.Errorf("QuotaBytes = %d, want 5000", rec.QuotaBytes)
}
if rec.UsedBytes != 4000 {
t.Errorf("UsedBytes = %d, want 4000", rec.UsedBytes)
}
}
func TestMailboxRepository_ResolveDeliveryTargets_PrefersDirectMailboxMatch(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
domainID := uuid.New()
domainName := "repo-test-direct-" + 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()
address := "user@" + domainName
_, 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)
targets, err := repo.ResolveDeliveryTargets(ctx, address)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 1 || targets[0].ID != mailboxID {
t.Errorf("targets = %+v, want single record with ID %v (direct mailbox match)", targets, mailboxID)
}
}
func TestMailboxRepository_ResolveDeliveryTargets_ResolvesSpecificAlias(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
domainID := uuid.New()
domainName := "repo-test-alias-" + 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)
destMailboxID := uuid.New()
destAddress := "realuser@" + domainName
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'realuser', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
destMailboxID, domainID, destAddress)
if err != nil {
t.Fatalf("seed destination mailbox: %v", err)
}
aliasAddress := "postmaster@" + domainName
_, err = pool.Exec(ctx, `INSERT INTO aliases (id, domain_id, source_address, destination_addresses) VALUES ($1, $2, $3, $4)`,
uuid.New(), domainID, aliasAddress, []string{destAddress})
if err != nil {
t.Fatalf("seed alias: %v", err)
}
repo := NewMailboxRepository(pool)
targets, err := repo.ResolveDeliveryTargets(ctx, aliasAddress)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 1 || targets[0].ID != destMailboxID {
t.Errorf("targets = %+v, want single record with ID %v (alias destination)", targets, destMailboxID)
}
}
func TestMailboxRepository_ResolveDeliveryTargets_ResolvesCatchAllAlias(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
domainID := uuid.New()
domainName := "repo-test-catchall-" + 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)
destMailboxID := uuid.New()
destAddress := "catchall-target@" + domainName
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'catchall-target', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
destMailboxID, domainID, destAddress)
if err != nil {
t.Fatalf("seed destination mailbox: %v", err)
}
_, err = pool.Exec(ctx, `INSERT INTO aliases (id, domain_id, source_address, destination_addresses, is_catch_all) VALUES ($1, $2, $3, $4, true)`,
uuid.New(), domainID, "*@"+domainName, []string{destAddress})
if err != nil {
t.Fatalf("seed catch-all alias: %v", err)
}
repo := NewMailboxRepository(pool)
targets, err := repo.ResolveDeliveryTargets(ctx, "whatever-unmapped@"+domainName)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 1 || targets[0].ID != destMailboxID {
t.Errorf("targets = %+v, want single record with ID %v (catch-all destination)", targets, destMailboxID)
}
}
func TestMailboxRepository_ResolveDeliveryTargets_ReturnsEmptySliceWhenNothingMatches(t *testing.T) {
pool := testPool(t)
defer pool.Close()
repo := NewMailboxRepository(pool)
targets, err := repo.ResolveDeliveryTargets(context.Background(), "definitely-not-seeded@example.invalid")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 0 {
t.Errorf("targets = %+v, want empty slice for an address with no mailbox and no alias", targets)
}
}
func TestMailboxRepository_ResolveDeliveryTargets_SkipsAliasDestinationThatIsNotAnInternalMailbox(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
domainID := uuid.New()
domainName := "repo-test-external-" + 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)
aliasAddress := "forward@" + domainName
_, err = pool.Exec(ctx, `INSERT INTO aliases (id, domain_id, source_address, destination_addresses) VALUES ($1, $2, $3, $4)`,
uuid.New(), domainID, aliasAddress, []string{"someone@external-provider.invalid"})
if err != nil {
t.Fatalf("seed alias: %v", err)
}
repo := NewMailboxRepository(pool)
targets, err := repo.ResolveDeliveryTargets(ctx, aliasAddress)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 0 {
t.Errorf("targets = %+v, want empty slice - external forwarding destinations are not internal mailboxes and outbound delivery does not exist yet", targets)
}
}
@@ -61,23 +61,35 @@ func (s *session) Rcpt(to string, _ *gosmtp.RcptOptions) error {
}
}
rec, err := s.useCase.ResolveRecipient(context.Background(), to)
targets, err := s.useCase.ResolveRecipient(context.Background(), to)
if err != nil {
if errors.Is(err, usecase.ErrRecipientNotFound) {
switch {
case errors.Is(err, usecase.ErrRecipientNotFound):
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 1, 1},
Message: "User unknown",
}
case errors.Is(err, usecase.ErrMailboxQuotaExceeded):
return &gosmtp.SMTPError{
Code: 452,
EnhancedCode: gosmtp.EnhancedCode{4, 2, 2},
Message: "Mailbox full",
}
default:
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error resolving recipient",
}
}
s.recipients = append(s.recipients, *rec)
}
// One RCPT TO can resolve to multiple mailboxes (alias fan-out) - each
// gets its own delivery, all recorded under the same envelope address.
for _, rec := range targets {
s.recipients = append(s.recipients, rec)
s.recipientAddresses = append(s.recipientAddresses, to)
}
return nil
}
@@ -26,6 +26,14 @@ func (f *fakeMailboxRepository) FindActiveByAddress(_ context.Context, address s
return &rec, nil
}
func (f *fakeMailboxRepository) ResolveDeliveryTargets(_ context.Context, address string) ([]port.MailboxRecord, error) {
rec, ok := f.byAddress[address]
if !ok {
return []port.MailboxRecord{}, nil
}
return []port.MailboxRecord{rec}, nil
}
type recordingBlobStorage struct {
stored []byte
ref port.BlobRef
@@ -61,7 +69,7 @@ func newTestSession(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorag
func TestSession_Rcpt_AcceptsKnownActiveRecipient(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000},
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000, QuotaBytes: 1000},
}}
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
@@ -94,7 +102,7 @@ func TestSession_Rcpt_Rejects550ForUnknownRecipient(t *testing.T) {
func TestSession_Data_PersistsOnceForSingleRecipient(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000},
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000, QuotaBytes: 1000},
}}
blobs := &recordingBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc", SizeBytes: 5}}
messages := &recordingMessageRepository{}
@@ -119,10 +127,30 @@ func TestSession_Data_PersistsOnceForSingleRecipient(t *testing.T) {
}
}
func TestSession_Rcpt_Rejects452ForFullMailbox(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"full@example.test": {ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 1000},
}}
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
err := session.Rcpt("full@example.test", &gosmtp.RcptOptions{})
if err == nil {
t.Fatal("expected an error for a full mailbox, got nil")
}
var smtpErr *gosmtp.SMTPError
if !errors.As(err, &smtpErr) {
t.Fatalf("error is not a *gosmtp.SMTPError: %v", err)
}
if smtpErr.Code != 452 {
t.Errorf("Code = %d, want 452", smtpErr.Code)
}
}
func TestSession_Rcpt_DeduplicatesRepeatedAddress(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000},
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000, QuotaBytes: 1000},
}}
blobs := &recordingBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc", SizeBytes: 5}}
messages := &recordingMessageRepository{}
@@ -147,7 +175,7 @@ func TestSession_Rcpt_DeduplicatesRepeatedAddress(t *testing.T) {
func TestSession_Reset_ClearsAccumulatedRecipients(t *testing.T) {
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000},
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000, QuotaBytes: 1000},
}}
messages := &recordingMessageRepository{}
session := newTestSession(mailboxes, &recordingBlobStorage{}, messages)