fix(vacation): descodifica o assunto e responde a cada pessoa uma vez

O assunto que chegou a uma caixa real: "Fora do escritorio: Re:
=?UTF-8?Q?Re=3A_Fora_do_escrit=C3=B3rio?=". Duas coisas erradas, e a
segunda maior do que parece.

Um assunto com acento viaja como palavra codificada (RFC 2047). Colei o
cabecalho em bruto, portanto aquilo foi lido tal e qual por uma pessoa.
E foi tambem o que derrotou a verificacao do "Re:" ja existente:
codificado, todo o assunto comeca por "=?", nunca por "Re:", e por isso
foi acrescentado outro por cima. Descodificar corrige os dois sintomas
de uma vez. Os prefixos acumulados ("Re: RE: Re:") passam a ser
reduzidos a um.

O que estava por tras: aquele assunto era "Re: Fora do escritorio" -
alguem tinha respondido a propria mensagem automatica, e ela respondeu
de novo. Faltava a supressao por remetente. O RFC 5230 seccao 4.1 pede
exactamente isto e propoe sete dias: sem ela, uma conversa de cinco
mensagens produzia cinco copias do mesmo aviso, e quem respondesse ao
aviso recebia outro - que e como uma pessoa e um respondedor automatico
acabam a falar um para o outro indefinidamente.

A supressao falha aberta: um registo que nao se consegue ler deixa a
resposta sair. Ser avisado duas vezes e uma falha menor do que nunca
saber que a caixa esta ausente, que e o proposito da funcionalidade. O
envio so e registado depois de a resposta estar mesmo na fila, para que
um envio falhado nao silencie a mensagem seguinte da mesma pessoa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 15:15:58 -03:00
1 parent 866bd21846
commit e4491479ef
10 files changed
+451 -6

No files matched your search

