#21: serve the webmail mail API from the protocols service

The mail screens had no backend at all: they rendered a hardcoded array,
and the storage service that ADR-001 assigned this to is still a health
stub. The API lands here instead, because the folder, UID, MODSEQ, flag
and blob logic already exists and is tested in this service for IMAP.
Reimplementing it in TypeScript would mean two implementations of the
same semantics and two chances to disagree about what a UID means.

Reads use a projected read model rather than hydrated aggregates, which
is the CQRS exception PLAN.md section 2 carves out for webmail queries.
Every query is scoped to the mailbox resolved from the verified session,
never to an identifier taken from the request, so one account cannot read
another's mail by guessing a UID - covered by a test that tries exactly
that and expects 404.

Sending reuses ProcessOutboundEmailUseCase, so mail written in the browser
gets the same treatment as mail submitted over 587: send limits, DKIM
signing, and the durable queue. The From address comes from the session
and never from the request body; letting the body choose it is how a
webmail becomes a relay for spoofed mail.

MIME is parsed server-side because this service already depends on a MIME
library. A message that will not parse still returns its raw text - an
unreadable message beats an inbox that refuses to open one.

Marking a message read updates the flag and the folder counter in one
transaction, and only on a real transition, so re-opening a message cannot
drive unread_count negative.

Verified against the running stack: a message delivered over port 25
appears in the list with its decoded subject and sender, opens, marks
itself read, is found by search, and a message composed in the webmail
reaches outbound_jobs with the session's address as the envelope sender.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 10:03:25 -03:00
1 parent d33068a43c
commit 48fe3c68f3
11 files changed
+1293

No files matched your search

