fix(smtp): keep a copy of every submitted message in the sender's Sent folder
A message sent over SMTP submission was delivered but left nothing behind: the account's Sent folder stayed empty. Webmail files its own copy after sending, and a mail client cannot do it either, since IMAP APPEND is not implemented, so nothing ever filled Sent for SMTP clients. - ProcessOutboundEmailInput.FileSentCopy asks Submit to file the signed message in the sender's Sent folder, as read (not unread), sharing the blob the delivery jobs already reference; one copy per submission, not per recipient - the SMTP submission session sets it; the webmail leaves it off because it files its own copy without the Bcc line, and asking twice would duplicate it - filed only after the message is durably queued, so a submission refused by the send limit leaves nothing behind; a failure to file is logged, never returned, because the mail is already on its way and the client would send it again Tests: unit cases (asked/not asked, shared blob, failure swallowed, limit refusal, no repository) and an end-to-end test on the real submission server that fails with "Sent holds 0 messages" without the fix. Full go test ./... (25 packages) with TEST_DATABASE_URL, go vet and lang-lint are clean.
This commit is contained in:
1 parent
110a240cd9
commit
9c8b9c00fc
5 files changed
+323
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,13 @@ type ProcessOutboundEmailInput struct {
|
||||
// MaxRecipientsPerHour is the sender's policy, carried from the
|
||||
// authenticated session.
|
||||
MaxRecipientsPerHour int
|
||||
// FileSentCopy asks for a copy of the message in the sender's Sent folder.
|
||||
// Set for SMTP submission: a mail client cannot file it itself, since IMAP
|
||||
// APPEND is not implemented, so without this nothing a client sent ever
|
||||
// showed up in Sent. The webmail leaves it off because it files its own copy
|
||||
// (one that omits the Bcc line), and asking twice would show every sent
|
||||
// message twice.
|
||||
FileSentCopy bool
|
||||
}
|
||||
|
||||
// ProcessOutboundEmailUseCase implements the submission half of PLAN.md
|
||||
@@ -57,6 +65,14 @@ type ProcessOutboundEmailUseCase struct {
|
||||
outbound port.OutboundRepository
|
||||
blobs port.BlobStorage
|
||||
signer port.DkimSigner
|
||||
// sentCopies files the sender's copy; nil leaves FileSentCopy a no-op
|
||||
sentCopies port.InboundMessageRepository
|
||||
}
|
||||
|
||||
// SetSentCopies gives the use case somewhere to file the sender's copy of what
|
||||
// was submitted (see ProcessOutboundEmailInput.FileSentCopy).
|
||||
func (uc *ProcessOutboundEmailUseCase) SetSentCopies(messages port.InboundMessageRepository) {
|
||||
uc.sentCopies = messages
|
||||
}
|
||||
|
||||
func NewProcessOutboundEmailUseCase(
|
||||
@@ -143,9 +159,40 @@ func (uc *ProcessOutboundEmailUseCase) Submit(ctx context.Context, input Process
|
||||
}
|
||||
}
|
||||
|
||||
// Only once the message is durably queued, so a submission refused by the
|
||||
// send limit or a failed enqueue never leaves a "sent" copy behind. A
|
||||
// failure here is logged and swallowed: the mail is on its way, and telling
|
||||
// the client the submission failed would only make it send it again.
|
||||
if input.FileSentCopy && uc.sentCopies != nil {
|
||||
if err := uc.fileSentCopy(ctx, input, blob, payload); err != nil {
|
||||
slog.Warn("could not file the Sent copy", "mailbox", input.SenderAddr, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSentCopy records the sender's copy in their Sent folder, referencing the
|
||||
// blob the delivery jobs already share, so it costs a row and no extra bytes.
|
||||
func (uc *ProcessOutboundEmailUseCase) fileSentCopy(ctx context.Context, input ProcessOutboundEmailInput, blob port.BlobRef, payload []byte) error {
|
||||
headers := ExtractMessageHeaders(payload)
|
||||
_, err := uc.sentCopies.PersistAll(ctx, []port.PersistInboundMessageInput{{
|
||||
MailboxID: input.MailboxID,
|
||||
Blob: blob,
|
||||
SenderAddress: input.SenderAddr,
|
||||
RecipientAddress: input.SenderAddr,
|
||||
TargetFolderName: "Sent",
|
||||
Subject: headers.Subject,
|
||||
Snippet: headers.Snippet,
|
||||
FromDisplayName: headers.FromDisplayName,
|
||||
MessageIDHeader: headers.MessageID,
|
||||
HasAttachments: headers.HasAttachments,
|
||||
// The sender's own copy is not unread mail
|
||||
AlreadySeen: true,
|
||||
}})
|
||||
return err
|
||||
}
|
||||
|
||||
// checkSendLimit refuses the whole submission when accepting it would take the
|
||||
// mailbox past its hourly recipient budget. Refusing up front is what contains
|
||||
// a compromised account; accepting and dropping later would not.
|
||||
|
||||
Reference in new issue
Block a user