refactor(mail): one send path for Sent, a fixed lock order, and concurrency tests

- the webmail's Send no longer files its own Sent copy: it asks Submit to
  (FileSentCopy), the same path SMTP clients use, so the rule exists once
- every operation now locks the folders first (lowest id first) and the message
  after. Opposite moves, and a move racing a flag change, deadlocked (SQLSTATE
  40P01) because they took those locks in different orders; the new test
  reproduces it (10-19 s of retries, errors) and passes in ~1 s with the order
- TestOneMailboxEverywhere drives SMTP, IMAP and the webmail on one mailbox,
  mixing them, and checks after each step that every view agrees with the others
  and with the rows. It fails on the previous code and passes now.
This commit is contained in:
Lucas Rayan committed 2026-10-16 13:30:59 -03:00
1 parent 67bbe7a50a
commit 183191d18d
6 files changed
+612 -48

No files matched your search

@@ -137,3 +137,51 @@ func TestSubmit_WorksWithoutASentCopyRepository(t *testing.T) {
t.Fatalf("Submit without a Sent repository: %v", err)
}
}
// One rule for every path: a message sent from the webmail and the same message
// sent by an SMTP client leave the same thing behind in Sent. They used to differ
// completely - the webmail filed a copy, SMTP filed nothing - because the rule
// lived in the webmail alone.
func TestSentCopyIsTheSameWhicheverPathSent(t *testing.T) {
mailboxID := uuid.New()
messages := &recordingMessageRepository{}
submission := NewProcessOutboundEmailUseCase(nil, &countingOutboundRepo{}, &capturingBlobStorage{}, nil)
submission.SetSentCopies(messages)
account := &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test", DomainName: "example.test", MaxRecipientsPerHour: 100}
web := &WebmailUseCase{
repo: &stubWebmailRepo{mailboxID: mailboxID.String()},
auth: &stubAuthRepo{account: account},
submission: submission,
localHost: "mail.example.test",
}
if err := web.Send(context.Background(), ComposeInput{From: "me@example.test", To: []string{"you@remote.test"}, Subject: "Aviso", Text: "hi"}); err != nil {
t.Fatalf("webmail send: %v", err)
}
smtp := ProcessOutboundEmailInput{
MailboxID: mailboxID, SenderAddr: "me@example.test", DomainName: "example.test",
Recipients: []string{"you@remote.test"}, FileSentCopy: true,
Body: bytes.NewBufferString("From: me@example.test\r\nTo: you@remote.test\r\nSubject: Aviso\r\n\r\nhi"),
}
if err := submission.Submit(context.Background(), smtp); err != nil {
t.Fatalf("smtp submit: %v", err)
}
if len(messages.persisted) != 2 {
t.Fatalf("want one copy per path (2), got %d: the webmail must not file its own copy as well", len(messages.persisted))
}
fromWeb, fromSMTP := messages.persisted[0], messages.persisted[1]
for _, got := range []port.PersistInboundMessageInput{fromWeb, fromSMTP} {
if got.TargetFolderName != "Sent" || !got.AlreadySeen || got.MailboxID != mailboxID {
t.Errorf("a Sent copy is not what the rule says: %+v", got)
}
if got.SenderAddress != "me@example.test" || got.RecipientAddress != "me@example.test" {
t.Errorf("a Sent copy belongs to its sender: %+v", got)
}
if got.Subject != "Aviso" {
t.Errorf("subject = %q, want Aviso", got.Subject)
}
}
}
@@ -197,6 +197,8 @@ func newSendableUseCaseWith(
}
submission := NewProcessOutboundEmailUseCase(
nil, &countingOutboundRepo{}, &capturingBlobStorage{}, &recordingSigner{})
// The Sent copy is filed by the submission, as it is for SMTP clients
submission.SetSentCopies(messages)
return (&WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: account},
@@ -32,10 +32,9 @@ type WebmailUseCase struct {
submission *ProcessOutboundEmailUseCase
auth port.AuthRepository
localHost string
// blobStore and messages file the sender's own copy into Sent, and hold
// drafts. Both are optional: when either is nil the copy is skipped rather
// than the send failing, because a message that has already been queued
// for delivery must not be reported as an error.
// blobStore and messages hold drafts. Both are optional: when either is nil
// saving a draft reports ErrDraftsUnavailable. (The copy in Sent is filed by
// the submission use case, not here.)
blobStore port.BlobStorage
messages port.InboundMessageRepository
}
@@ -53,7 +52,7 @@ func NewWebmailUseCase(
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
}
// WithLocalFiling enables the Sent copy and draft storage.
// WithLocalFiling enables draft storage.
func (uc *WebmailUseCase) WithLocalFiling(store port.BlobStorage, messages port.InboundMessageRepository) *WebmailUseCase {
uc.blobStore = store
uc.messages = messages
@@ -188,6 +187,10 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
payload := buildMimeMessage(input, recipients, uc.localHost)
// The copy in Sent is filed by Submit, the same way it is for a client that
// sends over SMTP: one rule, one place. It used to be filed here, after the
// submission, and not at all for SMTP, so an account that sent by SMTP had an
// empty Sent folder.
if err := uc.submission.Submit(ctx, ProcessOutboundEmailInput{
MailboxID: account.ID,
SenderAddr: account.EmailAddress,
@@ -195,19 +198,11 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
Recipients: recipients,
Body: bytes.NewReader(payload),
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
FileSentCopy: true,
}); err != nil {
return err
}
// Only after the message is durably queued. Filing it first would leave a
// copy in Sent for a message that was then rejected by the send limit.
//
// A failure here is logged and swallowed: the mail is already on its way,
// and telling the composer the send failed would invite a duplicate.
if err := uc.fileLocalCopy(ctx, account.ID, account.EmailAddress, "Sent", payload); err != nil {
log.Printf("webmail: could not file the Sent copy for %s: %v", account.EmailAddress, err)
}
// The draft this message grew out of has been superseded by the real
// thing. Swallowed like the Sent copy above and for the same reason: the
// mail is already queued, and failing the request now would only invite a
@@ -288,36 +283,6 @@ func (uc *WebmailUseCase) Delete(ctx context.Context, address, folder string, ui
}
}
// fileLocalCopy stores a message the user themselves composed into one of
// their own folders. It reuses the inbound persistence path so the copy gets a
// real IMAP UID and shows up over IMAP as well as in the webmail.
func (uc *WebmailUseCase) fileLocalCopy(
ctx context.Context, mailboxID uuid.UUID, address, folder string, payload []byte,
) error {
if uc.blobStore == nil || uc.messages == nil {
return nil
}
blob, err := uc.blobStore.Store(ctx, bytes.NewReader(payload))
if err != nil {
return err
}
headers := ExtractMessageHeaders(payload)
_, err = uc.messages.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: mailboxID,
Blob: blob,
SenderAddress: address,
RecipientAddress: address,
TargetFolderName: folder,
Subject: headers.Subject,
Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName,
MessageIDHeader: headers.MessageID,
HasAttachments: headers.HasAttachments,
AlreadySeen: true,
}})
return err
}
// SaveDraft files the message being composed into the Drafts folder.
//
// Nothing did this before: the composer displayed "draft saved automatically"