Files
LambdaMail/services/protocols/internal/application/usecase/process_inbound_email.go
T
Lucas RayanandClaude Opus 5 d33068a43c #21: fill in message metadata and stop losing mail to a missing folder
Two problems the mail screens exposed, both in the delivery path.

The subject, snippet, sender name and message-id columns have existed
since the first migration, but nothing ever wrote them: every row went in
with those fields NULL. A message list built on the database would have
shown blank subjects for every message. Delivery now parses them once per
message and stores them, decoding RFC 2047 encoded words so an accented
subject is readable rather than raw. Parsing failures are non-fatal: a
message with unreadable headers is still delivered, just with less
metadata, because refusing it would lose mail over a malformed Subject.

Worse, a message the spam filter routed to Junk failed delivery outright
when the mailbox had no Junk folder - the sender got 451 and retried
forever against a mailbox that would never grow one on its own. Delivery
now falls back to INBOX and says so in the log. Found by sending a real
message through port 25 against the running stack.

The SMTP handler also swallowed the cause of any unexpected failure: the
peer saw "temporary error" and the operator got nothing. It is logged now,
which is how the missing folder was diagnosed at all.

Also adds the JWT verifier the mail API needs. The tokens are minted by
the auth service and verified here under the same secret, so the test
fixtures are real tokens produced by that service: if the two
implementations drift, this fails before the webmail does. Challenge
tokens, wrong secrets, tampered payloads, expired tokens and the other
surface's audience are all rejected.

The dev seed gained the standard folder set, which is what a provisioned
mailbox has and what the fallback above should not have to compensate for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:03:03 -03:00

276 lines
9.6 KiB
Go

