Docs›Comunicação›Correio interno
Comunicação

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 via EXISTS(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)

  1. Lê user_to_id, cc_list (CSV vindo dos chips do cliente → vira cc_ids), subject, message.
  2. 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.
  3. Salva a mensagem (thread_id = id quando é uma thread nova), grava cc_ids.
  4. MessageRecipient::syncForMessage() cria as linhas de pivot to + cc (idempotente, ignora rascunho).
  5. Dispara App\Events\CommMessageReceived para 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.full com 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 em public/lib/mad/mad-mail.{js,css} via composer 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, o MadMail é 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') (env MAD_MAILBOX_REVERB). Desligada (default) → a rota /broadcasting/auth nem 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), autoriza private-user.{id} quando (int) id === session('userid'). O app usa session('userid'), não a facade Auth.
  • Cliente (MadMail.setupEcho): assina private('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 de CommMessageReceived::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)

  1. Leitura — global scope mailAccess (Message::booted()): toda query Eloquent (find/get/ações) só enxerga mensagens em que session('userid') participa (remetente, destinatário primário OU linha de Cc no pivot). Desligado sem sessão (CLI/seed); removível com Message::withoutGlobalScope('mailAccess') para rotas de sistema.
  2. Listing em SQL cru — Message::participantWhere() repete a cláusula de participante explicitamente, porque o global scope não cobre as queries via connection()->select(...) usadas no listing por pasta.
  3. 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.
  4. Auditoria — toda rejeição de envio emite Log::warning('mailbox.idor_blocked', …) com ator, ids rejeitados e IP.
  5. 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 pivot kind=to) está no roadmap, não implementado.
  • self-cc (usuário em To e Cc da mesma mensagem) gera 2 linhas de recipient — countUnread conta 2 até o usuário ler (auto-cura ao ler; edge raro, craftável manualmente).
  • countThreadsForFolder (pivot) não repete o participantWhere do 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 em routes/modules/communication.php o bloco do chat vem DEPOIS: quem responde é o ChatBroadcastAuthController. Unificar num único controller de auth de canal está no roadmap. (As notificações nativas já escaparam disso — usam endpoint próprio POST /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.