Files
LambdaMail/services/protocols/internal/application/usecase/webmail_mail.go
T
Lucas Rayan 183191d18d 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.
2026-10-16 13:30:59 -03:00

523 lines
18 KiB
Go

package usecase
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"mime"
"net/mail"
"strings"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// ErrNoSuchMailbox is returned when the session names an address this server
// does not serve, which happens after an account is deleted while a token is
// still valid.
var ErrNoSuchMailbox = errors.New("webmail: no active mailbox for this session")
// WebmailUseCase serves the webmail's mail screens.
//
// Every operation starts from the address in the verified session and resolves
// it to a mailbox ID here, so no handler can be tricked into acting on an
// identifier that came from the request.
type WebmailUseCase struct {
repo port.WebmailRepository
blobs port.BlobReader
submission *ProcessOutboundEmailUseCase
auth port.AuthRepository
localHost string
// 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
}
func NewWebmailUseCase(
repo port.WebmailRepository,
blobs port.BlobReader,
submission *ProcessOutboundEmailUseCase,
auth port.AuthRepository,
localHost string,
) *WebmailUseCase {
if localHost == "" {
localHost = "localhost"
}
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
}
// WithLocalFiling enables draft storage.
func (uc *WebmailUseCase) WithLocalFiling(store port.BlobStorage, messages port.InboundMessageRepository) *WebmailUseCase {
uc.blobStore = store
uc.messages = messages
return uc
}
// MailboxIDFor resolves a session's address to its mailbox, which the event
// stream needs so it can filter pushes per account.
func (uc *WebmailUseCase) MailboxIDFor(ctx context.Context, address string) (string, error) {
return uc.mailboxID(ctx, address)
}
func (uc *WebmailUseCase) mailboxID(ctx context.Context, address string) (string, error) {
id, err := uc.repo.FindMailboxIDByAddress(ctx, address)
if err != nil || id == "" {
return "", ErrNoSuchMailbox
}
return id, nil
}
func (uc *WebmailUseCase) ListFolders(ctx context.Context, address string) ([]port.WebmailFolder, error) {
id, err := uc.mailboxID(ctx, address)
if err != nil {
return nil, err
}
return uc.repo.ListFolders(ctx, id)
}
func (uc *WebmailUseCase) ListMessages(
ctx context.Context, address, folder, search string, limit, offset int,
) ([]port.WebmailMessage, error) {
id, err := uc.mailboxID(ctx, address)
if err != nil {
return nil, err
}
return uc.repo.ListMessages(ctx, id, folder, search, limit, offset)
}
// GetMessage returns the raw RFC 5322 bytes and marks the message read.
func (uc *WebmailUseCase) GetMessage(ctx context.Context, address, folder string, uid uint32) ([]byte, error) {
id, err := uc.mailboxID(ctx, address)
if err != nil {
return nil, err
}
blobID, err := uc.repo.GetMessageBlob(ctx, id, folder, uid)
if err != nil {
return nil, err
}
raw, err := uc.blobs.ReadByID(ctx, blobID)
if err != nil {
return nil, err
}
// Opening a message marks it read, but a failure to record that must not
// stop it being displayed.
_ = uc.repo.MarkSeen(ctx, id, folder, uid, true)
return raw, nil
}
func (uc *WebmailUseCase) SetSeen(ctx context.Context, address, folder string, uid uint32, seen bool) error {
id, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
return uc.repo.MarkSeen(ctx, id, folder, uid, seen)
}
// SetFlagged marks a message important, or takes the mark off.
func (uc *WebmailUseCase) SetFlagged(ctx context.Context, address, folder string, uid uint32, flagged bool) error {
id, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
return uc.repo.MarkFlagged(ctx, id, folder, uid, flagged)
}
// ComposeInput is one message written in the webmail.
type ComposeInput struct {
From string
To []string
Cc []string
// Bcc reaches the envelope but never the headers - that is the whole
// point of it. It used not to exist here at all, so a blind copy typed
// into the webmail was silently dropped and nobody received it.
Bcc []string
Subject string
// Text is the plain-text body. HTML, when present, is what the composer
// produced; both are sent together as multipart/alternative so a reader
// that cannot or will not render HTML still has something to show.
Text string
HTML string
// DraftUID is the autosaved draft this message grew out of, or 0 when it
// was composed without one ever being stored.
//
// Sending used to ignore it entirely, so a message that had been autosaved
// went out and its draft stayed in Drafts: a duplicate of mail already on
// its way, which no screen in the webmail could then delete.
DraftUID uint32
// Important sends the message with high priority, in the two spellings
// mail clients read: Importance (RFC 4021) and the older X-Priority.
Important bool
// InReplyTo is the Message-ID of the message being answered. It is what
// lets the recipient's client thread the reply under the original.
InReplyTo string
}
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
// reaches the queue.
var ErrNoRecipients = errors.New("webmail: at least one recipient is required")
// Send builds an RFC 5322 message and hands it to the same submission use
// case the SMTP ports use, so webmail mail gets the identical treatment:
// send limits, DKIM signing and the durable queue.
func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
// Bcc joins the envelope here and is left out of buildMimeMessage, which
// is what makes it blind.
recipients := normaliseRecipients(
append(append(append([]string{}, input.To...), input.Cc...), input.Bcc...))
if len(recipients) == 0 {
return ErrNoRecipients
}
account, err := uc.auth.FindByAddress(ctx, input.From)
if err != nil {
return err
}
if account == nil {
return ErrNoSuchMailbox
}
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,
DomainName: account.DomainName,
Recipients: recipients,
Body: bytes.NewReader(payload),
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
FileSentCopy: true,
}); err != nil {
return 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
// second copy to be sent.
if input.DraftUID != 0 {
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", input.DraftUID); err != nil {
log.Printf("webmail: could not discard the sent draft %d: %v", input.DraftUID, err)
}
}
}
return nil
}
// ErrFolderNotATarget refuses a move into a folder that describes how a
// message came to exist rather than where its reader filed it.
var ErrFolderNotATarget = errors.New("webmail: messages cannot be moved into this folder")
// ErrNoTargetFolder rejects a move with no destination named.
var ErrNoTargetFolder = errors.New("webmail: a destination folder is required")
// notAMoveTarget are the folders a message may never be moved into.
//
// Sent and Drafts are not places a reader files things; they record what a
// message is. Moving arbitrary mail into Sent would make it claim the message
// was sent from here, and a message in Drafts that cannot be edited would sit
// among the unfinished ones forever.
var notAMoveTarget = map[string]bool{"sent": true, "drafts": true}
// Move files one message into another folder.
//
// The webmail could delete a message and it could read one, but it had no way
// to put it anywhere: archiving something out of the inbox was impossible.
func (uc *WebmailUseCase) Move(ctx context.Context, address, folder string, uid uint32, target string) error {
target = strings.TrimSpace(target)
if target == "" {
return ErrNoTargetFolder
}
if notAMoveTarget[strings.ToLower(target)] {
return ErrFolderNotATarget
}
// Moving a message where it already is is what a second click on the same
// target produces; it is a no-op rather than an error.
if strings.EqualFold(target, folder) {
return nil
}
mailboxID, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
_, err = uc.repo.MoveToFolder(ctx, mailboxID, folder, uid, target)
return err
}
// Delete removes one message the way a mail client does: from any ordinary
// folder it goes to Trash, and from Trash it is really expunged.
//
// The webmail had no delete at all. Expunge existed but was reachable only
// when an autosave superseded a draft, so anything the user wanted rid of -
// most visibly the draft left behind by a sent message - stayed put.
func (uc *WebmailUseCase) Delete(ctx context.Context, address, folder string, uid uint32) error {
mailboxID, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
_, err = uc.repo.MoveToTrash(ctx, mailboxID, folder, uid)
switch {
case err == nil:
return nil
case errors.Is(err, port.ErrAlreadyInTrash), errors.Is(err, port.ErrNoTrashFolder):
// Already in Trash, or nowhere to move it to. Either way the second
// press is the one that removes it for good.
return uc.repo.Expunge(ctx, mailboxID, folder, uid)
default:
return err
}
}
// SaveDraft files the message being composed into the Drafts folder.
//
// Nothing did this before: the composer displayed "draft saved automatically"
// from a setTimeout, with no request behind it, and closing the tab lost the
// message. Drafts are whole RFC 5322 messages like any other, so they are
// readable over IMAP too.
// replaceUID is the draft this one supersedes, or 0 for the first save of a
// message. Without it every autosave would append another copy and a few
// minutes of typing would leave the Drafts folder full of the same half-written
// message.
func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput, replaceUID uint32) (uint32, error) {
account, err := uc.auth.FindByAddress(ctx, input.From)
if err != nil {
return 0, err
}
if account == nil {
return 0, ErrNoSuchMailbox
}
if uc.blobStore == nil || uc.messages == nil {
return 0, ErrDraftsUnavailable
}
// A draft has no envelope, so recipients may legitimately be empty here -
// unlike Send, this must not reject a half-written message.
recipients := normaliseRecipients(
append(append(append([]string{}, input.To...), input.Cc...), input.Bcc...))
payload := buildMimeMessage(input, recipients, uc.localHost)
blob, err := uc.blobStore.Store(ctx, bytes.NewReader(payload))
if err != nil {
return 0, err
}
headers := ExtractMessageHeaders(payload)
uids, err := uc.messages.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: account.ID,
Blob: blob,
SenderAddress: account.EmailAddress,
RecipientAddress: account.EmailAddress,
TargetFolderName: "Drafts",
Subject: headers.Subject,
Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName,
MessageIDHeader: headers.MessageID,
HasAttachments: headers.HasAttachments,
AlreadySeen: true,
}})
if err != nil {
return 0, err
}
// Only after the replacement is committed, so a failure here leaves the
// older draft in place rather than losing both.
if replaceUID != 0 {
mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress)
if idErr == nil {
if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", replaceUID); err != nil {
log.Printf("webmail: could not remove the superseded draft %d: %v", replaceUID, err)
}
}
}
if len(uids) == 0 {
return 0, nil
}
return uint32(uids[0]), nil
}
// ErrDraftsUnavailable reports that this deployment cannot store drafts,
// rather than accepting one and dropping it.
var ErrDraftsUnavailable = errors.New("webmail: draft storage is not configured")
func normaliseRecipients(raw []string) []string {
seen := map[string]bool{}
out := []string{}
for _, entry := range raw {
for _, part := range strings.FieldsFunc(entry, func(r rune) bool { return r == ',' || r == ';' }) {
addr, err := mail.ParseAddress(strings.TrimSpace(part))
if err != nil {
continue
}
if !seen[addr.Address] {
seen[addr.Address] = true
out = append(out, addr.Address)
}
}
}
return out
}
// buildMimeMessage assembles the headers a receiver expects. Date and
// Message-ID are set here because a message without them is scored as spam by
// most receivers (PLAN.md section 5).
//
// Bcc recipients are deliberately absent from the headers: they are carried in
// the envelope only, which is what "blind" means.
//
// A message with HTML goes out as multipart/alternative with a plain-text part
// first, in ascending order of preference per RFC 2046 section 5.1.4. Sending
// the composer's HTML under a text/plain header - which is what this did while
// the body was being dropped anyway - shows the recipient raw markup.
func buildMimeMessage(input ComposeInput, recipients []string, localHost string) []byte {
var b strings.Builder
b.WriteString("From: " + input.From + "\r\n")
if len(input.To) > 0 {
b.WriteString("To: " + strings.Join(input.To, ", ") + "\r\n")
} else {
to := normaliseRecipients(input.To)
if len(to) == 0 {
to = recipients
}
b.WriteString("To: " + strings.Join(to, ", ") + "\r\n")
}
if len(input.Cc) > 0 {
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n")
}
if id := cleanMessageID(input.InReplyTo); id != "" {
b.WriteString("In-Reply-To: " + id + "\r\n")
b.WriteString("References: " + id + "\r\n")
}
if input.Important {
b.WriteString("Importance: high\r\nX-Priority: 1\r\n")
}
b.WriteString("Subject: " + encodeHeaderValue(input.Subject) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString(fmt.Sprintf("Message-ID: <%s@%s>\r\n", uuid.NewString(), localHost))
b.WriteString("MIME-Version: 1.0\r\n")
text := input.Text
if text == "" && input.HTML != "" {
text = htmlToPlainText(input.HTML)
}
if input.HTML == "" {
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(text))
return []byte(b.String())
}
boundary := "=_alt_" + uuid.NewString()
b.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
b.WriteString("--" + boundary + "\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(text))
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/html; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(input.HTML))
b.WriteString("--" + boundary + "--\r\n")
return []byte(b.String())
}
// normaliseEOL converts to CRLF and guarantees the trailing break a MIME part
// boundary must be preceded by.
func normaliseEOL(s string) string {
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
if !strings.HasSuffix(s, "\r\n") {
s += "\r\n"
}
return s
}
// htmlToPlainText derives a readable fallback from composed HTML.
//
// Deliberately crude - block elements become line breaks, tags are dropped,
// the handful of entities a contenteditable actually emits are decoded. It
// exists so the text/plain part is not empty, which is what makes a
// multipart/alternative message look like spam to a filter.
func htmlToPlainText(html string) string {
replacer := strings.NewReplacer(
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
"</p>", "\n\n", "</div>", "\n", "</li>", "\n", "</tr>", "\n",
"<li>", "- ",
)
s := replacer.Replace(html)
var out strings.Builder
depth := 0
for _, r := range s {
switch {
case r == '<':
depth++
case r == '>' && depth > 0:
depth--
case depth == 0:
out.WriteRune(r)
}
}
text := strings.NewReplacer(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", "\"", "&#39;", "'",
).Replace(out.String())
// Collapse the runs of blank lines the block replacements leave behind.
for strings.Contains(text, "\n\n\n") {
text = strings.ReplaceAll(text, "\n\n\n", "\n\n")
}
return strings.TrimSpace(text)
}
// encodeHeaderValue RFC 2047-encodes a subject that is not plain ASCII, so an
// accented subject does not arrive as mojibake.
func encodeHeaderValue(value string) string {
for i := 0; i < len(value); i++ {
if value[i] > 127 {
return mimeEncode(value)
}
}
// A bare CR or LF here would let the caller inject extra headers.
return strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
}
// mimeEncode wraps a non-ASCII header value as an RFC 2047 encoded word.
func mimeEncode(value string) string {
clean := strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
return mime.BEncoding.Encode("utf-8", clean)
}
// cleanMessageID returns id only when it is a single, well-formed <...>
// Message-ID. The value comes from the request and is written into a header,
// so anything with a line break - which would start a new header - or
// anything that is not an angle-bracketed token is dropped rather than
// repaired.
func cleanMessageID(id string) string {
id = strings.TrimSpace(id)
if len(id) < 3 || len(id) > 998 || id[0] != '<' || id[len(id)-1] != '>' {
return ""
}
if strings.ContainsAny(id, "\r\n\x00 \t,;") || strings.Count(id, "<") != 1 || strings.Count(id, ">") != 1 {
return ""
}
return id
}