@@ -148,6 +148,10 @@ func run(cfg config) {
// the same queue, signing and send limits as any other message this
// server sends - it is not a special path.
inboundUC.SetVacationSender(submissionUC, authRepo, cfg.PrimaryMailHost)
// One notice per sender per week, per RFC 5230. Without this a
// conversation of five messages produced five copies of it.
inboundUC.SetVacationSuppression(usecase.NewVacationSuppression(
postgres.NewVacationLog(pool), usecase.DefaultVacationPeriod))
mxResolver := netdns.NewNetMXResolver()
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost)
@@ -2,6 +2,7 @@ package port
import (
"context"
"time"
"github.com/google/uuid"
@@ -41,3 +42,15 @@ type SieveScriptReader interface {
// has none.
ActiveScriptFor(ctx context.Context, mailboxID uuid.UUID) (string, error)
}
// VacationLog remembers who has already been told the mailbox is unattended.
//
// RFC 5230 section 4.1: an autoresponder answers a given sender at most once
// per period. Without this, every message in a conversation produces its own
// copy of the same notice.
type VacationLog interface {
// LastRepliedAt is when this sender was last answered, or the zero time
// when they never have been.
LastRepliedAt(ctx context.Context, mailboxID uuid.UUID, to string) (time.Time, error)
RecordReply(ctx context.Context, mailboxID uuid.UUID, to string) error
}
@@ -70,6 +70,7 @@ type ProcessInboundEmailUseCase struct {
submission *ProcessOutboundEmailUseCase
auth port.AuthRepository
replyHost string
suppression *VacationSuppression
}
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
@@ -307,7 +308,11 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
if decision.Folder != "" {
folder = decision.Folder
}
if decision.Vacation != nil {
// Someone who has already been told recently is not told again:
// otherwise a conversation of five messages produces five copies
// of the same notice, and a person replying to the automatic
// message is answered once more.
if decision.Vacation != nil && uc.suppression.ShouldReply(ctx, recipient.ID, input.Sender) {
// The message being answered travels with the reply, so it
// arrives inside that conversation instead of as a loose note
// the sender has to match up themselves.
@@ -320,6 +325,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
OriginalMessageID: firstHeader(original, "message-id"),
OriginalReferences: firstHeader(original, "references"),
OriginalSubject: firstHeader(original, "subject"),
MailboxID: recipient.ID,
})
}
}
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"log"
"mime"
"strings"
"time"
@@ -26,6 +27,8 @@ type pendingVacation struct {
OriginalMessageID string
OriginalReferences string
OriginalSubject string
// MailboxID is whose responder this is, for the suppression log.
MailboxID uuid.UUID
}
// SetVacationSender enables actually sending the out-of-office replies the
@@ -39,6 +42,12 @@ func (uc *ProcessInboundEmailUseCase) SetVacationSender(
uc.replyHost = host
}
// SetVacationSuppression limits how often one sender is told. Optional:
// without it every message produces a reply, which is what happened before.
func (uc *ProcessInboundEmailUseCase) SetVacationSuppression(s *VacationSuppression) {
uc.suppression = s
}
// sendVacationReplies queues the replies the rules produced.
//
// Failures are logged and swallowed. The reply is a courtesy; the message that
@@ -59,7 +68,11 @@ func (uc *ProcessInboundEmailUseCase) sendVacationReplies(ctx context.Context, r
for _, reply := range replies {
if err := uc.sendVacationReply(ctx, reply); err != nil {
log.Printf("vacation: could not reply to %s on behalf of %s: %v", reply.To, reply.From, err)
continue
}
// Recorded only after the reply is really queued, so a send that
// failed does not silence the next message from the same person.
uc.suppression.Record(ctx, reply.MailboxID, reply.To)
}
}
@@ -137,18 +150,64 @@ func vacationSubject(reply pendingVacation) string {
if configured == "" {
configured = "Out of office"
}
original := strings.TrimSpace(reply.OriginalSubject)
// Decoded first. A subject carrying an accent travels as an RFC 2047
// encoded word, and pasting the raw header in put
// "=?UTF-8?Q?Re=3A_Fora...?=" in front of a real person. It also defeated
// the check below: encoded, every subject starts with "=?", so an existing
// "Re:" went unrecognised and a second one was added on top.
original := stripReplyPrefixes(decodeHeaderValue(reply.OriginalSubject))
if original == "" {
return configured
}
// A subject that already announces itself as a reply does not need a
// second announcement.
if strings.HasPrefix(strings.ToLower(original), "re:") {
return configured + ": " + original
// Someone replying to the automatic message itself would otherwise get
// their own out-of-office text quoted back at them as the conversation's
// subject.
if strings.EqualFold(original, configured) {
return configured
}
return configured + ": Re: " + original
}
// decodeHeaderValue reads an RFC 2047 encoded word back into text.
//
// A header that is not encoded, or that this decoder cannot read, is returned
// as it stands: showing the raw value is better than showing nothing, and a
// subject is not worth failing a reply over.
func decodeHeaderValue(value string) string {
raw := strings.TrimSpace(value)
if raw == "" {
return ""
}
decoded, err := (&mime.WordDecoder{}).DecodeHeader(raw)
if err != nil {
return raw
}
return strings.TrimSpace(decoded)
}
// stripReplyPrefixes removes the Re: a thread accumulates.
//
// A conversation that has been round several times carries "Re: RE: Re:", and
// each one added another. One is enough, and it is added back by the caller.
func stripReplyPrefixes(subject string) string {
out := strings.TrimSpace(subject)
for {
lower := strings.ToLower(out)
switch {
case strings.HasPrefix(lower, "re:"):
out = strings.TrimSpace(out[3:])
case strings.HasPrefix(lower, "fwd:"):
out = strings.TrimSpace(out[4:])
case strings.HasPrefix(lower, "fw:"):
out = strings.TrimSpace(out[3:])
default:
return out
}
}
}
// messageIDRef normalises a Message-ID into something usable as a reference.
//
// A value carrying a line break would end the header and let whatever follows
@@ -218,3 +218,65 @@ func TestDeliveredMessageProducesAThreadedReply(t *testing.T) {
}
}
}
// --- the subject as it actually arrives ----------------------------------
// A subject with an accent travels as an RFC 2047 encoded word. Pasting the
// raw header into the reply put "=?UTF-8?Q?Re=3A_Fora...?=" in front of a real
// person, and it also defeated the check for an existing "Re:" - encoded, the
// subject starts with "=?", so a second "Re:" was added on top.
func TestOriginalSubjectIsDecodedBeforeUse(t *testing.T) {
reply := replyTo("<abc@example.test>", "")
reply.OriginalSubject = "=?UTF-8?Q?Proposta_comercial?="
subject := vacationHeaders(t, reply)["subject"]
decoded := decodeHeaderValue(subject)
if strings.Contains(decoded, "=?UTF-8?") {
t.Errorf("an encoded word reached the reader: %q", decoded)
}
if !strings.Contains(decoded, "Proposta comercial") {
t.Errorf("subject %q", decoded)
}
}
// The same, for the "Re:" that is only visible once decoded.
func TestEncodedReplyPrefixIsNotDuplicated(t *testing.T) {
reply := replyTo("<abc@example.test>", "")
// "Re: Fora do escritorio", encoded the way a mail client sends it.
reply.OriginalSubject = "=?UTF-8?Q?Re=3A_Fora_do_escrit=C3=B3rio?="
decoded := decodeHeaderValue(vacationHeaders(t, reply)["subject"])
if strings.Count(strings.ToLower(decoded), "re:") > 1 {
t.Errorf("stacked Re: prefixes: %q", decoded)
}
}
// A thread that has been round several times accumulates prefixes. The reply
// carries one at most.
func TestManyReplyPrefixesCollapseToOne(t *testing.T) {
reply := replyTo("<abc@example.test>", "")
reply.OriginalSubject = "Re: RE: Re: Proposta comercial"
decoded := decodeHeaderValue(vacationHeaders(t, reply)["subject"])
if strings.Count(strings.ToLower(decoded), "re:") > 1 {
t.Errorf("prefixes were not collapsed: %q", decoded)
}
if !strings.Contains(decoded, "Proposta comercial") {
t.Errorf("the subject itself was lost: %q", decoded)
}
}
// The reply must not quote back the out-of-office text as though it were the
// subject of the conversation, which is what happens when someone replies to
// the automatic message itself.
func TestReplyToTheAutoReplyDoesNotEchoItself(t *testing.T) {
reply := replyTo("<abc@example.test>", "")
reply.Subject = "Fora do escritorio"
reply.OriginalSubject = "Re: Fora do escritorio"
decoded := decodeHeaderValue(vacationHeaders(t, reply)["subject"])
if strings.Count(strings.ToLower(decoded), "fora do escritorio") > 1 {
t.Errorf("the reply repeated its own subject back: %q", decoded)
}
}
@@ -0,0 +1,88 @@
package usecase
import (
"context"
"log"
"strings"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// VacationSuppression decides whether someone has already been told.
//
// Without it every message produced a reply: a conversation of five mails
// produced five identical out-of-office notes, and someone who replied to the
// automatic message itself was answered again - which is how a person and an
// autoresponder end up talking past each other indefinitely.
//
// RFC 5230 section 4.1 calls for exactly this, and names seven days as the
// default period.
type VacationSuppression struct {
log port.VacationLog
period time.Duration
}
// DefaultVacationPeriod is how long one notice stands for, per RFC 5230.
const DefaultVacationPeriod = 7 * 24 * time.Hour
func NewVacationSuppression(vacationLog port.VacationLog, period time.Duration) *VacationSuppression {
if period <= 0 {
period = DefaultVacationPeriod
}
return &VacationSuppression{log: vacationLog, period: period}
}
// ShouldReply reports whether this sender may be told again.
//
// Fails open: a log that cannot be read still lets the reply out. Being told
// twice is a smaller failure than someone never learning the mailbox is
// unattended, which is the whole purpose of the feature.
func (s *VacationSuppression) ShouldReply(ctx context.Context, mailboxID uuid.UUID, to string) bool {
if s == nil || s.log == nil {
return true
}
address := normaliseAddress(to)
if address == "" {
return false
}
last, err := s.log.LastRepliedAt(ctx, mailboxID, address)
if err != nil {
log.Printf("vacation: could not read the reply log for %s, replying anyway: %v", address, err)
return true
}
if last.IsZero() {
return true
}
return time.Since(last) >= s.period
}
// Record notes that this sender has been told.
func (s *VacationSuppression) Record(ctx context.Context, mailboxID uuid.UUID, to string) {
if s == nil || s.log == nil {
return
}
address := normaliseAddress(to)
if address == "" {
return
}
if err := s.log.RecordReply(ctx, mailboxID, address); err != nil {
// Logged and swallowed: the reply has already gone out, and failing
// here would only mean the caller thinks it did not.
log.Printf("vacation: could not record the reply to %s: %v", address, err)
}
}
// normaliseAddress makes one person one key, however their address was spelled.
func normaliseAddress(raw string) string {
address := strings.TrimSpace(strings.ToLower(raw))
if angled := strings.LastIndex(address, "<"); angled >= 0 {
if close := strings.Index(address[angled:], ">"); close > 0 {
address = address[angled+1 : angled+close]
}
}
return strings.TrimSpace(address)
}
@@ -0,0 +1,139 @@
package usecase
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
// How often the same person may be told you are away.
//
// Without this every message produced a reply, so a conversation of five mails
// produced five identical out-of-office notes - and someone who replied to the
// automatic message itself got another one, which is how a person and an
// autoresponder end up talking past each other indefinitely. RFC 5230 section
// 4.1 calls for a suppression period, and names seven days as the default.
type stubVacationLog struct {
last map[string]time.Time
saved []string
err error
}
func (s *stubVacationLog) LastRepliedAt(_ context.Context, mailbox uuid.UUID, to string) (time.Time, error) {
if s.err != nil {
return time.Time{}, s.err
}
return s.last[to], nil
}
func (s *stubVacationLog) RecordReply(_ context.Context, mailbox uuid.UUID, to string) error {
s.saved = append(s.saved, to)
return nil
}
func newSuppression(log *stubVacationLog) *VacationSuppression {
return NewVacationSuppression(log, 7*24*time.Hour)
}
func TestFirstMessageFromSomeoneGetsAReply(t *testing.T) {
log := &stubVacationLog{last: map[string]time.Time{}}
if !newSuppression(log).ShouldReply(context.Background(), uuid.New(), "amigo@example.test") {
t.Error("the first message from someone got no reply")
}
}
// The second message in the same conversation must not produce a second copy
// of the same notice.
func TestSecondMessageWithinThePeriodIsSilent(t *testing.T) {
log := &stubVacationLog{last: map[string]time.Time{
"amigo@example.test": time.Now().Add(-2 * time.Hour),
}}
if newSuppression(log).ShouldReply(context.Background(), uuid.New(), "amigo@example.test") {
t.Error("replied twice to the same person within the period")
}
}
// Once the period is up, someone writing again is told again - they may not
// remember, and the absence may have changed.
func TestSomeoneWritingAfterThePeriodIsToldAgain(t *testing.T) {
log := &stubVacationLog{last: map[string]time.Time{
"amigo@example.test": time.Now().Add(-8 * 24 * time.Hour),
}}
if !newSuppression(log).ShouldReply(context.Background(), uuid.New(), "amigo@example.test") {
t.Error("stayed silent after the suppression period had passed")
}
}
func TestDifferentPeopleEachGetOne(t *testing.T) {
log := &stubVacationLog{last: map[string]time.Time{
"amigo@example.test": time.Now(),
}}
if !newSuppression(log).ShouldReply(context.Background(), uuid.New(), "outro@example.test") {
t.Error("someone else's message was suppressed")
}
}
// The same person, spelled differently, is the same person.
func TestAddressComparisonIgnoresCase(t *testing.T) {
log := &stubVacationLog{last: map[string]time.Time{
"amigo@example.test": time.Now(),
}}
if newSuppression(log).ShouldReply(context.Background(), uuid.New(), "AMIGO@EXAMPLE.TEST") {
t.Error("the same address in another case got a second reply")
}
}
// A log that cannot be read must not stop the reply: being told twice is a
// smaller failure than never being told at all.
func TestAnUnreadableLogStillReplies(t *testing.T) {
log := &stubVacationLog{err: errTestLogUnavailable}
if !newSuppression(log).ShouldReply(context.Background(), uuid.New(), "amigo@example.test") {
t.Error("an unreadable log silenced the responder")
}
}
// Without a log configured at all, the responder behaves as it did before.
func TestNoLogMeansNoSuppression(t *testing.T) {
if !NewVacationSuppression(nil, time.Hour).ShouldReply(context.Background(), uuid.New(), "a@b.test") {
t.Error("a missing log silenced the responder")
}
}
func TestReplyIsRecorded(t *testing.T) {
log := &stubVacationLog{last: map[string]time.Time{}}
s := newSuppression(log)
s.Record(context.Background(), uuid.New(), "Amigo@Example.test")
if len(log.saved) != 1 || log.saved[0] != "amigo@example.test" {
t.Errorf("recorded %v, want the address lowercased", log.saved)
}
}
var errTestLogUnavailable = errTestUnavailable{}
type errTestUnavailable struct{}
func (errTestUnavailable) Error() string { return "log unavailable" }
// --- through the delivery path -------------------------------------------
// The second message from the same person in the same week produces no second
// notice, which is the behaviour the reader actually noticed was missing.
func TestDeliveryTellsEachSenderOnlyOnce(t *testing.T) {
replied := map[string]time.Time{}
suppression := NewVacationSuppression(&stubVacationLog{last: replied}, 7*24*time.Hour)
mailboxID := uuid.New()
if !suppression.ShouldReply(context.Background(), mailboxID, "amigo@example.test") {
t.Fatal("the first message got no reply")
}
// Recording is what the delivery path does once the reply is queued.
replied["amigo@example.test"] = time.Now()
if suppression.ShouldReply(context.Background(), mailboxID, "amigo@example.test") {
t.Error("a second message in the same week produced a second notice")
}
}
@@ -0,0 +1,56 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// VacationLog records who has already been told a mailbox is unattended.
type VacationLog struct {
pool *pgxpool.Pool
}
func NewVacationLog(pool *pgxpool.Pool) *VacationLog {
return &VacationLog{pool: pool}
}
// LastRepliedAt returns when this sender was last answered.
//
// A sender who has never been answered comes back as the zero time rather than
// an error: never having replied is the ordinary case, not a failure.
func (l *VacationLog) LastRepliedAt(
ctx context.Context, mailboxID uuid.UUID, to string,
) (time.Time, error) {
var last time.Time
err := l.pool.QueryRow(ctx, `
SELECT last_sent_at FROM vacation_replies
WHERE mailbox_id = $1 AND recipient = $2
`, mailboxID, to).Scan(&last)
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, fmt.Errorf("read the vacation log for %s: %w", to, err)
}
return last, nil
}
// RecordReply notes that this sender has been told, now.
func (l *VacationLog) RecordReply(ctx context.Context, mailboxID uuid.UUID, to string) error {
_, err := l.pool.Exec(ctx, `
INSERT INTO vacation_replies (mailbox_id, recipient, last_sent_at)
VALUES ($1, $2, NOW())
ON CONFLICT (mailbox_id, recipient)
DO UPDATE SET last_sent_at = NOW()
`, mailboxID, to)
if err != nil {
return fmt.Errorf("record the vacation reply to %s: %w", to, err)
}
return nil
}