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

@@ -34,6 +34,15 @@ type RenderedMessage struct {
Text string `json:"text"`
HTML string `json:"html"`
Attachments []string `json:"attachments"`
// MessageID is the Message-ID header, so a reply can thread under this
// message. Empty when the message has none.
MessageID string `json:"message_id"`
// InReplyTo is the Message-ID this message answers, so reopening a draft
// keeps the reply threaded.
InReplyTo string `json:"in_reply_to"`
// Important is true when the sender asked for priority: Importance: high,
// or X-Priority 1 or 2.
Important bool `json:"important"`
// InlineImages maps a part's Content-ID - without the angle brackets, the
// way the HTML body spells it in src="cid:..." - to a self-contained
// data: URI.
@@ -79,6 +88,9 @@ func RenderMessage(raw []byte, uid uint32) RenderedMessage {
}
}
}
out.MessageID = strings.TrimSpace(header.Get("Message-Id"))
out.InReplyTo = strings.TrimSpace(header.Get("In-Reply-To"))
out.Important = isHighPriority(header.Get("Importance"), header.Get("X-Priority"))
// 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.
@@ -214,3 +226,14 @@ func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, strin
}
return nil, ""
}
// isHighPriority reads the two headers clients use to mark urgent mail.
// X-Priority is "1 (Highest)" or "2 (High)"; anything else, or nothing, is
// ordinary.
func isHighPriority(importance, xPriority string) bool {
if strings.EqualFold(strings.TrimSpace(importance), "high") {
return true
}
fields := strings.Fields(xPriority)
return len(fields) > 0 && (fields[0] == "1" || fields[0] == "2")
}
@@ -169,3 +169,25 @@ func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) {
t.Errorf("ExtractAttachment = %q/%q", data, ctype)
}
}
func TestRenderMessage_ReadsPriorityAndMessageID(t *testing.T) {
cases := map[string]bool{
"Importance: high\r\n": true,
"Importance: High\r\n": true,
"X-Priority: 1 (Highest)\r\n": true,
"X-Priority: 2 (High)\r\n": true,
"X-Priority: 3 (Normal)\r\n": false,
"Importance: normal\r\n": false,
"": false,
}
for header, want := range cases {
raw := "From: a@b.test\r\nMessage-ID: <m1@b.test>\r\n" + header + "Subject: s\r\n\r\nbody\r\n"
got := RenderMessage([]byte(raw), 1)
if got.Important != want {
t.Errorf("%q: Important = %v, want %v", header, got.Important, want)
}
if got.MessageID != "<m1@b.test>" {
t.Errorf("MessageID = %q", got.MessageID)
}
}
}
@@ -155,6 +155,9 @@ func (s *stubWebmailRepo) GetMessageBlob(context.Context, string, string, uint32
return uuid.Nil, nil
}
func (s *stubWebmailRepo) MarkSeen(context.Context, string, string, uint32, bool) error { return nil }
func (s *stubWebmailRepo) MarkFlagged(context.Context, string, string, uint32, bool) error {
return nil
}
func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) error {
s.expunged = append(s.expunged, uid)
return s.expungeErr
@@ -472,3 +475,43 @@ func TestMoveRejectsAnEmptyTarget(t *testing.T) {
t.Error("an empty target should be refused")
}
}
// High importance is written as both headers clients read, and only on request.
func TestImportantMessageCarriesPriorityHeaders(t *testing.T) {
in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "s", Text: "t", Important: true}
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
for _, h := range []string{"Importance: high\r\n", "X-Priority: 1\r\n"} {
if !strings.Contains(got, h) {
t.Errorf("missing %q in\n%s", h, got)
}
}
in.Important = false
if got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test")); strings.Contains(got, "Importance") || strings.Contains(got, "X-Priority") {
t.Error("an ordinary message was marked with a priority")
}
}
// A reply threads under the original through In-Reply-To and References.
func TestReplyCarriesThreadingHeaders(t *testing.T) {
in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "Re: s", Text: "t", InReplyTo: "<orig-1@y.test>"}
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
for _, h := range []string{"In-Reply-To: <orig-1@y.test>\r\n", "References: <orig-1@y.test>\r\n"} {
if !strings.Contains(got, h) {
t.Errorf("missing %q", h)
}
}
}
// The reply id comes from the request and lands in a header, so anything that
// could start a new header must be dropped, not written.
func TestInReplyToCannotInjectHeaders(t *testing.T) {
for _, bad := range []string{
"<a@b>\r\nBcc: evil@x.test", "<a@b>\nX: y", "no-brackets@b", "<a b@c>", "<a@b><c@d>", "", "<>",
} {
in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "s", Text: "t", InReplyTo: bad}
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
if strings.Contains(got, "In-Reply-To") || strings.Contains(got, "evil@x.test") {
t.Errorf("accepted %q:\n%s", bad, got)
}
}
}
@@ -123,6 +123,15 @@ func (uc *WebmailUseCase) SetSeen(ctx context.Context, address, folder string, u
return uc.repo.MarkSeen(ctx, id, folder, uid, seen)
}
// SetFlagged marks a message important, or takes the mark off.
func (uc *WebmailUseCase) SetFlagged(ctx context.Context, address, folder string, uid uint32, flagged bool) error {
id, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
return uc.repo.MarkFlagged(ctx, id, folder, uid, flagged)
}
// ComposeInput is one message written in the webmail.
type ComposeInput struct {
From string
@@ -145,6 +154,12 @@ type ComposeInput struct {
// went out and its draft stayed in Drafts: a duplicate of mail already on
// its way, which no screen in the webmail could then delete.
DraftUID uint32
// Important sends the message with high priority, in the two spellings
// mail clients read: Importance (RFC 4021) and the older X-Priority.
Important bool
// InReplyTo is the Message-ID of the message being answered. It is what
// lets the recipient's client thread the reply under the original.
InReplyTo string
}
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
@@ -418,6 +433,13 @@ func buildMimeMessage(input ComposeInput, recipients []string, localHost string)
if len(input.Cc) > 0 {
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n")
}
if id := cleanMessageID(input.InReplyTo); id != "" {
b.WriteString("In-Reply-To: " + id + "\r\n")
b.WriteString("References: " + id + "\r\n")
}
if input.Important {
b.WriteString("Importance: high\r\nX-Priority: 1\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))
@@ -517,3 +539,19 @@ func mimeEncode(value string) string {
clean := strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
return mime.BEncoding.Encode("utf-8", clean)
}
// cleanMessageID returns id only when it is a single, well-formed <...>
// Message-ID. The value comes from the request and is written into a header,
// so anything with a line break - which would start a new header - or
// anything that is not an angle-bracketed token is dropped rather than
// repaired.
func cleanMessageID(id string) string {
id = strings.TrimSpace(id)
if len(id) < 3 || len(id) > 998 || id[0] != '<' || id[len(id)-1] != '>' {
return ""
}
if strings.ContainsAny(id, "\r\n\x00 \t,;") || strings.Count(id, "<") != 1 || strings.Count(id, ">") != 1 {
return ""
}
return id
}