Files
LambdaMail/services/protocols/internal/application/usecase/vacation_reply.go
T
Lucas RayanandClaude Opus 5 866bd21846 fix(vacation): a resposta de ausencia passa a ser uma resposta
Chegava como conversa nova. Ia sem qualquer cabecalho de encadeamento,
por isso todos os clientes de correio a mostravam solta, sem relacao
com a mensagem que a tinha desencadeado - e quem escreveu tinha de
descobrir sozinho a qual dos seus emails aquilo respondia.

In-Reply-To nomeia a mensagem que esta a ser respondida e References
leva a cadeia inteira, que e o que permite a um cliente colocar a
resposta no sitio certo mesmo quando nunca viu as mensagens pelo meio
(RFC 5322 seccao 3.6.4). A cadeia e limitada em comprimento: uma
conversa longa faria este cabecalho crescer sem fim.

O assunto passa a ecoar o original - "Fora do escritorio: Re: Proposta
comercial" - porque e o que a pessoa le na sua lista, e porque ha
clientes que agrupam pelo assunto e nao apenas pelos cabecalhos. O
texto configurado fica como prefixo em vez de ser substituido, para
continuar a dizer o que a mensagem e. Um assunto que ja anuncia ser
resposta nao recebe um segundo "Re:".

Sem Message-ID no original nao se escreve nada: apontar para uma
mensagem inexistente faria os clientes mostrarem a resposta agarrada a
nada. Um Message-ID com quebra de linha e recusado por inteiro - viria
de um remetente remoto, e uma quebra ali terminaria o cabecalho e
deixaria o resto ser lido como mais cabecalhos.

Os cabecalhos que impedem dois respondedores automaticos de se
responderem um ao outro continuam todos la, com teste que o garante.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:43:52 -03:00

193 lines
6.7 KiB
Go

package usecase
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// pendingVacation is one automatic reply the rules asked for, held until the
// message that triggered it is safely stored.
type pendingVacation struct {
To string
From string
Subject string
Body string
// The message being answered. Without these the reply arrives as a
// conversation of its own, and the sender has to work out for themselves
// which of their messages produced it.
OriginalMessageID string
OriginalReferences string
OriginalSubject string
}
// SetVacationSender enables actually sending the out-of-office replies the
// rules produce. Without it a reply is logged and not sent, which is what
// happens on a deployment with no submission path configured.
func (uc *ProcessInboundEmailUseCase) SetVacationSender(
submission *ProcessOutboundEmailUseCase, auth port.AuthRepository, host string,
) {
uc.submission = submission
uc.auth = auth
uc.replyHost = host
}
// sendVacationReplies queues the replies the rules produced.
//
// Failures are logged and swallowed. The reply is a courtesy; the message that
// triggered it is already delivered, and returning an error here would tell
// the sender their mail failed when it did not.
func (uc *ProcessInboundEmailUseCase) sendVacationReplies(ctx context.Context, replies []pendingVacation) {
if len(replies) == 0 {
return
}
if uc.submission == nil || uc.auth == nil {
for _, reply := range replies {
log.Printf("vacation: no submission path configured, not replying to %s on behalf of %s",
reply.To, reply.From)
}
return
}
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)
}
}
}
func (uc *ProcessInboundEmailUseCase) sendVacationReply(ctx context.Context, reply pendingVacation) error {
account, err := uc.auth.FindByAddress(ctx, reply.From)
if err != nil {
return err
}
if account == nil {
return fmt.Errorf("no mailbox for %s", reply.From)
}
payload := buildVacationMessage(reply, uc.replyHost)
return uc.submission.Submit(ctx, ProcessOutboundEmailInput{
MailboxID: account.ID,
SenderAddr: account.EmailAddress,
DomainName: account.DomainName,
Recipients: []string{reply.To},
Body: bytes.NewReader(payload),
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
})
}
// buildVacationMessage assembles the reply.
//
// Auto-Submitted: auto-replied is what stops two servers answering each other
// forever: RFC 3834 requires it on an automatic reply, and this server's own
// responder refuses to answer anything carrying it. Without the header, two
// mailboxes both on holiday would reply to each other until one of them ran
// out of send quota.
//
// In-Reply-To and References are what make it a reply rather than a new
// message. RFC 5322 section 3.6.4: the first names the message being answered,
// the second carries the chain so a client can place the reply even when it
// never saw the messages in between. Without them every mail client showed the
// out-of-office note as an unrelated conversation.
func buildVacationMessage(reply pendingVacation, host string) []byte {
if host == "" {
host = "localhost"
}
var b strings.Builder
b.WriteString("From: " + reply.From + "\r\n")
b.WriteString("To: " + reply.To + "\r\n")
b.WriteString("Subject: " + encodeHeaderValue(vacationSubject(reply)) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString(fmt.Sprintf("Message-ID: <%s@%s>\r\n", uuid.NewString(), host))
// Only when there is a real message to point at. Referencing one that does
// not exist would have clients show the reply attached to nothing.
if original := messageIDRef(reply.OriginalMessageID); original != "" {
b.WriteString("In-Reply-To: " + original + "\r\n")
b.WriteString("References: " + buildReferences(reply.OriginalReferences, original) + "\r\n")
}
b.WriteString("Auto-Submitted: auto-replied\r\n")
// Tells Exchange and Outlook not to generate their own automatic answer.
b.WriteString("X-Auto-Response-Suppress: All\r\n")
// Marks it as unimportant to anything that sorts by precedence.
b.WriteString("Precedence: bulk\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(reply.Body))
return []byte(b.String())
}
// vacationSubject is what the sender sees in their message list.
//
// The configured text is kept as a prefix rather than as the whole subject:
// echoing the original is what makes the reply read as part of that
// conversation, and clients that thread on the subject line need it too.
func vacationSubject(reply pendingVacation) string {
configured := strings.TrimSpace(reply.Subject)
if configured == "" {
configured = "Out of office"
}
original := strings.TrimSpace(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
}
return configured + ": Re: " + original
}
// 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
// be read as further headers, so it is refused outright rather than trimmed:
// this value came from a remote sender.
func messageIDRef(raw string) string {
id := strings.TrimSpace(raw)
if id == "" || strings.ContainsAny(id, "\r\n") {
return ""
}
if !strings.HasPrefix(id, "<") {
id = "<" + id
}
if !strings.HasSuffix(id, ">") {
id = id + ">"
}
return id
}
// buildReferences puts the answered message at the end of the chain, which is
// the order RFC 5322 defines and the order a client reads it in.
func buildReferences(existing, original string) string {
chain := strings.Fields(strings.ReplaceAll(strings.TrimSpace(existing), "\r\n", " "))
out := make([]string, 0, len(chain)+1)
for _, ref := range chain {
if ref != original && strings.HasPrefix(ref, "<") {
out = append(out, ref)
}
}
out = append(out, original)
// Bounded: a long-running thread would otherwise grow this header without
// limit. Keeping the first and the most recent is what RFC 5322 section
// 3.6.4 suggests when a chain has to be trimmed.
const maxRefs = 20
if len(out) > maxRefs {
trimmed := append([]string{out[0]}, out[len(out)-(maxRefs-1):]...)
out = trimmed
}
return strings.Join(out, " ")
}