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

@@ -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