From e4491479efa146cf544ae5ab22970c68ca4ea0c7 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 22 Aug 2026 15:15:58 -0300 Subject: [PATCH] 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 --- migrations/0010_vacation_reply_log.down.sql | 1 + migrations/0010_vacation_reply_log.up.sql | 17 +++ .../cmd/lambdamail-protocols/main.go | 4 + .../application/port/report_repository.go | 13 ++ .../usecase/process_inbound_email.go | 8 +- .../application/usecase/vacation_reply.go | 69 ++++++++- .../usecase/vacation_reply_test.go | 62 ++++++++ .../usecase/vacation_suppression.go | 88 +++++++++++ .../usecase/vacation_suppression_test.go | 139 ++++++++++++++++++ .../infrastructure/postgres/vacation_log.go | 56 +++++++ 10 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 migrations/0010_vacation_reply_log.down.sql create mode 100644 migrations/0010_vacation_reply_log.up.sql create mode 100644 services/protocols/internal/application/usecase/vacation_suppression.go create mode 100644 services/protocols/internal/application/usecase/vacation_suppression_test.go create mode 100644 services/protocols/internal/infrastructure/postgres/vacation_log.go diff --git a/migrations/0010_vacation_reply_log.down.sql b/migrations/0010_vacation_reply_log.down.sql new file mode 100644 index 0000000..4094bac --- /dev/null +++ b/migrations/0010_vacation_reply_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS vacation_replies; diff --git a/migrations/0010_vacation_reply_log.up.sql b/migrations/0010_vacation_reply_log.up.sql new file mode 100644 index 0000000..2e14c5d --- /dev/null +++ b/migrations/0010_vacation_reply_log.up.sql @@ -0,0 +1,17 @@ +-- 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 produces its own copy of the same +-- notice - a conversation of five mails produced five - and someone replying +-- to the automatic message itself was answered again, which is how a person +-- and an autoresponder end up talking past each other. +CREATE TABLE vacation_replies ( + mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + -- Lowercased, so one person is one row however they spell their address. + recipient VARCHAR(320) NOT NULL, + last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (mailbox_id, recipient) +); + +-- The sweep that discards rows older than any suppression period reads this. +CREATE INDEX idx_vacation_replies_sent ON vacation_replies (last_sent_at); diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index fa08180..f6c3e3b 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -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) diff --git a/services/protocols/internal/application/port/report_repository.go b/services/protocols/internal/application/port/report_repository.go index 33e0e09..70e5413 100644 --- a/services/protocols/internal/application/port/report_repository.go +++ b/services/protocols/internal/application/port/report_repository.go @@ -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 +} diff --git a/services/protocols/internal/application/usecase/process_inbound_email.go b/services/protocols/internal/application/usecase/process_inbound_email.go index 10e34f0..325c695 100644 --- a/services/protocols/internal/application/usecase/process_inbound_email.go +++ b/services/protocols/internal/application/usecase/process_inbound_email.go @@ -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, }) } } diff --git a/services/protocols/internal/application/usecase/vacation_reply.go b/services/protocols/internal/application/usecase/vacation_reply.go index 0d07eb7..c194fa9 100644 --- a/services/protocols/internal/application/usecase/vacation_reply.go +++ b/services/protocols/internal/application/usecase/vacation_reply.go @@ -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 diff --git a/services/protocols/internal/application/usecase/vacation_reply_test.go b/services/protocols/internal/application/usecase/vacation_reply_test.go index 954c056..42c0996 100644 --- a/services/protocols/internal/application/usecase/vacation_reply_test.go +++ b/services/protocols/internal/application/usecase/vacation_reply_test.go @@ -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("", "") + 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("", "") + // "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("", "") + 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("", "") + 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) + } +} diff --git a/services/protocols/internal/application/usecase/vacation_suppression.go b/services/protocols/internal/application/usecase/vacation_suppression.go new file mode 100644 index 0000000..9c62f91 --- /dev/null +++ b/services/protocols/internal/application/usecase/vacation_suppression.go @@ -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) +} diff --git a/services/protocols/internal/application/usecase/vacation_suppression_test.go b/services/protocols/internal/application/usecase/vacation_suppression_test.go new file mode 100644 index 0000000..de0dbdb --- /dev/null +++ b/services/protocols/internal/application/usecase/vacation_suppression_test.go @@ -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") + } +} diff --git a/services/protocols/internal/infrastructure/postgres/vacation_log.go b/services/protocols/internal/infrastructure/postgres/vacation_log.go new file mode 100644 index 0000000..c03119e --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/vacation_log.go @@ -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 +}