O lang-lint exige que a fonte deste repositório seja ASCII, e os testes que escrevi usavam acentos em texto de exemplo. Isto teria feito o CI vermelho. Onde o acento era só texto de exemplo, foi substituído. Nos dois testes onde o acento é o que está a ser testado - que um nome de pasta acentuado sobrevive ao parser, e que uma pasta pode chamar-se "Relatorios de Marco" - fica escrito como escape Unicode, que mantém o teste a verificar exactamente a mesma coisa sem pôr o caractere na fonte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
8.6 KiB
Go
234 lines
8.6 KiB
Go
package usecase
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
)
|
|
|
|
// The rules, applied to mail as it is delivered.
|
|
|
|
type stubSieveScripts struct {
|
|
script string
|
|
err error
|
|
asked []uuid.UUID
|
|
}
|
|
|
|
func (s *stubSieveScripts) ActiveScriptFor(_ context.Context, mailboxID uuid.UUID) (string, error) {
|
|
s.asked = append(s.asked, mailboxID)
|
|
return s.script, s.err
|
|
}
|
|
|
|
func newRules(scripts *stubSieveScripts) *ApplyRulesUseCase {
|
|
return NewApplyRulesUseCase(scripts)
|
|
}
|
|
|
|
const filingRule = `if header :contains "Subject" "Fatura" { fileinto "Faturas"; }`
|
|
|
|
func TestRuleDecidesTheFolder(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: filingRule})
|
|
payload := []byte("From: banco@example.test\r\nSubject: Fatura de agosto\r\n\r\ncorpo")
|
|
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "banco@example.test", payload)
|
|
if decision.Folder != "Faturas" {
|
|
t.Errorf("folder %q, want Faturas", decision.Folder)
|
|
}
|
|
}
|
|
|
|
func TestMessageWithNoMatchingRuleIsUntouched(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: filingRule})
|
|
payload := []byte("From: amigo@example.test\r\nSubject: Almoco\r\n\r\ncorpo")
|
|
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "amigo@example.test", payload)
|
|
if decision.Folder != "" || decision.Discard {
|
|
t.Errorf("an unmatched message was acted on: %+v", decision)
|
|
}
|
|
}
|
|
|
|
// A mailbox with no rules is the common case and must cost nothing.
|
|
func TestNoScriptMeansNoDecision(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: ""})
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "a@b.test",
|
|
[]byte("Subject: x\r\n\r\ny"))
|
|
if decision.Folder != "" || decision.Discard || decision.Vacation != nil {
|
|
t.Errorf("a mailbox with no rules got a decision: %+v", decision)
|
|
}
|
|
}
|
|
|
|
// The rules must never be able to stop mail arriving. A broken script, or a
|
|
// database that cannot be read, means the message is delivered unfiltered -
|
|
// not that it bounces.
|
|
func TestABrokenScriptStillDeliversTheMessage(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: `if header :contains "Subject" {{{`})
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "a@b.test",
|
|
[]byte("Subject: x\r\n\r\ny"))
|
|
if decision.Folder != "" || decision.Discard {
|
|
t.Errorf("a broken script changed the delivery: %+v", decision)
|
|
}
|
|
}
|
|
|
|
func TestAFailingLookupStillDeliversTheMessage(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{err: errors.New("database is down")})
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "a@b.test",
|
|
[]byte("Subject: x\r\n\r\ny"))
|
|
if decision.Folder != "" || decision.Discard {
|
|
t.Errorf("an unreadable script changed the delivery: %+v", decision)
|
|
}
|
|
}
|
|
|
|
// --- vacation ------------------------------------------------------------
|
|
|
|
const vacationRule = `require ["vacation"];
|
|
vacation :subject "Fora do escritorio" "Volto dia 30.";`
|
|
|
|
func TestVacationRepliesToAPerson(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: vacationRule})
|
|
payload := []byte("From: amigo@example.test\r\nSubject: Oi\r\n\r\ncorpo")
|
|
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "amigo@example.test", payload)
|
|
if decision.Vacation == nil {
|
|
t.Fatal("no reply was produced")
|
|
}
|
|
if decision.Vacation.Subject != "Fora do escritorio" {
|
|
t.Errorf("subject %q", decision.Vacation.Subject)
|
|
}
|
|
}
|
|
|
|
// The header the message carries decides, not just the envelope: a mailing
|
|
// list is recognised by List-Id, which lives in the headers.
|
|
func TestVacationStaysSilentForAList(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: vacationRule})
|
|
payload := []byte("From: lista@example.test\r\nList-Id: <l.example.test>\r\nSubject: Oi\r\n\r\ncorpo")
|
|
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "lista@example.test", payload)
|
|
if decision.Vacation != nil {
|
|
t.Error("replied to a mailing list")
|
|
}
|
|
}
|
|
|
|
func TestVacationStaysSilentForABounce(t *testing.T) {
|
|
uc := newRules(&stubSieveScripts{script: vacationRule})
|
|
payload := []byte("From: MAILER-DAEMON@example.test\r\nSubject: failed\r\n\r\ncorpo")
|
|
|
|
// Empty envelope sender: what a bounce actually arrives with.
|
|
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "", payload)
|
|
if decision.Vacation != nil {
|
|
t.Error("replied to a bounce")
|
|
}
|
|
}
|
|
|
|
// The rules belong to the mailbox being delivered to, not to the sender.
|
|
func TestRulesAreLookedUpForTheReceivingMailbox(t *testing.T) {
|
|
scripts := &stubSieveScripts{script: filingRule}
|
|
uc := newRules(scripts)
|
|
mailboxID := uuid.New()
|
|
|
|
uc.For(context.Background(), mailboxID, "me@example.test", "a@b.test",
|
|
[]byte("Subject: Fatura\r\n\r\ny"))
|
|
|
|
if len(scripts.asked) != 1 || scripts.asked[0] != mailboxID {
|
|
t.Errorf("looked up %v, want the receiving mailbox %v", scripts.asked, mailboxID)
|
|
}
|
|
}
|
|
|
|
var _ = port.MailboxRecord{}
|
|
|
|
// --- the delivery path ---------------------------------------------------
|
|
|
|
// The rule has to reach the message that is actually filed.
|
|
func TestDeliveryFilesWhereTheRuleSays(t *testing.T) {
|
|
messages := &recordingMessageRepository{}
|
|
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
|
uc.SetRules(newRules(&stubSieveScripts{script: filingRule}))
|
|
|
|
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "banco@example.test",
|
|
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
|
RecipientAddresses: []string{"me@example.test"},
|
|
Body: bytesReader("From: banco@example.test\r\nSubject: Fatura de agosto\r\n\r\ncorpo"),
|
|
}); err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
|
|
if len(messages.persisted) != 1 {
|
|
t.Fatalf("want one filed copy, got %d", len(messages.persisted))
|
|
}
|
|
if messages.persisted[0].TargetFolderName != "Faturas" {
|
|
t.Errorf("filed in %q, want Faturas", messages.persisted[0].TargetFolderName)
|
|
}
|
|
}
|
|
|
|
// Each recipient gets their own rules: two mailboxes on this server receiving
|
|
// the same message may file it in different places.
|
|
func TestEachRecipientGetsTheirOwnRules(t *testing.T) {
|
|
messages := &recordingMessageRepository{}
|
|
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
|
uc.SetRules(newRules(&stubSieveScripts{script: filingRule}))
|
|
|
|
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "banco@example.test",
|
|
Recipients: []port.MailboxRecord{{ID: uuid.New()}, {ID: uuid.New()}},
|
|
RecipientAddresses: []string{"a@example.test", "b@example.test"},
|
|
Body: bytesReader("Subject: Fatura\r\n\r\ncorpo"),
|
|
}); err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if len(messages.persisted) != 2 {
|
|
t.Fatalf("want two copies, got %d", len(messages.persisted))
|
|
}
|
|
for i, filed := range messages.persisted {
|
|
if filed.TargetFolderName != "Faturas" {
|
|
t.Errorf("copy %d filed in %q", i, filed.TargetFolderName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A discarded message is accepted and then dropped: the sender is told it was
|
|
// delivered, because a filing rule is not a rejection.
|
|
func TestDiscardAcceptsTheMessageAndFilesNothing(t *testing.T) {
|
|
messages := &recordingMessageRepository{}
|
|
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
|
uc.SetRules(newRules(&stubSieveScripts{
|
|
script: `if header :contains "Subject" "spam" { discard; }`,
|
|
}))
|
|
|
|
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "spammer@example.test",
|
|
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
|
RecipientAddresses: []string{"me@example.test"},
|
|
Body: bytesReader("Subject: spam barato\r\n\r\ncorpo"),
|
|
}); err != nil {
|
|
t.Fatalf("a discarded message must still be accepted: %v", err)
|
|
}
|
|
if len(messages.persisted) != 0 {
|
|
t.Errorf("a discarded message was filed: %d copies", len(messages.persisted))
|
|
}
|
|
}
|
|
|
|
// Without rules configured, delivery behaves exactly as it did before.
|
|
func TestDeliveryIsUnchangedWithoutRules(t *testing.T) {
|
|
messages := &recordingMessageRepository{}
|
|
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
|
|
|
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "banco@example.test",
|
|
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
|
RecipientAddresses: []string{"me@example.test"},
|
|
Body: bytesReader("Subject: Fatura\r\n\r\ncorpo"),
|
|
}); err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if messages.persisted[0].TargetFolderName == "Faturas" {
|
|
t.Error("a rule ran with no rules configured")
|
|
}
|
|
}
|
|
|
|
// bytesReader is the message body as the delivery path receives it.
|
|
func bytesReader(s string) io.Reader { return bytes.NewBufferString(s) }
|