241 lines
7.0 KiB
Go
241 lines
7.0 KiB
Go
package httppresentation
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"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
|
|
}
|
|
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
|
|
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")
|
|
}
|
|
}
|