Files
LambdaMail/services/protocols/internal/presentation/http/mail_api.go
T
Lucas RayanandClaude Opus 5 d8e2de2e11 #24: deliver what the composer wrote, keep a copy, and store drafts
Four faults, all in the path between the compose screen and the mailbox.

**Every message sent from the webmail had an empty body.** The HTTP handler
read a field named "body"; the composer has always sent "html". The two never
matched, so the payload handed to the queue contained headers and nothing
else. The handler now reads html and text, and still accepts body so an older
client is not broken. Sending was otherwise working, which is why this went
unnoticed: the mail left, it just said nothing.

**Bcc was discarded.** The field was absent from the request struct and from
ComposeInput, so a blind copy typed into the webmail reached nobody. It now
joins the envelope recipients and is deliberately kept out of the headers,
which is what makes it blind.

**HTML was labelled text/plain.** Even once the body was carried, the composer
produces markup and the message declared text/plain, so a recipient would see
raw tags. Messages with HTML now go out as multipart/alternative with a
plain-text part derived from the markup and ordered first per RFC 2046 5.1.4.
An empty or missing text alternative is itself a spam signal.

**Sent mail left no trace.** Nothing wrote to the Sent folder, so a delivered
message vanished from the sender's view entirely. A copy is now filed after
the message is durably queued - not before, or a send refused by the rate
limit would still leave a copy - reusing the inbound persistence path so it
gets a real IMAP UID and appears over IMAP too. A failure to file is logged
and swallowed: the mail is already on its way, and reporting failure would
invite the user to send it twice.

**Drafts were never saved.** There was no endpoint at all. The composer showed
"draft saved automatically" from a setTimeout with no request behind it, so
closing the tab lost the message the screen had just promised was safe. Adds
POST /api/v1/mail/draft, storing the draft as a whole RFC 5322 message in the
Drafts folder, and debounces the composer onto it.

The composer also navigated to Sent whether or not the send succeeded, which
is how a rejected message disappears silently. It now reports the error and
stays put.

**Delay notifications were being filed as spam.** A DSN this server writes is
injected through the same inbound path as mail from the internet, but without
a client IP - so SPF can only be "none" - and its bounce-shaped content scores
highly. The warning telling you your mail was delayed landed in your own Junk
folder. Messages the server generates itself now skip content scanning; the
exemption is narrow, and a test covers that ordinary mail is still scanned so
this cannot become a way past the filter. The DSN also declared
Auto-Submitted: auto-replied, which belongs to vacation responders; a delivery
report is auto-generated (RFC 3834 section 5), and filters read that header.

Not fixed here, because it is not a code fault: outbound port 25 is blocked by
the provider, which is what the delay notification was reporting
("i/o timeout" to Google's MX - a timeout, not a refusal, is the signature of
a block). Receiving is unaffected and queued mail keeps retrying. Documented
with the two ways out, including the relay path, which is verified working
code rather than another stub.

Verified: 22 Go packages pass, 5 new compose tests and 3 for the DSN handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:56:59 -03:00

342 lines
10 KiB
Go

package httppresentation
import (
"bytes"
"encoding/json"
"errors"
"mime"
"net/http"
"strconv"
"strings"
"github.com/emersion/go-message"
"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
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/mail/message/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID is required")
return
}
uid, err := strconv.ParseUint(parts[0], 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
}
// Handle attachment subpath: /api/v1/mail/message/{uid}/attachment/{filename}
if len(parts) >= 3 && parts[1] == "attachment" {
m.handleAttachmentDownload(w, r, raw, parts[2])
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) handleAttachmentDownload(w http.ResponseWriter, _ *http.Request, raw []byte, filename string) {
entity, err := message.Read(bytes.NewReader(raw))
if err != nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", "Attachment not found")
return
}
data, contentType := usecase.ExtractAttachment(entity, filename)
if data == nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", "Attachment not found")
return
}
// The sender chose this filename and this content type, so neither is
// trusted. Serving an attachment's own text/html back on the webmail's
// origin is stored XSS, and a quote in the name breaks out of the
// Content-Disposition parameter - both reachable by anyone who can send
// this mailbox a message.
w.Header().Set("Content-Type", safeAttachmentContentType(contentType))
w.Header().Set("Content-Disposition",
mime.FormatMediaType("attachment", map[string]string{"filename": sanitiseFilename(filename)}))
// Without nosniff a browser may still sniff the bytes and render them as
// HTML regardless of the declared type.
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
// renderableTypes are the content types a browser will execute or render in a
// document context. They are downgraded so an attachment can never become a
// page on this origin.
var renderableTypes = map[string]bool{
"text/html": true,
"application/xhtml+xml": true,
"image/svg+xml": true,
"application/xml": true,
"text/xml": true,
"application/javascript": true,
"text/javascript": true,
"application/x-javascript": true,
"application/xhtml": true,
"application/vnd.wap.xhtml+xml": true,
}
func safeAttachmentContentType(declared string) string {
declared = strings.ToLower(strings.TrimSpace(declared))
if declared == "" || renderableTypes[declared] {
return "application/octet-stream"
}
return declared
}
// sanitiseFilename keeps a name usable in a header and on a filesystem: no
// path separators, no control characters, no quotes.
func sanitiseFilename(name string) string {
name = strings.Map(func(r rune) rune {
switch {
case r < 0x20 || r == 0x7f:
return -1
case r == '"' || r == '\\' || r == '/' || r == ':':
return '_'
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "attachment"
}
if len(name) > 200 {
name = name[:200]
}
return name
}
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
}
input, ok := m.decodeCompose(w, r, session.Email)
if !ok {
return
}
if err := m.useCase.Send(r.Context(), input); err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true})
}
// handleSaveDraft stores the message being composed in the Drafts folder.
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
input, ok := m.decodeCompose(w, r, session.Email)
if !ok {
return
}
uid, err := m.useCase.SaveDraft(r.Context(), input)
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"saved": true, "uid": uid})
}
// decodeCompose reads a composed message off the wire.
//
// "html" and "text" are both accepted, and both are new: the field was called
// "body" while the composer had always sent "html", so every message sent from
// the webmail was delivered with an empty body. "bcc" was likewise never read,
// so blind recipients were silently dropped.
func (m *mailAPI) decodeCompose(
w http.ResponseWriter, r *http.Request, sender string,
) (usecase.ComposeInput, bool) {
var body struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
// Accepted so an older client - or a script written against the
// previous shape - still sends a readable message rather than a blank
// one. Only used when neither text nor html is present.
LegacyBody 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 usecase.ComposeInput{}, false
}
text := body.Text
if text == "" && body.HTML == "" {
text = body.LegacyBody
}
// 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.
return usecase.ComposeInput{
From: sender,
To: body.To,
Cc: body.Cc,
Bcc: body.Bcc,
Subject: body.Subject,
Text: text,
HTML: body.HTML,
}, 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")
case errors.Is(err, usecase.ErrDraftsUnavailable):
writeError(w, http.StatusServiceUnavailable, "DRAFTS_UNAVAILABLE", "Draft storage is not configured")
default:
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Request could not be processed")
}
}