Correio interno
Inbox estilo Outlook: pastas, Cc, rótulos, rascunhos, pivot per-destinatário e push via Reverb.
Correio interno
Mensageria interna estilo Outlook/Gmail: pastas, Cc, rótulos, rascunhos, favoritos, arquivamento (separado de "lido") e push em tempo real via Laravel Reverb — tudo server-driven, sem build de frontend.
Este módulo é funcional e coberto por testes, mas tem dívidas técnicas conhecidas documentadas no final desta página em Limitações conhecidas — leia antes de tratá-lo como 100% pronto para produção em alta escala.
Visão geral
Browser
resources/views/comm/message-list.blade.php (.mad-mailbox, single-pane)
├─ mad:click / mad:model → App\Control\Comm\MessageList (MadComponent)
├─ MadMail (classe JS fina): compose flutuante, autocomplete, toasts, badge
└─ Echo (Reverb) — private-user.{id}
│ WebSocket
Servidor
App\Control\Comm\MessageList / MessageForm / MessageFormView
App\Http\Controllers\Comm\MailController (autocomplete + autorização)
Models: App\Models\Comm\Message · MessageRecipient (pivot) · Label
Event App\Events\CommMessageReceived → broadcast (ShouldBroadcastNow)
conexão `comm` (mad.sqlite em dev; split em prod)
O estado mora no servidor — props públicas do MadComponent, serializadas em
mad_state criptografado. O JS (MadMail) cuida só do que é client-only: a janela
de composição flutuante, autocomplete de destinatário, popovers, toasts e a
assinatura do canal realtime.
Modelo de dados (conexão comm)
| Tabela | Model | Papel |
|---|---|---|
mad_comm_message |
App\Models\Comm\Message |
A mensagem. user_id (remetente), user_to_id (destinatário primário), subject, message, thread_id, reply_to_id, cc_ids (JSON), is_draft, dt_message. Colunas de estado (checked/archived/starred/deleted_by_*) ainda existem na própria linha, mas o estado autoritativo do recebido vive no pivot. |
mad_comm_message_recipient |
App\Models\Comm\MessageRecipient |
Pivot per-destinatário — o coração do design. 1 linha por (mensagem × usuário), kind = to|cc, e o estado de caixa daquele usuário: read_at, starred, archived, deleted. |
mad_comm_label + mad_comm_label_link |
App\Models\Comm\Label |
Rótulos N:N (owner_id + name + color) e o vínculo mensagem↔rótulo. |
mad_comm_message_attachment |
App\Models\Comm\MessageAttachment |
Anexos (file_path, mime_type, kind). |
Por que o pivot: uma mensagem com Cc precisa de estado independente por destinatário (B leu, C não; C favoritou, B não) — um modelo 1:1 simples não consegue representar isso.
De onde sai cada pasta
- Recebidas (inbox/archive/starred/trash/label): saem do pivot. A última
mensagem por thread vem de
MessageRecipient::folderInner(), que filtra viaEXISTS(recipient do usuário com archived/deleted/starred conforme a pasta). - Enviadas (sent) e Rascunhos (drafts): lado-remetente — o remetente não
tem linha de pivot, então essas duas usam
Message::getThreadsForFolderSender()(user_id = ? AND is_draft = ...). - Favoritos: união do favorito do destinatário (
recipient.starred) com o do remetente (message.starred AND user_id = usuário).
O listing de recebidas (MessageRecipient::getThreadsForFolder, default
limit = 25) faz LEFT JOIN na linha de pivot do usuário e traz o estado do
ponto de vista dele em aliases que o hydrate vira atributos do model:
_r_read_at, _r_starred, _r_archived. É por isso que a lista mostra
lido/favorito do usuário logado e não do destinatário primário.
Componentes (server)
| Classe | Rota | Papel |
|---|---|---|
App\Control\Comm\MessageList |
/app/comunicacao/mensagens |
Inbox server-driven: pastas, contadores, busca, seleção/ações em massa, ações da thread (ler/arquivar/favoritar/rótulo/lixeira), abrir thread (single-pane). |
App\Control\Comm\MessageForm |
/app/comunicacao/mensagem |
Composição flutuante (nova/encaminhar/editar rascunho). onSave/onSaveDraft chamam persist(). |
App\Control\Comm\MessageFormView |
/app/comunicacao/mensagem-conversa |
Leitura da thread + responder/responder a todos inline. Abrir marca como lido (não arquiva). |
App\Http\Controllers\Comm\MailController |
endpoints JSON | users() (autocomplete de destinatário, escopado por unidade) e filterAllowed() (autorização server-side — o portão único de envio). |
Envio (MessageForm::persist)
- Lê
user_to_id,cc_list(CSV vindo dos chips do cliente → viracc_ids),subject,message. - Autoriza To + cada Cc via
MailController::filterAllowed()(usuários ativos, na(s) unidade(s) do remetente). Falhou →Log::warning('mailbox.idor_blocked', …)- erro de validação, antes de qualquer escrita.
- Salva a mensagem (
thread_id = idquando é uma thread nova), gravacc_ids. MessageRecipient::syncForMessage()cria as linhas de pivotto+cc(idempotente, ignora rascunho).- Dispara
App\Events\CommMessageReceivedpara o destinatário primário + cada Cc (pula rascunho).
public function onSave(): MadResponse
{
return $this->persist(false);
}
public function onSaveDraft(): MadResponse
{
return $this->persist(true);
}
Frontend
resources/views/comm/message-list.blade.php— raiz.mad-mailbox[data-density=compact]#madMailbox, com um<aside class="mail-folders">fixo (pastas, contadores e rótulos via<mad-db-blocks name="sidebar-labels">) e, ao lado, um único painel que alterna (estilo Gmail): com thread selecionada renderiza.m-read-pane.full(leitura full-width com "← Voltar" + toolbar); sem seleção,.m-list-pane.fullcom a lista 100%. Não é o 3-colunas clássico do Outlook — a leitura substitui a lista, não divide a tela com ela. Emite um boot inline (MadMail.init(...)) e injeta a config pública do Reverb quando a flag está ligada.- Fonte do widget:
packages/mad-framework/assets/mad-mail.{js,css}→ publicada empublic/lib/mad/mad-mail.{js,css}viacomposer mad:sync(ver .claude/rules/mad-assets-sync.md — editar só a fonte, nunca a cópia servida). - Classe JS:
MadMail—openCompose, autocomplete.cp-ac, toasts, badge,setupEcho. - Como o
<script src>injetado por navegação AJAX é ignorado pelo browser, oMadMailé carregado uma vez globalmente pelo casco; a blade só faz o boot inline a cada render.
Tempo real (Laravel Reverb)
- Flag de deploy:
config('mad.mailbox.reverb_enabled')(envMAD_MAILBOX_REVERB). Desligada (default) → a rota/broadcasting/authnem registra e o Echo não é injetado;broadcast()vira no-op — nunca bloqueia o envio da mensagem. - Evento:
App\Events\CommMessageReceived—ShouldBroadcastNow,PrivateChannel('user.{toId}'). Payload com id, subject, preview e nome do remetente montado no construtor. - Auth de canal:
App\Http\Controllers\Comm\MailBroadcastAuthController— ponte por sessão (POST /broadcasting/auth), autorizaprivate-user.{id}quando(int) id === session('userid'). O app usasession('userid'), não a facadeAuth. - Cliente (
MadMail.setupEcho): assinaprivate('user.'+meId), escuta.MensagemRecebida→ atualiza o badge, dispara um toast e atualiza a lista (se o compose não estiver aberto). O nome do evento no fio é.MensagemRecebida(retorno deCommMessageReceived::broadcastAs()), não o FQCN da classe. - O
broadcast()é envolto em try/catch no service de envio — uma queda do Reverb nunca derruba a persistência da mensagem.
MAD_MAILBOX_REVERB=true
Segurança (defesa em camadas)
- Leitura — global scope
mailAccess(Message::booted()): toda query Eloquent (find/get/ações) só enxerga mensagens em quesession('userid')participa (remetente, destinatário primário OU linha de Cc no pivot). Desligado sem sessão (CLI/seed); removível comMessage::withoutGlobalScope('mailAccess')para rotas de sistema. - Listing em SQL cru —
Message::participantWhere()repete a cláusula de participante explicitamente, porque o global scope não cobre as queries viaconnection()->select(...)usadas no listing por pasta. - Envio —
filterAllowed()é o portão único: To/Cc precisam estar ativos e na(s) unidade(s) do remetente (regra "unidade única vê todos" quando há 1 unidade). O autocomplete já restringe no cliente, mas o form é texto livre — o servidor nunca confia nele. - Auditoria — toda rejeição de envio emite
Log::warning('mailbox.idor_blocked', …)com ator, ids rejeitados e IP. - XSS — corpo sanitizado (
Mad\Util\MadHtmlSanitizer) no caminho de leitura e ao montar o forward.
Ações principais (mapa rápido)
Na UI cada ação é um handler on* do MessageList (onArchive, onUnarchive,
onToggleStar, onDelete, onRestore, onMarkUnread, onApplyLabel,
onRemoveLabel, mais as versões em massa onBulkArchive/onBulkDelete/onBulkRead);
o trabalho real fica nos métodos estáticos de Message da coluna "Server" abaixo,
que rodam fora do global scope mailAccess (a ação já é keyed pelo $userId
explícito).
| Ação UI | Server | Efeito no pivot |
|---|---|---|
| Abrir thread | markThreadRead |
recipient.read_at setado (não arquiva) |
| Arquivar / desarquivar | archiveThread |
recipient.archived Y/N |
| Favoritar | toggleStarThread |
recebido → recipient.starred; só-enviado → message.starred |
| Lixeira / restaurar | trashThread / restoreThread |
recipient.deleted (+ deleted_by_from no remetente) |
| Marcar não lida | markThreadUnread |
recipient.read_at = null na última recebida |
| Rótulo aplicar/remover | applyLabel / removeLabel |
mad_comm_label_link |
| Badge não-lido (header) | countUnread |
conta recipient read_at IS NULL e deleted='N' e mensagem is_draft='N' — independe de arquivamento (arquivado e não lido ainda conta) |
Limitações conhecidas
O módulo é funcional ponta a ponta e tem suíte de testes (MailboxUpgradeTest,
MailboxRecipientPivotParityTest, MailboxHardeningTest), mas algumas decisões
de design ficaram conscientemente como dívida técnica documentada, não como bug
escondido:
- Múltiplos destinatários primários (To: N) não existe — hoje
user_to_idé 1 coluna; o fan-out real por destinatário (todos virando linha de pivotkind=to) está no roadmap, não implementado. self-cc(usuário em To e Cc da mesma mensagem) gera 2 linhas de recipient —countUnreadconta 2 até o usuário ler (auto-cura ao ler; edge raro, craftável manualmente).countThreadsForFolder(pivot) não repete oparticipantWheredo listing — sob ação concorrente entre as duas queries, badge e lista do header podem divergir por 1 (baixa probabilidade)./broadcasting/authé compartilhada com o chat (ver Chat em tempo real) — com as duas flags ligadas ao mesmo tempo, a última rota registrada vence, e emroutes/modules/communication.phpo bloco do chat vem DEPOIS: quem responde é oChatBroadcastAuthController. Unificar num único controller de auth de canal está no roadmap. (As notificações nativas já escaparam disso — usam endpoint próprioPOST /broadcasting/auth-notifications.)- Sem rate-limit de envio ainda — um usuário autenticado pode disparar mensagens (e broadcasts) sem limite de taxa.
- Sem full-text search — a busca por assunto/corpo é
LIKE %termo%, sem operadores (from:,has:attachment) nem índice de texto.
Nenhuma dessas limitações compromete a segurança do módulo (autorização e isolamento por usuário continuam corretos) — são lacunas de produto/escala a fechar conforme a necessidade.
Veja também
- Chat em tempo real — mensageria 1:1/grupo via o mesmo transporte Reverb.
- Notificações — sino do header e central de notificações, módulo irmão que também usa Reverb com a mesma filosofia de degradação graciosa.