@@ -0,0 +1,112 @@
package usecase
import (
"bytes"
"io"
"strings"
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
)
// maxRenderedPartBytes bounds how much of one part is returned to the browser.
// A message can legitimately be tens of megabytes; the reader pane cannot use
// that, and holding it in memory per request is how a few open tabs turn into
// an out-of-memory kill.
const maxRenderedPartBytes = 2 << 20 // 2 MiB
// RenderedMessage is a message prepared for display: headers the reader shows
// and the two body flavours, already decoded from their transfer encoding.
type RenderedMessage struct {
UID uint32 `json:"uid"`
Subject string `json:"subject"`
From string `json:"from"`
To []string `json:"to"`
Cc []string `json:"cc"`
Date string `json:"date"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments []string `json:"attachments"`
}
// RenderMessage turns raw RFC 5322 bytes into what the reader pane needs.
//
// Parsing happens here rather than in the browser because this service already
// depends on a MIME library, and a second implementation in TypeScript would
// be a second set of edge cases to get wrong. A message that cannot be parsed
// still yields its raw text rather than an error - unreadable formatting beats
// an inbox that refuses to open a message.
func RenderMessage(raw []byte, uid uint32) RenderedMessage {
out := RenderedMessage{UID: uid, To: []string{}, Cc: []string{}, Attachments: []string{}}
entity, err := message.Read(bytes.NewReader(raw))
if err != nil {
out.Text = string(raw)
return out
}
header := mail.Header{Header: entity.Header}
out.Subject, _ = header.Subject()
if addrs, err := header.AddressList("From"); err == nil && len(addrs) > 0 {
out.From = addrs[0].String()
}
for _, field := range []struct {
name string
dst *[]string
}{{"To", &out.To}, {"Cc", &out.Cc}} {
if addrs, err := header.AddressList(field.name); err == nil {
for _, a := range addrs {
*field.dst = append(*field.dst, a.String())
}
}
}
// A missing Date header parses without error into the zero time, which
// would render as the year 0001 in the reader. Left empty, the UI falls
// back to the received timestamp it already has.
if date, err := header.Date(); err == nil && !date.IsZero() {
out.Date = date.UTC().Format("2006-01-02T15:04:05Z")
}
collectParts(entity, &out)
return out
}
// collectParts walks the MIME tree, keeping the first text and HTML bodies and
// naming everything else as an attachment.
func collectParts(entity *message.Entity, out *RenderedMessage) {
if multipart := entity.MultipartReader(); multipart != nil {
for {
part, err := multipart.NextPart()
if err != nil {
return
}
collectParts(part, out)
}
}
mediaType, _, err := entity.Header.ContentType()
if err != nil {
mediaType = "text/plain"
}
if disposition, params, err := entity.Header.ContentDisposition(); err == nil && disposition == "attachment" {
name := params["filename"]
if name == "" {
name = "attachment"
}
out.Attachments = append(out.Attachments, name)
return
}
body, err := io.ReadAll(io.LimitReader(entity.Body, maxRenderedPartBytes))
if err != nil {
return
}
switch {
case strings.EqualFold(mediaType, "text/plain") && out.Text == "":
out.Text = string(body)
case strings.EqualFold(mediaType, "text/html") && out.HTML == "":
out.HTML = string(body)
}
}
@@ -0,0 +1,59 @@
package usecase
import "testing"
func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) {
raw := "From: Alice <alice@remote.test>\r\n" +
"To: bob@example.test\r\n" +
"Subject: =?UTF-8?Q?Ol=C3=A1?=\r\n" +
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\nCorpo.\r\n"
got := RenderMessage([]byte(raw), 7)
if got.UID != 7 || got.Subject != "Olá" {
t.Errorf("UID/Subject = %d/%q", got.UID, got.Subject)
}
if got.Text != "Corpo.\r\n" {
t.Errorf("Text = %q", got.Text)
}
if got.Date == "" {
t.Error("Date was not parsed")
}
}
// A message with no Date header parses into the zero time without an error;
// rendering that verbatim showed the year 0001 in the reader.
func TestRenderMessage_LeavesDateEmptyWhenHeaderAbsent(t *testing.T) {
got := RenderMessage([]byte("From: a@b.test\r\nSubject: s\r\n\r\nbody\r\n"), 1)
if got.Date != "" {
t.Errorf("Date = %q, want empty so the UI falls back to received_at", got.Date)
}
}
func TestRenderMessage_PrefersPlainTextAndListsAttachments(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nplain part\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p>html part</p>\r\n" +
"--B\r\nContent-Type: application/pdf\r\n" +
"Content-Disposition: attachment; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
got := RenderMessage([]byte(raw), 1)
if got.Text != "plain part" {
t.Errorf("Text = %q", got.Text)
}
if got.HTML != "<p>html part</p>" {
t.Errorf("HTML = %q", got.HTML)
}
if len(got.Attachments) != 1 || got.Attachments[0] != "report.pdf" {
t.Errorf("Attachments = %v", got.Attachments)
}
}
// Garbage must still open: an unreadable message beats an inbox that errors.
func TestRenderMessage_FallsBackToRawOnParseFailure(t *testing.T) {
got := RenderMessage([]byte("this is not a message"), 3)
if got.Text == "" {
t.Error("a malformed message produced no body at all")
}
}
@@ -0,0 +1,210 @@
package usecase
import (
"bytes"
"context"
"errors"
"fmt"
"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
}
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}
}
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)
}
// ComposeInput is one message written in the webmail.
type ComposeInput struct {
From string
To []string
Cc []string
Subject string
Body 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 {
recipients := normaliseRecipients(append(append([]string{}, input.To...), input.Cc...))
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)
return uc.submission.Submit(ctx, ProcessOutboundEmailInput{
MailboxID: account.ID,
SenderAddr: account.EmailAddress,
DomainName: account.DomainName,
Recipients: recipients,
Body: bytes.NewReader(payload),
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
})
}
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).
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 {
b.WriteString("To: " + strings.Join(recipients, ", ") + "\r\n")
}
if len(input.Cc) > 0 {
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\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")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(input.Body, "\n", "\r\n"))
if !strings.HasSuffix(input.Body, "\n") {
b.WriteString("\r\n")
}
return []byte(b.String())
}
// 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)
}