Enviar Comunicado por Email

Objetivo

Base genérica para mandar qualquer email pelo sistema de comunicados já existente no WebApp (app.reorganize.com.br) — não é exclusiva de um produto ou formato. Serve tanto para conteúdo estruturado com template fixo (ex.: comunicado semanal de aula, ver skill comunicado-semanal-aula) quanto para um aviso pontual escrito na hora, sem template nenhum.

Sistema real (não é tmp, é o sistema de produção)

Repo: /Users/hugodiasneto/Sites/reorganize-webapp (não usar o legado dreamhost-dreampress).

Script CLI genérico (roda no container)

Instalação ativa desta skill: ~/.codex/skills/enviar-comunicado-email/ (espelho em kb/operations/enviar-comunicado-email-skill/).

Script real (não é tmp, versionado em reorganize-webapp):

/var/www/sites/app.reorganize.com.br/cron/comunicado_email_cli.php

(caminho real na VPS, dentro do container Docker rz-web — confirmado 2026-09-21; /home/rzvps01/... e outros caminhos antigos citados abaixo estão obsoletos, a stack roda em Docker, não direto no host)

Rodar sempre via docker exec na VPS, nunca local nem via SSH direto no host. php não existe no shell da VPS fora de container (bash: php: command not found) — a stack roda em Docker (rz-web = Apache+PHP, rz-mysql = MySQL). O script também inclui inc_globals.php, que abre conexão direta com o banco (mysql2.reorganize.com.br) só pra bootar — isso funciona de dentro do container, mas dá Connection refused se rodado do Mac local (confirmado em 2026-08-25, quando a execução automática de segunda tentou rodar local e travou). Fluxo confirmado:

scp /caminho/local/mensagem.html reorganize-vps:/tmp/mensagem.html
ssh reorganize-vps "docker cp /tmp/mensagem.html rz-web:/tmp/mensagem.html"

ssh reorganize-vps "docker exec -w /var/www/sites/app.reorganize.com.br/cron rz-web php comunicado_email_cli.php --product=... --campaign-slug=... --subject='...' --content-file=/tmp/mensagem.html [--commit]"

O --content-file precisa existir no filesystem dentro do container — por isso o scp pro host seguido de docker cp pro container antes de rodar o CLI. Um heredoc via SSH direto no host (ssh ... "cat > /tmp/x" <<EOF) grava só no host, não no container — precisa do docker cp extra. Testado em 2026-08-25 com acentos/UTF-8 (preserva encoding sem escapar nada, contanto que o arquivo local já esteja em UTF-8). Consultas read-only rápidas (ex.: checar se uma campanha já existe) podem usar a API HTTPS (/api/v1/campaigns, /api/v1/audience) direto do Mac local sem precisar de SSH — só o CLI que grava/agenda (dry-run e --commit) precisa do container.

Resolver product_slug de edição sozinho, antes de perguntar a Hugo

Pra um comunicado de uma edição/turma específica (MRZPR, ICDB, etc.) — nunca usar o produto genérico/pai (mrzpr, mrz3-presencial, icdb), senão a audiência pega histórico inteiro, não só a turma. Mas também não perguntar a Hugo de cara: primeiro tentar achar o slug exato sozinho.

curl -s "https://app.reorganize.com.br/api/v1/products?q=<cidade-ou-YYYYMM>&active=all&limit=200" \
  -H "Authorization: Bearer <token em app.reorganize.com.br/api/v1/products.php>" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); [print(p['slug'],'|',p['title']) for p in d['data']]"

Convenção observada: {produto-base}-{cidade}-{uf}-br-{YYYYMM}[-variante] — ex.: mrz3-presencial-rio-rj-br-202608, icdb-rio-rj-br-202608-prata-lote1. Buscar por cidade (q=rio) ou ciclo (q=202608) já costuma entregar o slug certo. Rodar o dry-run (sem --commit) depois pra confirmar a contagem antes de qualquer coisa. Só perguntar a Hugo se a busca não retornar candidato único e claro (zero resultados, ou mais de um produto plausível pro mesmo recorte).

Uso:

php comunicado_email_cli.php \
  --product=<product_slug> \
  --campaign-slug=<slug-unico> \
  --subject="Assunto do email" \
  --content-file=/caminho/mensagem.html \
  [--message-type=academica|marketing] \
  [--sender-name="Reorganize"] [--sender-email=reorganize@app.reorganize.com.br] \
  [--test] [--commit]

Ou, sem produto (audiência só por DDD — ver seção de audiência abaixo):

php comunicado_email_cli.php \
  --ddd=<codigo>[,<codigo2>] [--sources=person,lead] \
  --campaign-slug=<slug-unico> \
  --subject="Assunto do email" \
  --content-file=/caminho/mensagem.html \
  [--test] [--commit]

Múltiplos produtos num único envio

--product aceita N slugs separados por vírgula, sem espaço:

