feat(regras): aplica as regras na entrega e envia a resposta de ausência

A metade que faltava. O motor já existia e nada o chamava; agora o
caminho de entrega consulta as regras da caixa que está a receber, e a
resposta de ausência é mesmo enviada.

Cada destinatário tem as suas próprias regras, avaliadas sobre a sua
própria cópia: duas caixas neste servidor que recebem a mesma mensagem
podem arquivá-la em sítios diferentes.

Nada disto pode impedir o correio de chegar. Um script que não faz
parse, ou uma base de dados que não responde, deixa a mensagem ser
entregue sem filtro em vez de a recusar - recusar diria ao remetente
que a mensagem falhou, e o servidor dele voltaria a tentar para sempre.
discard é aceitar e depois deitar fora, pela mesma razão: uma regra de
arquivo não é uma rejeição.

A resposta de ausência sai pela mesma fila, assinatura e limites de
envio que qualquer outra mensagem deste servidor - não é um caminho
especial - e só depois de a mensagem que a desencadeou estar guardada,
para não responder a correio que a caixa afinal não recebeu. Leva
Auto-Submitted: auto-replied, que é o que impede duas caixas ambas de
férias de se responderem uma à outra até uma esgotar a quota.

Pastas próprias. Só existiam as seis padrão, portanto uma regra que
dizia "arquiva isto em Faturas" nomeava uma pasta que não havia como
criar. Criar, renomear e apagar, com as reservadas protegidas: cada uma
delas significa alguma coisa para o protocolo ou para este servidor, e
deixar renomear a INBOX partia caminhos que nenhuma mensagem de erro
explica. Renomear não mexe nas mensagens nem nos UIDs, senão um cliente
IMAP perdia de vista tudo o que lá está dentro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:35:17 -03:00
1 parent b9ebf606e1
commit 42420a7de2
14 files changed
+1082 -6

No files matched your search

@@ -66,12 +66,22 @@ type ProcessInboundEmailUseCase struct {
folders port.ImapFolderRepository
trackerManager *MailboxTrackerManager
reportIngestor *IngestDeliveredReportsUseCase
rules *ApplyRulesUseCase
submission *ProcessOutboundEmailUseCase
auth port.AuthRepository
replyHost string
}
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages}
}
// SetRules enables the mailbox's own filing rules. Optional: without it a
// message is delivered exactly as it was before rules existed.
func (uc *ProcessInboundEmailUseCase) SetRules(rules *ApplyRulesUseCase) {
uc.rules = rules
}
// SetReportIngestor enables reading DMARC and TLS-RPT reports out of the mail
// that carries them. Optional: with no ingestor a report is delivered like any
// other message, which is what happened before this existed.
@@ -250,6 +260,8 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
// was sent to is what makes it one, and leaving a broken report in the
// inbox instead would put back exactly the noise this removes. The bytes
// are kept either way, so it can be re-ingested once the cause is fixed.
var vacationReplies []pendingVacation
alreadySeen := false
if uc.reportIngestor != nil {
outcome, err := uc.reportIngestor.Ingest(ctx, input.RecipientAddresses, payload)
@@ -276,14 +288,41 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
// same headers, and the list view reads them instead of the blob.
headers := ExtractMessageHeaders(payload)
persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients))
persistInputs := make([]port.PersistInboundMessageInput, 0, len(input.Recipients))
for i, recipient := range input.Recipients {
persistInputs[i] = port.PersistInboundMessageInput{
// Each recipient's own rules decide their own copy: two mailboxes on
// this server receiving the same message may file it differently.
folder := targetFolder
var decision RuleDecision
if uc.rules != nil {
decision = uc.rules.For(ctx, recipient.ID, input.RecipientAddresses[i], input.Sender, payload)
if decision.Discard {
// Accepted and then dropped. The sender is told the message was
// delivered, because a filing rule is not a rejection - saying
// otherwise would have their server retry it forever.
log.Printf("rules: %s discarded a message from %s",
input.RecipientAddresses[i], input.Sender)
continue
}
if decision.Folder != "" {
folder = decision.Folder
}
if decision.Vacation != nil {
vacationReplies = append(vacationReplies, pendingVacation{
To: input.Sender,
From: input.RecipientAddresses[i],
Subject: decision.Vacation.Subject,
Body: decision.Vacation.Body,
})
}
}
persistInputs = append(persistInputs, port.PersistInboundMessageInput{
MailboxID: recipient.ID,
Blob: blob,
SenderAddress: input.Sender,
RecipientAddress: input.RecipientAddresses[i],
TargetFolderName: targetFolder,
TargetFolderName: folder,
Subject: headers.Subject,
Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName,
@@ -293,13 +332,24 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC,
}
})
}
// Every recipient's rules discarded it: nothing to file, and the sender is
// still told the message was accepted.
if len(persistInputs) == 0 {
uc.sendVacationReplies(ctx, vacationReplies)
return nil
}
if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil {
return err
}
// Only after the message is safely stored: a reply sent for a delivery
// that then rolled back would answer mail the mailbox never received.
uc.sendVacationReplies(ctx, vacationReplies)
// 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 {