Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês que restava, e "Bcc" tem nome próprio em português - quem não conhece a abreviatura inglesa não adivinha o que o campo faz. Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console de administração", que transbordava no telemóvel e no computador: o botão passa a mostrar um rótulo curto e guarda o nome completo no tooltip. O endereço da conta usava break-words e partia-se a meio do domínio, deixando um "br" sozinho na linha de baixo; passa a truncar com o valor inteiro no tooltip. A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã largo os assuntos eram espremidos numa coluna estreita enquanto o leitor ficava entre duas margens enormes. Ambos crescem agora com a janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do Google são uma única "palavra" sem espaços, mais longa que a coluna, e sem sítio onde quebrar saíam da linha. Imagens remotas. A escolha era reposta sempre que uma mensagem era aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada leitura. A decisão passa a ser lembrada por remetente, com um segundo botão para a retirar. Bloqueado continua a ser o que acontece por omissão: uma imagem remota é um aviso de leitura que o remetente não pediu licença para receber. Assinatura. Era escrita num textarea - texto simples com quebras de linha reais - e injetada no compositor com innerHTML, onde uma quebra de linha é espaço em branco: todas desapareciam. Passa a ser um campo rico, com negrito, itálico, links e imagens, guardado como HTML e sanitizado nos dois sentidos, já que este valor é injetado como marcação na origem do webmail. Assinaturas guardadas pelo campo antigo continuam a funcionar: as suas quebras de linha são convertidas. Mover entre pastas. Faltava a única ação corrente que o leitor não tinha: arquivar uma mensagem para fora da caixa de entrada era impossível. Enviados e Rascunhos nunca são destino - descrevem como a mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é permitido. Pastas criadas pelo utilizador, que não têm papel special-use, são destino como qualquer outra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
444 lines
14 KiB
Go
444 lines
14 KiB
Go
package httppresentation
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/emersion/go-message"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
"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})
|
|
}
|
|
|
|
// handleDelete removes one message: to Trash from anywhere else, and for good
|
|
// from Trash itself.
|
|
func (m *mailAPI) handleDelete(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"`
|
|
}
|
|
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 body.UID == 0 {
|
|
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID is required")
|
|
return
|
|
}
|
|
|
|
if err := m.useCase.Delete(r.Context(), session.Email, body.Folder, body.UID); err != nil {
|
|
m.fail(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
// handleMove files one message into another folder.
|
|
func (m *mailAPI) handleMove(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"`
|
|
Target string `json:"target"`
|
|
}
|
|
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 body.UID == 0 {
|
|
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID is required")
|
|
return
|
|
}
|
|
|
|
if err := m.useCase.Move(r.Context(), session.Email, body.Folder, body.UID, body.Target); err != nil {
|
|
m.fail(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"moved": 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
|
|
}
|
|
// The body is read once, so replace_uid has to come out of the same decode.
|
|
input, replaceUID, ok := m.decodeDraft(w, r, session.Email)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
uid, err := m.useCase.SaveDraft(r.Context(), input, replaceUID)
|
|
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"`
|
|
// DraftUID is the autosaved draft this message came from, so sending
|
|
// can clear it. Without it the draft outlived the message it became.
|
|
DraftUID uint32 `json:"draft_uid"`
|
|
}
|
|
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,
|
|
DraftUID: body.DraftUID,
|
|
}, 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.ErrFolderNotATarget):
|
|
writeError(w, http.StatusBadRequest, "FOLDER_NOT_A_TARGET",
|
|
"Messages cannot be moved into Sent or Drafts")
|
|
case errors.Is(err, usecase.ErrNoTargetFolder):
|
|
writeError(w, http.StatusBadRequest, "NO_TARGET_FOLDER", "A destination folder is required")
|
|
case errors.Is(err, port.ErrNoTrashFolder):
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "That folder does not exist")
|
|
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")
|
|
}
|
|
}
|
|
|
|
// decodeDraft reads a composed message plus the draft it supersedes.
|
|
//
|
|
// replace_uid carries the UID of the previous autosave so the new one takes
|
|
// its place. Without it each keystroke pause would leave another copy behind
|
|
// and the Drafts folder would fill with the same unfinished message.
|
|
func (m *mailAPI) decodeDraft(
|
|
w http.ResponseWriter, r *http.Request, sender string,
|
|
) (usecase.ComposeInput, uint32, bool) {
|
|
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxComposeBytes))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Could not read the request")
|
|
return usecase.ComposeInput{}, 0, false
|
|
}
|
|
|
|
var meta struct {
|
|
ReplaceUID uint32 `json:"replace_uid"`
|
|
}
|
|
// A missing or malformed replace_uid is not fatal: the draft is still
|
|
// worth saving, it just will not supersede anything.
|
|
_ = json.Unmarshal(raw, &meta)
|
|
|
|
r.Body = io.NopCloser(bytes.NewReader(raw))
|
|
input, ok := m.decodeCompose(w, r, sender)
|
|
return input, meta.ReplaceUID, ok
|
|
}
|