--product=mrz3-gold-vitalicio,mrz3-presencial,mrz3-upgrade-vitalicio

O CLI busca a audiência de cada slug separadamente (mesma paginação já testada) e faz union deduplicada por email — quem comprou mais de um dos produtos recebe o email uma única vez. --limit continua valendo por produto, não no total combinado. O product_slug gravado na fila e na campanha fica com a lista completa separada por vírgula (é só rótulo/display, não afeta envio nem paginação).

Audiência por DDD (--ddd / --sources), com ou sem produto

Confirmado direto no docblock de comunicado_email_cli.php (2026-09-22). --ddd filtra a audiência por DDD do telefone e funciona de dois jeitos:

--ddd=21 --sources=person,lead

--sources só é lido quando --product não foi passado, aceita person, lead e/ou prospect separados por vírgula. Default person,lead (clientes + leads) — prospect (CRM de recuperação/carrinho abandonado) só entra se for pedido explicitamente: --sources=person,lead,prospect.

Usado hoje em icdb-comunicado-venda-ingresso-quarta e no comunicado de abertura de matrícula MRZPR por DDD — ambos sem --product, modo "qualquer humano com esse DDD".

Variável de nome: {{first_name}}, chave dupla

ComunicadoCliUnfilledTemplateVariables() reprova o envio inteiro se sobrar {qualquer_coisa} com chave simples no assunto ou no conteúdo. Primeiro nome é {{first_name}} (substituído por destinatária em ComunicadosQueueCampaign). Link fixo — grupo de WhatsApp da edição — entra escrito com a URL real de participants_whatsapp_group (aba editions), não como variável.

Audiência por tag (tag_id): não existe no CLI

O painel (admin_comunicados.php?tag_id=...) monta audiência por tag, o CLI não tem --tag. Antes de propor código novo, testar o produto equivalente: em 2026-10-22 a tag rav2026 bonus (tag_id 14, 27 pessoas) deu exatamente os mesmos 27 com --product=rav2026-bonus (o fetch casa product_slug ou product_version). Confirmar a contagem no dry-run dos dois lados antes de assumir equivalência.

ComunicadosApplyUtmToHtml() existe, mas só o painel chama (admin_comunicados.php). Campanha criada pelo CLI entrega o HTML como foi escrito — então as UTMs pedidas vão literais em cada <a href>, sem o sufixo -webapp_comunicados. Em link com âncora, a query vai antes do #fragmento.

Teste no Gmail do Hugo (fora da fila)

--test do CLI não manda pro Gmail: cron_comunicados_queue.php fixa tech@reorganize.com.br. Para teste fiel, chamar no container SendEmailTransacional('<gmail>', $assunto, $html_renderizado, ...) com o mesmo remetente do envio real — não entra na fila nem na contagem da campanha.

Agendamento

O CLI aceita --scheduled-at="YYYY-MM-DD HH:MM:SS" (horário de Brasília). Sem esse parâmetro, o envio é imediato (agora). Com ele, a campanha fica com status=scheduled e scheduled_datetime no futuro — o cron (cron_comunicados_queue.php, roda a cada poucos minutos) só pega e dispara quando a hora chega.

Regra fixa (2026-08-21): quando Hugo pedir um envio "pra agora", sempre usar --scheduled-at com agora+10min, nunca literalmente agora. Dá folga entre o --commit (que Hugo roda) e o horário real de disparo, cobrindo o tempo de Hugo copiar/colar o comando e rodar. Isso é sobre o caso "pra agora" especificamente — pedido com horário explícito no futuro segue a regra geral de nunca agendar no passado ou a menos de 15min (feedback_email_scheduling_timing na memória).

Regra fixa: no retorno depois do --commit, sempre mandar de cara o link de acompanhamento (admin_comunicados_queue.php?campaign_slug=...), sem esperar o usuário pedir.

Audiência grande (>2000 destinatários)

A API de audiência (api/v1/audience.php) limita cada chamada a no máximo 2000 linhas. O CLI já pagina isso sozinho internamente (parâmetro offset, usando meta.raw_count da resposta pra saber se ainda há mais páginas) — não é preciso se preocupar com isso ao usar o script. Só vale lembrar se algum dia o número de destinatários enviados parecer baixo demais: checar se a paginação está de fato varrendo todas as páginas.

Reenviar / reagendar uma campanha existente

--resend=<slug-original> cria uma campanha nova (slug diferente, obrigatório em --campaign-slug) reaproveitando produto, assunto, conteúdo e message_type já gravados na campanha original — busca audiência atualizada na hora, não reusa a fila antiga:

ssh reorganize-vps "docker exec -w /var/www/sites/app.reorganize.com.br/cron rz-web php comunicado_email_cli.php --resend=comunicado-diretora-20260820 --campaign-slug=comunicado-diretora-20260820-r2 --scheduled-at='2026-08-30 09:00:00' --commit"

