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:
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
|
||||
}
|
||||
Reference in new issue
Block a user