Files
LambdaMail/services/protocols/internal/application/usecase/message_render_test.go
T
Lucas RayanandClaude Sonnet 5.5 50a67531ba 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>
2026-10-13 10:09:55 -03:00

194 lines
7.5 KiB
Go

package usecase
import (
"bytes"
"testing"
"github.com/emersion/go-message"
)
func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) {
raw := "From: Alice <alice@remote.test>\r\n" +
"To: bob@example.test\r\n" +
"Subject: =?UTF-8?Q?Ol=C3=A1?=\r\n" +
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\nCorpo.\r\n"
got := RenderMessage([]byte(raw), 7)
if got.UID != 7 || got.Subject != "Ol\u00e1" {
t.Errorf("UID/Subject = %d/%q", got.UID, got.Subject)
}
if got.Text != "Corpo.\r\n" {
t.Errorf("Text = %q", got.Text)
}
if got.Date == "" {
t.Error("Date was not parsed")
}
}
// A message with no Date header parses into the zero time without an error;
// rendering that verbatim showed the year 0001 in the reader.
func TestRenderMessage_LeavesDateEmptyWhenHeaderAbsent(t *testing.T) {
got := RenderMessage([]byte("From: a@b.test\r\nSubject: s\r\n\r\nbody\r\n"), 1)
if got.Date != "" {
t.Errorf("Date = %q, want empty so the UI falls back to received_at", got.Date)
}
}
func TestRenderMessage_PrefersPlainTextAndListsAttachments(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nplain part\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p>html part</p>\r\n" +
"--B\r\nContent-Type: application/pdf\r\n" +
"Content-Disposition: attachment; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
got := RenderMessage([]byte(raw), 1)
if got.Text != "plain part" {
t.Errorf("Text = %q", got.Text)
}
if got.HTML != "<p>html part</p>" {
t.Errorf("HTML = %q", got.HTML)
}
if len(got.Attachments) != 1 || got.Attachments[0] != "report.pdf" {
t.Errorf("Attachments = %v", got.Attachments)
}
}
// Garbage must still open: an unreadable message beats an inbox that errors.
func TestRenderMessage_FallsBackToRawOnParseFailure(t *testing.T) {
got := RenderMessage([]byte("this is not a message"), 3)
if got.Text == "" {
t.Error("a malformed message produced no body at all")
}
}
// An inline image is referenced from the HTML body as cid:<Content-ID>. Neither
// the part nor its identifier used to leave this function, so every message
// written by a normal mail client rendered with broken image placeholders and
// no way for the reader to resolve them.
func TestRenderMessage_ReturnsInlineImagesKeyedByContentID(t *testing.T) {
// "iVBORw0KGgo=" is the leading bytes of a PNG, base64 encoded.
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p><img src=\"cid:logo@x\"></p>\r\n" +
"--B\r\nContent-Type: image/png\r\n" +
"Content-ID: <logo@x>\r\n" +
"Content-Disposition: inline; filename=\"logo.png\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\niVBORw0KGgo=\r\n--B--\r\n"
got := RenderMessage([]byte(raw), 1)
// Keyed without the angle brackets, which is how the HTML spells it.
src, ok := got.InlineImages["logo@x"]
if !ok {
t.Fatalf("InlineImages = %v, want an entry for logo@x", got.InlineImages)
}
if src != "data:image/png;base64,iVBORw0KGgo=" {
t.Errorf("InlineImages[logo@x] = %q", src)
}
}
// An inline image already shown in the body is not a file the reader has to
// download; listing it as an attachment puts a chip under every signature.
func TestRenderMessage_DoesNotListInlineImagesAsAttachments(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<img src=\"cid:sig\">\r\n" +
"--B\r\nContent-Type: image/gif\r\n" +
"Content-ID: <sig>\r\nContent-Disposition: inline; filename=\"sig.gif\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\nR0lGOD==\r\n--B--\r\n"
if got := RenderMessage([]byte(raw), 1); len(got.Attachments) != 0 {
t.Errorf("Attachments = %v, want none", got.Attachments)
}
}
// A file sent inline that is not an image - a PDF a client attached without a
// disposition of "attachment" - is still something the reader must be able to
// download, so it belongs in the attachment list.
func TestRenderMessage_ListsNonImageInlinePartsAsAttachments(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" +
"--B\r\nContent-Type: application/pdf; name=\"report.pdf\"\r\n" +
"Content-Disposition: inline; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
got := RenderMessage([]byte(raw), 1)
if len(got.Attachments) != 1 || got.Attachments[0] != "report.pdf" {
t.Errorf("Attachments = %v, want [report.pdf]", got.Attachments)
}
}
// An image big enough to matter is not worth inlining into a JSON response
// that the browser holds in memory; the reader shows the alt text instead.
func TestRenderMessage_SkipsOversizedInlineImages(t *testing.T) {
huge := make([]byte, maxInlineImageBytes+1024)
for i := range huge {
huge[i] = 'A'
}
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<img src=\"cid:big\">\r\n" +
"--B\r\nContent-Type: image/png\r\nContent-ID: <big>\r\n\r\n" + string(huge) + "\r\n--B--\r\n"
if got := RenderMessage([]byte(raw), 1); len(got.InlineImages) != 0 {
t.Errorf("InlineImages has %d entries, want none", len(got.InlineImages))
}
}
// image/svg+xml is a document: it carries script and is the one image type a
// data: URI must never smuggle onto the reader pane.
func TestRenderMessage_RefusesToInlineSvg(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<img src=\"cid:x\">\r\n" +
"--B\r\nContent-Type: image/svg+xml\r\nContent-ID: <x>\r\n\r\n<svg onload=\"alert(1)\"/>\r\n--B--\r\n"
if got := RenderMessage([]byte(raw), 1); len(got.InlineImages) != 0 {
t.Errorf("InlineImages = %v, want svg refused", got.InlineImages)
}
}
// The chip the reader draws for an inline PDF links at the download endpoint,
// which resolves the name through here. Matching only on a disposition of
// "attachment" made every one of those chips 404.
func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" +
"--B\r\nContent-Type: application/pdf\r\n" +
"Content-Disposition: inline; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
entity, err := message.Read(bytes.NewReader([]byte(raw)))
if err != nil {
t.Fatalf("parse: %v", err)
}
data, ctype := ExtractAttachment(entity, "report.pdf")
if string(data) != "PDFDATA" || ctype != "application/pdf" {
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)
}
}
}