Não reusa o campaign-slug antigo — o dedupe da fila é por campaign_slug+email, então isso não geraria linhas novas. Campanhas criadas antes deste recurso existir não têm conteúdo salvo (coluna content_raw); nesses casos passe --content-file também.

O mesmo fluxo existe no painel (admin_comunicados_queue.php?campaign_slug=...): botão Reenviar como nova campanha, com slug novo e data/hora já sugeridos (padrão: agora + 10min).

Prévia clicável antes de pedir OK

Sempre que pedir confirmação de conteúdo/audiência antes do --commit, ou ao confirmar que um envio foi agendado/disparado, mandar um link clicável pra Hugo ler a mensagem já renderizada — nunca só colar o texto/HTML cru no chat.

Motivo: em 2026-09-02 um --content-file em texto puro (quebras de linha sem <br>) só foi percebido como quebrado depois que Hugo abriu o HTML renderizado no painel — e nesse meio-tempo um reagendamento errado já tinha disparado a versão quebrada pra 17 pessoas reais. Uma prévia clicável antes do commit teria pego o problema de formatação sem precisar de um envio real pra revelar.

Painel de monitoramento e controle

https://app.reorganize.com.br/admin_comunicados_queue.php?campaign_slug=...

Mostra status e progresso de uma campanha específica (draft, scheduled, processing, done, canceled). Tem dois botões de controle:

Corrigido em 2026-08-21: o UPDATE final do cron que libera o lock da campanha não tinha guarda — um worker que ainda estava processando um batch podia terminar depois de um "Cancelar envio" e sobrescrever o canceled de volta pra processing/done silenciosamente (aconteceu de verdade na campanha de teste mrz-aula-semanal-20260826-test). O UPDATE final agora exige AND lock_token=... (o token que o próprio worker capturou ao travar a campanha) — como cancelar zera o lock_token no banco, um worker atrasado não bate mais nenhuma linha e não sobrescreve o cancelamento.

Timeout de SMTP (causa de campanha travada)

Incidente real em 2026-08-21: uma campanha ficou travada em processing por vários minutos sem avançar. Causa raiz: EnviarEmailsEmLoteSMTP() (em inc_functions.php) não tinha timeout configurado no PHPMailer (default 300s+), e o cron só libera o lock da campanha depois que a função de envio retorna — então um único destinatário com resposta SMTP lenta trava a fila inteira. Corrigido com $mail->Timeout = 20; $mail->SMTPKeepAlive = false;. Em 2026-08-21 a mesma correção foi replicada nas outras três funções de envio transacional com o mesmo padrão (SendEmailTransacional, SendEmailTransacionalSes, SendAppComunicado) — cobertura completa dos pontos que abrem conexão SMTP síncrona em inc_functions.php.

Corrigido em 2026-08-21: --test era estruturalmente mais propenso a travar que envio real, porque mandava pra um endereço fixo do mail-tester.com ($test_email em inc_config.php, provavelmente uma caixa expirada — essas são de uso único) somado a $user_email vindo de $_SESSION['email'], vazio em contexto de CLI/cron (sem sessão). cron_comunicados_queue.php — único ponto de entrada que de fato dispara o email, sempre via CLI — agora sobrescreve os dois globais para tech@reorganize.com.br logo no início, então qualquer envio --test cai nessa caixa real de exceção em vez de mail-tester.com/endereço vazio.

Fluxo

  1. Definir o conteúdo do email: pode vir de um template vivo no KB (caso estruturado, ex.: kb/products/comunidade-diretoras-aula-semanal.md) ou ser escrito na hora pra esse envio específico (caso não estruturado).
  2. Confirmar product_slug da audiência-alvo.
  3. Rodar o script sem --commit (dry-run) e mostrar a contagem/exemplos pra Hugo.
  4. Hugo confirma conteúdo e audiência.
  5. Hugo confirma conteúdo e audiência (ou pede --test primeiro) e autoriza no chat — a IA pode rodar o --commit ela mesma com essa autorização explícita, por envio (não generaliza pra próximos envios). Ver regra completa em rz-app/kb/operations/procedimento-envio-comunicados-por-ia.md.
  6. Depois do commit, checar status da fila/campanha é permitido (leitura).

Casos de uso conhecidos

Erros a evitar

Relação com outras skills

Status

Base genérica criada em 2026-08-20, a partir do script já testado em produção no comunicado semanal da Diretora (366 destinatários, 0 erros). Em 2026-08-21 ganhou agendamento (--scheduled-at), paginação de audiência acima de 2000, painel de cancelar/reiniciar envio (com o bug de race condition do cancelamento corrigido), correção de travamento por timeout de SMTP, e correção do endereço de teste em envios --test via CLI — testado em produção no envio de confirmação ICDB Rio (33 + 10 destinatários, 0 erros).