#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

@@ -14,6 +14,7 @@ type config struct {
MailDomain string
SpoolDir string
JwtSecret string
MasterKey string
TLSMode string
@@ -61,6 +62,9 @@ func loadConfig() config {
SpoolDir: envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool"),
MasterKey: os.Getenv("LAMBDAMAIL_MASTER_KEY"),
// Shared with the auth service, which mints the webmail sessions this
// service verifies on /api/v1/mail/*.
JwtSecret: os.Getenv("JWT_SECRET"),
TLSMode: strings.ToLower(envOrDefault("TLS_MODE", "traefik")),
TraefikAcmeDir: os.Getenv("TRAEFIK_ACME_DIR"),
@@ -162,7 +162,18 @@ func run(cfg config) {
}
// ------------------------------------------------------------- HTTP API
// The webmail reads and sends through this service because the folder,
// UID, flag and blob logic already lives here; duplicating it in another
// service would mean two implementations of the same IMAP semantics.
webmailUC := usecase.NewWebmailUseCase(
postgres.NewWebmailRepository(pool), blobReader, submissionUC, authRepo, cfg.PrimaryMailHost,
)
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
if cfg.JwtSecret == "" {
log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled")
}
router.SetMailAPI(webmailUC, cfg.JwtSecret)
router.SetDegradedCheck(certDegraded)
applyMtaStsMode(router, cfg)
@@ -0,0 +1,64 @@
package port
import (
"context"
"time"
"github.com/google/uuid"
)
// WebmailFolder is a folder as the message list shows it.
type WebmailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
SpecialUse string `json:"special_use"`
UnreadCount int `json:"unread_count"`
TotalCount int `json:"total_count"`
}
// WebmailMessage is one row of the message list.
//
// This is a read model projected straight from SQL rather than a hydrated
// aggregate, which is the CQRS exception PLAN.md section 2 carves out for
// webmail reads.
type WebmailMessage struct {
UID uint32 `json:"uid"`
Subject string `json:"subject"`
SenderAddress string `json:"sender_address"`
FromName string `json:"from_display_name"`
Snippet string `json:"snippet"`
SizeBytes int64 `json:"size_bytes"`
ReceivedAt time.Time `json:"received_at"`
HasAttachments bool `json:"has_attachments"`
Seen bool `json:"seen"`
Flagged bool `json:"flagged"`
Answered bool `json:"answered"`
SpamVerdict string `json:"spam_verdict"`
DmarcResult string `json:"dmarc_result"`
}
// WebmailMessageBody carries what is needed to render one message.
type WebmailMessageBody struct {
UID uint32 `json:"uid"`
BlobID uuid.UUID `json:"-"`
Raw []byte `json:"-"`
}
// WebmailRepository serves the webmail's read side.
//
// Every method takes the authenticated mailbox ID and scopes its query to it.
// Taking only a folder or a UID would let one account read another's mail by
// guessing an identifier, so the ownership check lives in the SQL rather than
// in the caller.
type WebmailRepository interface {
FindMailboxIDByAddress(ctx context.Context, address string) (string, error)
ListFolders(ctx context.Context, mailboxID string) ([]WebmailFolder, error)
// ListMessages returns the folder's messages newest first. search, when
// non-empty, filters on subject, sender and snippet.
ListMessages(ctx context.Context, mailboxID, folderName, search string, limit, offset int) ([]WebmailMessage, error)
// GetMessageBlob resolves one message to its stored blob, or returns
// ErrMessageNotFound when it does not belong to this mailbox.
GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error)
// MarkSeen records that the user opened the message.
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
}
@@ -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)
}
@@ -0,0 +1,359 @@
package e2e
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
httppresentation "lambdamail/protocols/internal/presentation/http"
)
const webmailSecret = "webmail-e2e-shared-secret"
// mintSession builds the token the auth service would issue, using the same
// HS256 construction, so this test exercises the real verification path.
func mintSession(t *testing.T, email, surface string, ttl time.Duration) string {
t.Helper()
encode := func(v any) string {
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
header := encode(map[string]string{"alg": "HS256", "typ": "JWT"})
payload := encode(map[string]any{
"sub": uuid.NewString(), "email": email, "role": "USER", "domainId": uuid.NewString(),
"surface": surface, "aud": "lambdamail:" + surface, "mfaSatisfied": true,
"purpose": "session", "iat": time.Now().Unix(), "exp": time.Now().Add(ttl).Unix(),
})
mac := hmac.New(sha256.New, []byte(webmailSecret))
mac.Write([]byte(header + "." + payload))
return header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func TestWebmailApiEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
runtimeDir := t.TempDir()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(54346).
RuntimePath(runtimeDir).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:54346/postgres?sslmode=disable")
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
// ------------------------------------------------------------ fixtures
domainID, mailboxID, folderID := uuid.New(), uuid.New(), uuid.New()
const address = "reader@webmail.example"
if _, err := pool.Exec(ctx,
`INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'webmail.example', 'webmail.example')`, domainID); err != nil {
t.Fatalf("seed domain: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
VALUES ($1, $2, 'reader', $3, 'x')`, mailboxID, domainID, address); err != nil {
t.Fatalf("seed mailbox: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
folderID, mailboxID); err != nil {
t.Fatalf("seed folder: %v", err)
}
// ------------------------------------------------- deliver a real message
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
inbound := appusecase.NewProcessInboundEmailUseCase(
postgres.NewMailboxRepository(pool), blobs, postgres.NewInboundMessageRepository(pool))
raw := "From: Alice Sender <alice@remote.test>\r\n" +
"To: " + address + "\r\n" +
"Subject: =?UTF-8?Q?Relat=C3=B3rio?=\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Corpo da mensagem de teste.\r\n"
targets, err := inbound.ResolveRecipient(ctx, address)
if err != nil {
t.Fatalf("resolve recipient: %v", err)
}
if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{
Sender: "alice@remote.test",
Recipients: targets,
RecipientAddresses: []string{address},
Body: strings.NewReader(raw),
}); err != nil {
t.Fatalf("deliver: %v", err)
}
// ------------------------------------------------------------ the API
webmailUC := appusecase.NewWebmailUseCase(
postgres.NewWebmailRepository(pool),
diskstorage.NewLocalDiskBlobReader(pool),
nil, nil, "mail.webmail.example",
)
router := httppresentation.NewRouter(nil, func() error { return nil })
router.SetMailAPI(webmailUC, webmailSecret)
server := httptest.NewServer(router)
defer server.Close()
token := mintSession(t, address, "user", time.Hour)
call := func(method, path string, body any) (*http.Response, []byte) {
var reader *bytes.Reader
if body != nil {
encoded, _ := json.Marshal(body)
reader = bytes.NewReader(encoded)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, server.URL+path, reader)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
return resp, buf.Bytes()
}
// 1. Folders come back with the seeded INBOX and its unread counter.
resp, body := call(http.MethodGet, "/api/v1/mail/folders", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("folders: status %d body %s", resp.StatusCode, body)
}
var folders []struct {
Name string `json:"name"`
SpecialUse string `json:"special_use"`
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(body, &folders); err != nil {
t.Fatalf("decode folders: %v (%s)", err, body)
}
if len(folders) != 1 || folders[0].SpecialUse != "inbox" || folders[0].UnreadCount != 1 {
t.Fatalf("folders = %+v, want one inbox with 1 unread", folders)
}
// 2. The listing carries the header metadata, decoded.
resp, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("messages: status %d body %s", resp.StatusCode, body)
}
var messages []struct {
UID uint32 `json:"uid"`
Subject string `json:"subject"`
FromName string `json:"from_display_name"`
Snippet string `json:"snippet"`
Seen bool `json:"seen"`
}
if err := json.Unmarshal(body, &messages); err != nil {
t.Fatalf("decode messages: %v (%s)", err, body)
}
if len(messages) != 1 {
t.Fatalf("got %d messages, want 1", len(messages))
}
if messages[0].Subject != "Relatório" {
t.Errorf("Subject = %q, want the decoded encoded-word", messages[0].Subject)
}
if messages[0].FromName != "Alice Sender" {
t.Errorf("FromName = %q", messages[0].FromName)
}
if messages[0].Snippet == "" {
t.Error("Snippet is empty; the list would show no preview")
}
if messages[0].Seen {
t.Error("a freshly delivered message is already marked seen")
}
// 3. Fetching the body returns the original bytes and marks it read.
resp, body = call(http.MethodGet, fmt.Sprintf("/api/v1/mail/message/%d?folder=inbox", messages[0].UID), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("message: status %d body %s", resp.StatusCode, body)
}
var message struct {
Subject string `json:"subject"`
From string `json:"from"`
Text string `json:"text"`
}
if err := json.Unmarshal(body, &message); err != nil {
t.Fatalf("decode message: %v", err)
}
if !strings.Contains(message.Text, "Corpo da mensagem de teste.") {
t.Errorf("the rendered body is not the stored message: %q", message.Text)
}
if message.Subject != "Relatório" {
t.Errorf("rendered Subject = %q, want the decoded encoded-word", message.Subject)
}
if !strings.Contains(message.From, "alice@remote.test") {
t.Errorf("rendered From = %q", message.From)
}
_, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox", nil)
_ = json.Unmarshal(body, &messages)
if !messages[0].Seen {
t.Error("opening the message did not mark it seen")
}
var unread int
if err := pool.QueryRow(ctx, `SELECT unread_count FROM folders WHERE id = $1`, folderID).Scan(&unread); err != nil {
t.Fatal(err)
}
if unread != 0 {
t.Errorf("unread_count = %d after reading the only message, want 0", unread)
}
// 4. Search matches on the indexed columns.
_, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox&q=Relat", nil)
_ = json.Unmarshal(body, &messages)
if len(messages) != 1 {
t.Errorf("search returned %d messages, want 1", len(messages))
}
_, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox&q=nothing-matches-this", nil)
_ = json.Unmarshal(body, &messages)
if len(messages) != 0 {
t.Errorf("search returned %d messages for a non-matching term", len(messages))
}
}
// One account must not be able to read another's mail by guessing a UID.
func TestWebmailApiRejectsCrossMailboxAccess(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
runtimeDir := t.TempDir()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(54347).
RuntimePath(runtimeDir).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:54347/postgres?sslmode=disable")
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
domainID := uuid.New()
if _, err := pool.Exec(ctx,
`INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'iso.example', 'iso.example')`, domainID); err != nil {
t.Fatal(err)
}
victim, attacker := uuid.New(), uuid.New()
for id, local := range map[uuid.UUID]string{victim: "victim", attacker: "attacker"} {
if _, err := pool.Exec(ctx,
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
VALUES ($1, $2, $3, $4, 'x')`, id, domainID, local, local+"@iso.example"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, 'INBOX', 'inbox')`, id); err != nil {
t.Fatal(err)
}
}
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
inbound := appusecase.NewProcessInboundEmailUseCase(
postgres.NewMailboxRepository(pool), blobs, postgres.NewInboundMessageRepository(pool))
targets, err := inbound.ResolveRecipient(ctx, "victim@iso.example")
if err != nil {
t.Fatal(err)
}
if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{
Sender: "someone@remote.test",
Recipients: targets,
RecipientAddresses: []string{"victim@iso.example"},
Body: strings.NewReader("From: s@remote.test\r\nSubject: private\r\n\r\nsecret\r\n"),
}); err != nil {
t.Fatal(err)
}
webmailUC := appusecase.NewWebmailUseCase(
postgres.NewWebmailRepository(pool), diskstorage.NewLocalDiskBlobReader(pool), nil, nil, "mail.iso.example")
router := httppresentation.NewRouter(nil, func() error { return nil })
router.SetMailAPI(webmailUC, webmailSecret)
server := httptest.NewServer(router)
defer server.Close()
// The victim's only message has UID 1; the attacker asks for exactly that.
req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/mail/message/1?folder=inbox", nil)
req.Header.Set("Authorization", "Bearer "+mintSession(t, "attacker@iso.example", "user", time.Hour))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404: one mailbox read another's message", resp.StatusCode)
}
}
// An admin token must not open the user mail API, and neither must no token.
func TestWebmailApiRequiresUserSession(t *testing.T) {
router := httppresentation.NewRouter(nil, func() error { return nil })
router.SetMailAPI(appusecase.NewWebmailUseCase(nil, nil, nil, nil, "mail.test"), webmailSecret)
server := httptest.NewServer(router)
defer server.Close()
for _, tc := range []struct{ name, token string }{
{"no token", ""},
{"admin token", mintSession(t, "admin@iso.example", "admin", time.Hour)},
{"forged token", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.AAAA"},
} {
t.Run(tc.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/mail/folders", nil)
if tc.token != "" {
req.Header.Set("Authorization", "Bearer "+tc.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
})
}
}
@@ -0,0 +1,202 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
)
// ErrMessageNotFound is returned when a message does not exist, or exists but
// belongs to another mailbox. The two are deliberately indistinguishable:
// telling them apart would let a caller probe for other people's UIDs.
var ErrMessageNotFound = errors.New("webmail: message not found")
// WebmailRepository implements the webmail read side against Postgres.
type WebmailRepository struct {
pool *pgxpool.Pool
}
func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository {
return &WebmailRepository{pool: pool}
}
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `
SELECT m.id::text FROM mailboxes m
JOIN domains d ON d.id = m.domain_id
WHERE m.email_address = $1 AND m.is_active AND d.is_active
`, address).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrMessageNotFound
}
return id, err
}
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
rows, err := r.pool.Query(ctx, `
SELECT id::text, name, COALESCE(special_use, ''), unread_count, total_count
FROM folders WHERE mailbox_id = $1
ORDER BY CASE special_use
WHEN 'inbox' THEN 0 WHEN 'drafts' THEN 1 WHEN 'sent' THEN 2
WHEN 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5
ELSE 6 END, name
`, mailboxID)
if err != nil {
return nil, fmt.Errorf("list folders: %w", err)
}
defer rows.Close()
folders := []port.WebmailFolder{}
for rows.Next() {
var f port.WebmailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.SpecialUse, &f.UnreadCount, &f.TotalCount); err != nil {
return nil, err
}
folders = append(folders, f)
}
return folders, rows.Err()
}
// folderCondition matches either the special-use role ("inbox") or the literal
// folder name, so the UI can address a folder the same way IMAP does.
const folderCondition = `(f.special_use = LOWER($2) OR LOWER(f.name) = LOWER($2))`
func (r *WebmailRepository) ListMessages(
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
) ([]port.WebmailMessage, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := r.pool.Query(ctx, `
SELECT e.uid, COALESCE(e.subject, ''), e.sender_address::text,
COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''),
e.size_bytes, e.received_at, e.has_attachments,
EXISTS (SELECT 1 FROM message_flags mf
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'),
EXISTS (SELECT 1 FROM message_flags mf
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Flagged'),
EXISTS (SELECT 1 FROM message_flags mf
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Answered'),
COALESCE(e.spam_verdict, ''), COALESCE(e.dmarc_result, '')
FROM email_messages e
JOIN folders f ON f.id = e.folder_id
WHERE e.mailbox_id = $1 AND `+folderCondition+`
AND e.expunged_at IS NULL
AND ($3 = '' OR e.subject ILIKE '%%' || $3 || '%%'
OR e.sender_address::text ILIKE '%%' || $3 || '%%'
OR e.snippet ILIKE '%%' || $3 || '%%')
ORDER BY e.received_at DESC, e.uid DESC
LIMIT $4 OFFSET $5
`, mailboxID, folderName, search, limit, offset)
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
defer rows.Close()
messages := []port.WebmailMessage{}
for rows.Next() {
var m port.WebmailMessage
if err := rows.Scan(
&m.UID, &m.Subject, &m.SenderAddress, &m.FromName, &m.Snippet,
&m.SizeBytes, &m.ReceivedAt, &m.HasAttachments,
&m.Seen, &m.Flagged, &m.Answered, &m.SpamVerdict, &m.DmarcResult,
); err != nil {
return nil, err
}
messages = append(messages, m)
}
return messages, rows.Err()
}
func (r *WebmailRepository) GetMessageBlob(
ctx context.Context, mailboxID, folderName string, uid uint32,
) (uuid.UUID, error) {
var blobID uuid.UUID
err := r.pool.QueryRow(ctx, `
SELECT e.blob_id FROM email_messages e
JOIN folders f ON f.id = e.folder_id
WHERE e.mailbox_id = $1 AND `+folderCondition+`
AND e.uid = $3 AND e.expunged_at IS NULL
`, mailboxID, folderName, int64(uid)).Scan(&blobID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrMessageNotFound
}
return blobID, err
}
// MarkSeen keeps the \Seen flag and the folder's unread counter in step. They
// are updated together in one transaction, because a counter that disagrees
// with the flags is what makes an inbox show unread mail that is not there.
func (r *WebmailRepository) MarkSeen(
ctx context.Context, mailboxID, folderName string, uid uint32, seen bool,
) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var messageID uuid.UUID
var receivedAt any
var folderID string
err = tx.QueryRow(ctx, `
SELECT e.id, e.received_at, f.id::text FROM email_messages e
JOIN folders f ON f.id = e.folder_id
WHERE e.mailbox_id = $1 AND `+folderCondition+`
AND e.uid = $3 AND e.expunged_at IS NULL
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrMessageNotFound
}
if err != nil {
return err
}
var changed int64
if seen {
tag, err := tx.Exec(ctx, `
INSERT INTO message_flags (message_id, received_at, flag)
VALUES ($1, $2, '\Seen') ON CONFLICT DO NOTHING
`, messageID, receivedAt)
if err != nil {
return err
}
changed = tag.RowsAffected()
} else {
tag, err := tx.Exec(ctx, `
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Seen'
`, messageID, receivedAt)
if err != nil {
return err
}
changed = tag.RowsAffected()
}
// Only a real transition moves the counter, so opening an already-read
// message repeatedly cannot drive unread_count negative.
if changed > 0 {
delta := 1
if seen {
delta = -1
}
if _, err := tx.Exec(ctx, `
UPDATE folders SET unread_count = GREATEST(0, unread_count + $2), highest_modseq = highest_modseq + 1
WHERE id = $1
`, folderID, delta); err != nil {
return err
}
}
return tx.Commit(ctx)
}
@@ -0,0 +1,199 @@
package httppresentation
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/postgres"
)
// maxComposeBytes caps a single submission from the webmail. The SMTP ports
// have their own limit; this one stops an authenticated browser session from
// buffering an unbounded body in this process.
const maxComposeBytes = 32 << 20 // 32 MiB
// mailAPI serves /api/v1/mail/* for the webmail's message screens.
type mailAPI struct {
useCase *usecase.WebmailUseCase
sessions *WebSessionVerifier
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]string{"error": code, "message": message})
}
// authenticate resolves the caller's session, refusing anything that is not a
// /user session token signed by the auth service.
func (m *mailAPI) authenticate(w http.ResponseWriter, r *http.Request) (*WebSession, bool) {
token := ""
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
token = strings.TrimPrefix(header, "Bearer ")
} else if cookie, err := r.Cookie("lm_user_session"); err == nil {
token = cookie.Value
}
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required")
return nil, false
}
session, err := m.sessions.RequireSurface(token, "user")
if err != nil {
// The reason is deliberately not echoed back: whether a token expired,
// was forged or belongs to the other surface is not the caller's
// business.
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required")
return nil, false
}
return session, true
}
func (m *mailAPI) handleFolders(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
folders, err := m.useCase.ListFolders(r.Context(), session.Email)
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, folders)
}
func (m *mailAPI) handleMessages(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
query := r.URL.Query()
folder := query.Get("folder")
if folder == "" {
folder = "inbox"
}
limit, _ := strconv.Atoi(query.Get("limit"))
offset, _ := strconv.Atoi(query.Get("offset"))
messages, err := m.useCase.ListMessages(r.Context(), session.Email, folder, query.Get("q"), limit, offset)
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, messages)
}
func (m *mailAPI) handleMessage(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
uid, err := strconv.ParseUint(strings.TrimPrefix(r.URL.Path, "/api/v1/mail/message/"), 10, 32)
if err != nil {
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID must be a number")
return
}
folder := r.URL.Query().Get("folder")
if folder == "" {
folder = "inbox"
}
raw, err := m.useCase.GetMessage(r.Context(), session.Email, folder, uint32(uid))
if err != nil {
m.fail(w, err)
return
}
// Parsed here rather than in the browser: this service already depends on
// a MIME library, and a second parser in TypeScript would be a second set
// of edge cases to get wrong.
writeJSON(w, http.StatusOK, usecase.RenderMessage(raw, uint32(uid)))
}
func (m *mailAPI) handleSeen(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
var body struct {
Folder string `json:"folder"`
UID uint32 `json:"uid"`
Seen bool `json:"seen"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
}
if body.Folder == "" {
body.Folder = "inbox"
}
if err := m.useCase.SetSeen(r.Context(), session.Email, body.Folder, body.UID, body.Seen); err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
var body struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Subject string `json:"subject"`
Body string `json:"body"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
}
// The sender is the session's address, never a field in the request:
// letting the body choose From is how a webmail becomes an open relay for
// spoofed mail.
err := m.useCase.Send(r.Context(), usecase.ComposeInput{
From: session.Email,
To: body.To,
Cc: body.Cc,
Subject: body.Subject,
Body: body.Body,
})
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true})
}
// fail maps domain errors onto status codes without leaking internals.
func (m *mailAPI) fail(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, postgres.ErrMessageNotFound), errors.Is(err, usecase.ErrNoSuchMailbox):
writeError(w, http.StatusNotFound, "NOT_FOUND", "Message not found")
case errors.Is(err, usecase.ErrNoRecipients):
writeError(w, http.StatusBadRequest, "NO_RECIPIENTS", "At least one valid recipient is required")
case errors.Is(err, usecase.ErrSendLimitExceeded):
writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
case errors.Is(err, usecase.ErrSenderNotOwned):
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account")
default:
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Request could not be processed")
}
}
@@ -22,6 +22,10 @@ type Router struct {
reportUseCase *appusecase.IngestReportsUseCase
pingFunc func() error
mtaStsMode valueobject.MtaStsMode
// mail serves the webmail's message screens. It stays nil when no session
// secret is configured, and the routes then answer 503 rather than
// pretending to be open.
mail *mailAPI
// degradedFunc reports a condition that leaves the service running but
// not fit for production, the clearest case being a self-signed
// certificate standing in for one Traefik never issued
@@ -63,6 +67,11 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
}
@@ -219,3 +228,60 @@ func baseDomain(host string) string {
}
return host
}
// SetMailAPI wires the webmail message API. Without it the mail routes exist
// but report that the feature is not configured, which is a clearer failure
// than a 404 that looks like a typo in the path.
func (r *Router) SetMailAPI(useCase *appusecase.WebmailUseCase, sessionSecret string) {
if useCase == nil || sessionSecret == "" {
return
}
r.mail = &mailAPI{useCase: useCase, sessions: NewWebSessionVerifier(sessionSecret)}
}
func (r *Router) mailReady(w http.ResponseWriter) bool {
if r.mail == nil {
writeError(w, http.StatusServiceUnavailable, "MAIL_API_DISABLED",
"The mail API needs JWT_SECRET to verify webmail sessions")
return false
}
return true
}
func (r *Router) handleMailFolders(w http.ResponseWriter, req *http.Request) {
if r.mailReady(w) {
r.mail.handleFolders(w, req)
}
}
func (r *Router) handleMailMessages(w http.ResponseWriter, req *http.Request) {
if r.mailReady(w) {
r.mail.handleMessages(w, req)
}
}
func (r *Router) handleMailMessage(w http.ResponseWriter, req *http.Request) {
if r.mailReady(w) {
r.mail.handleMessage(w, req)
}
}
func (r *Router) handleMailSeen(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
if r.mailReady(w) {
r.mail.handleSeen(w, req)
}
}
func (r *Router) handleMailSend(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
if r.mailReady(w) {
r.mail.handleSend(w, req)
}
}