// 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 (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"sort"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
// 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")
// 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.
type ProcessInboundEmailInput struct {
Sender string
Recipients []port.MailboxRecord
RecipientAddresses []string
Body io.Reader
// ClientIP and HeloDomain identify the peer. SPF is defined over them
// (RFC 7208 section 2.3), so a delivery without them can only ever
// produce a "none" verdict.
ClientIP net.IP
HeloDomain string
}
// 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 {
scanner port.ContentScanner
authenticator port.MailAuthenticator
arcSealer port.ArcSealer
mailboxes port.MailboxRepository
blobs port.BlobStorage
messages port.InboundMessageRepository
folders port.ImapFolderRepository
trackerManager *MailboxTrackerManager
}
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages}
}
func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) {
uc.scanner = scanner
}
func (uc *ProcessInboundEmailUseCase) SetAuthenticator(authenticator port.MailAuthenticator) {
uc.authenticator = authenticator
}
// SetArcSealer enables ARC sealing of accepted messages. It is only worth
// enabling on a host that forwards mail onward (PLAN.md section 5).
func (uc *ProcessInboundEmailUseCase) SetArcSealer(sealer port.ArcSealer) {
uc.arcSealer = sealer
}
func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) {
uc.trackerManager = tm
uc.folders = folders
}
// prependHeaders inserts the scanner's verdict headers at the top of the
// message. A receiving MTA adds its trace headers above the existing ones
// (RFC 5321 section 4.4), which also keeps the original bytes - and therefore
// any DKIM signature over them - untouched.
func prependHeaders(payload []byte, headers map[string]string) []byte {
if len(headers) == 0 {
return payload
}
// Sorted so the delivered message is byte-for-byte reproducible instead of
// depending on Go's randomized map iteration order.
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
sort.Strings(names)
var buf bytes.Buffer
for _, name := range names {
buf.WriteString(name)
buf.WriteString(": ")
buf.WriteString(headers[name])
buf.WriteString("\r\n")
}
buf.Write(payload)
return buf.Bytes()
}
// 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 len(targets) == 0 {
return nil, ErrRecipientNotFound
}
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
// 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 {
// The two slices are indexed in lockstep below: one envelope address per
// resolved mailbox, repeated when an alias fans out. Refusing a mismatch
// turns a caller's bug into an error instead of an out-of-range panic that
// would take down the calling worker.
if len(input.Recipients) != len(input.RecipientAddresses) {
return fmt.Errorf(
"process inbound email: %d recipients but %d envelope addresses",
len(input.Recipients), len(input.RecipientAddresses),
)
}
payload, err := io.ReadAll(input.Body)
if err != nil {
return err
}
targetFolder := "INBOX"
authResult := port.InboundAuthResult{
SPF: port.AuthResultNone,
DKIM: port.AuthResultNone,
DMARC: port.AuthResultNone,
}
// Authentication runs before the content scanners so that the spam filter
// sees our Authentication-Results header and can score on it
// (PLAN.md section 6.1).
if uc.authenticator != nil {
authResult = uc.authenticator.Authenticate(ctx, port.InboundAuthInput{
ClientIP: input.ClientIP,
HeloDomain: input.HeloDomain,
EnvelopeFrom: input.Sender,
Message: payload,
})
// Honouring a published "p=reject" is the whole point of the sender
// having published it. Quarantine is left to the spam filter, which
// files rather than refuses.
if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" {
return rejectDmarc()
}
if authResult.AuthenticationResults != "" {
payload = prependHeaders(payload, map[string]string{
"Authentication-Results": authResult.AuthenticationResults,
})
}
// The ARC set has to be built over the message as it arrived, with
// this hop's verdict already known, so it is sealed here rather than
// after the content scanners rewrite headers.
if uc.arcSealer != nil {
sealed, err := uc.arcSealer.Seal(ctx, payload, authResult)
if err != nil {
// A sealing failure must not lose the message: it is delivered
// without the ARC set, which is exactly the state it would
// have been in had sealing not been enabled.
log.Printf("arc: could not seal message from %s: %v", input.Sender, err)
} else {
payload = sealed
}
}
}
if uc.scanner != nil {
recipientAddr := ""
if len(input.RecipientAddresses) > 0 {
recipientAddr = input.RecipientAddresses[0]
}
scanRes, err := uc.scanner.Scan(ctx, port.ScanInput{
Sender: input.Sender,
Recipient: recipientAddr,
Payload: payload,
})
if err != nil {
return err
}
if scanRes != nil {
switch scanRes.Verdict {
case valueobject.ScanVerdictVirusReject:
return rejectVirus(scanRes.VirusName)
case valueobject.ScanVerdictSpamReject:
return rejectSpam()
case valueobject.ScanVerdictGreylist:
return deferGreylisted()
case valueobject.ScanVerdictSpamJunk:
targetFolder = "Junk"
}
payload = prependHeaders(payload, scanRes.HeadersToAdd)
}
}
blob, err := uc.blobs.Store(ctx, bytes.NewReader(payload))
if err != nil {
return err
}
// Every recipient is handed to the repository at once so the whole
// delivery commits or none of it does. Persisting them one call at a time
// would mean a failure halfway through leaves the earlier recipients with
// a copy while the sender is told to retry - and the retry delivers to
// them a second time.
// Parsed once for the whole delivery: every recipient's row carries the
// same headers, and the list view reads them instead of the blob.
headers := ExtractMessageHeaders(payload)
persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients))
for i, recipient := range input.Recipients {
persistInputs[i] = port.PersistInboundMessageInput{
MailboxID: recipient.ID,
Blob: blob,
SenderAddress: input.Sender,
RecipientAddress: input.RecipientAddresses[i],
TargetFolderName: targetFolder,
Subject: headers.Subject,
Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName,
MessageIDHeader: headers.MessageID,
HasAttachments: headers.HasAttachments,
SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC,
}
}
if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil {
return err
}
// Notifications happen only after the commit: an IDLE client told about a
// message that then rolled back would show one that does not exist.
for _, recipient := range input.Recipients {
if uc.trackerManager != nil && uc.folders != nil {
folderRec, err := uc.folders.FindByName(ctx, recipient.ID.String(), targetFolder)
if err == nil && folderRec != nil {
uc.trackerManager.NotifyNumMessages(folderRec.ID, folderRec.NumMessages)
}
}
}
return nil
}