feat(webmail,auth): leitor estilo Outlook, responder a todos, importantes e isolamento do admin de dominio

Webmail
- Leitor de mensagem em componente proprio, com barra de comandos fixa,
  cabecalho compacto, faixa de imagens bloqueadas e chips de anexo.
- Divisor arrastavel entre lista e leitor (mouse, setas, duplo clique),
  largura persistida e limites que preservam os dois paineis.
- Destinatarios em chips (Para, Cc, Cco): aceita nomes com virgula,
  colagem de listas, validacao e bloqueio de envio com endereco invalido.
- Responder a todos, sem o proprio endereco e sem repetidos; In-Reply-To
  e References nas respostas; prefixo Re:/Fwd: sem repeticao.
- Marcar como importante (estrela, filtro, linha da lista) e enviar com
  alta importancia (Importance/X-Priority), com etiqueta no leitor.
- Sanitizador: a regexp de URI customizada descartava todo atributo que nao
  fosse link (width, colspan, bgcolor...), o que desmontava tabelas de
  e-mail; imagens de fundo agora sao bloqueadas de forma reversivel.

Protocols
- POST /api/v1/mail/flagged (\Flagged) com bump de modseq para IMAP.
- Renderizacao expoe message_id, in_reply_to e important.
- Cabecalhos de resposta validados contra injecao.

Auth
- As views do admin de dominio eram globais: dashboard, fila de saida,
  DMARC, preflight e diagnostico mostravam dados de todos os dominios.
  Agora todas respeitam o escopo da sessao; super admin segue vendo tudo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-13 10:09:55 -03:00
1 parent b5baf33a63
commit 50a67531ba
30 files changed
+1810 -348

No files matched your search

@@ -245,6 +245,32 @@ func (m *mailAPI) handleSeen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (m *mailAPI) handleFlagged(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"`
Flagged bool `json:"flagged"`
}
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.SetFlagged(r.Context(), session.Email, body.Folder, body.UID, body.Flagged); 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 {
@@ -411,6 +437,9 @@ func (m *mailAPI) decodeCompose(
// 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"`
// Important sends with high priority; InReplyTo threads a reply.
Important bool `json:"important"`
InReplyTo string `json:"in_reply_to"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
@@ -426,14 +455,16 @@ func (m *mailAPI) decodeCompose(
// 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,
From: sender,
To: body.To,
Cc: body.Cc,
Bcc: body.Bcc,
Subject: body.Subject,
Text: text,
HTML: body.HTML,
DraftUID: body.DraftUID,
Important: body.Important,
InReplyTo: body.InReplyTo,
}, true
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -34,6 +35,7 @@ type fakeMailStore struct {
addresses map[string]string // address -> mailbox ID
seenCalls []string
flaggedCalls []string
deletedCalls []string
movedCalls []string
expungeErr error
@@ -80,6 +82,11 @@ func (f *fakeMailStore) MarkSeen(_ context.Context, mailboxID, folder string, ui
return nil
}
func (f *fakeMailStore) MarkFlagged(_ context.Context, mailboxID, folder string, uid uint32, flagged bool) error {
f.flaggedCalls = append(f.flaggedCalls, fmt.Sprintf("%s/%s/%d/%t", mailboxID, folder, uid, flagged))
return nil
}
func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid uint32) error {
if f.expungeErr != nil {
return f.expungeErr
@@ -308,6 +315,43 @@ func TestMessageListDefaultsToTheInbox(t *testing.T) {
}
}
// --- flagging ------------------------------------------------------------
// Marking important is scoped to the session's own mailbox, never to an
// identifier the request supplies.
func TestFlaggingActsOnTheSessionsMailbox(t *testing.T) {
store := storeWithMail()
api := newMailAPI(t, store, nil)
rec := httptest.NewRecorder()
api.handleFlagged(rec, request(http.MethodPost, "/api/v1/mail/flagged", `{"uid":1,"flagged":true}`))
if rec.Code != http.StatusOK {
t.Fatalf("answered %d", rec.Code)
}
if len(store.flaggedCalls) != 1 || store.flaggedCalls[0] != "mailbox-1/inbox/1/true" {
t.Errorf("flag call = %v, want [mailbox-1/inbox/1/true]", store.flaggedCalls)
}
}
func TestFlaggingRefusesARequestWithNoSession(t *testing.T) {
api := newMailAPI(t, storeWithMail(), nil)
rec := httptest.NewRecorder()
api.handleFlagged(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mail/flagged", strings.NewReader(`{"uid":1}`)))
if rec.Code != http.StatusUnauthorized {
t.Errorf("answered %d without a session, want 401", rec.Code)
}
}
func TestFlaggingRejectsMalformedJson(t *testing.T) {
api := newMailAPI(t, storeWithMail(), nil)
rec := httptest.NewRecorder()
api.handleFlagged(rec, request(http.MethodPost, "/api/v1/mail/flagged", `{`))
if rec.Code != http.StatusBadRequest {
t.Errorf("answered %d, want 400", rec.Code)
}
}
// --- deleting ------------------------------------------------------------
func TestDeleteMovesToTrashFromAnOrdinaryFolder(t *testing.T) {
@@ -94,6 +94,7 @@ func (r *Router) registerRoutes() {
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/flagged", r.handleMailFlagged)
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
@@ -303,6 +304,16 @@ func (r *Router) handleMailSeen(w http.ResponseWriter, req *http.Request) {
}
}
func (r *Router) handleMailFlagged(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.handleFlagged(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")