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).
- Audiência:
POST https://app.reorganize.com.br/api/v1/audience— token em.secrets/reorganize_api_credentials.jsonou envREORGANIZE_API_TOKEN. - Fila/campanha: tabelas
custom_appreorganize_messages_queueecustom_appreorganize_messages_campaigns. - Envio de fato:
cron/cron_comunicados_queue.php, já rodando via crontab no servidor. - Deploy: push em
masterno reporeorganize-webappjá auto-deploya via GitHub Actions (.github/workflows/deploy.yml, criado 2026-08-20) — não precisa mais rodarscripts/deploy-vps.shmanualmente para código que já está commitado e pushado.
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]
- Sem
--commit: dry-run — só mostra quantos destinatários existiriam. Nada é gravado. - Com
--commit: grava a fila e agenda a campanha (status=scheduled,scheduled_datetime=agora) para o cron existente enviar de verdade. --message-typedefaultacademica(audiência person+product pura, sem marketing). Paramarketing, ocontent-fileprecisa ter uma ação clara de parar de receber (regra AGENTS.md) — o script avisa se não detectar isso, mas não bloqueia.--sender-name/--sender-emaildefaultReorganize/reorganize@app.reorganize.com.br. Trocar quando o remetente certo for outra pessoa (ex.: Priscila Sabóia para comunicados da comunidade dela).- Audiência tem dois modos:
- Com
--product:source=person+category=product(clientes daquele produto).--ddd=<código>opcional, funciona como interseção (só quem tem esse DDD dentro da audiência do produto). - Sem
--product:--ddd=<código>(obrigatório) +--sources=person,lead[,prospect](opcional, defaultperson,lead) resolve "qualquer humano com esse DDD, sem produto nenhum" — une clientes (person) e leads de campanha (lead);prospect(carrinho abandonado,CRM_recuperacao) só entra se pedido explicitamente. Implementado emComunicadosFetchAudienceByDdd()(inc_functions.php), reaproveitando o caminho "all sources" que a API de audiência já expõe quandoselectionsvem vazio — mesmas proteções de compliance (email_send_permission, exclusão de unsubscribers) de sempre.
- Com
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:
- Combinado com
--product— é interseção (não union) em cima da audiência person+product já resolvida:--product=mrz3-presencial --ddd=21manda só pra quem comprou aquele produto E tem telefone do DDD 21. - Sem
--product, junto com--sources— modo "qualquer humano com esse DDD", sem produto nenhum. Usa o caminho all-sources da API de audiência (person+lead+prospect unidos, já com as proteções de compliance de cada fonte) filtrado client-side por--sources:
--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.
UTM: o CLI não reescreve link
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.
- Antes do
--commit(pedindo OK): publicar uma prévia do email como Artifact (HTML), envolvendo o conteúdo no mesmo wrapper visual do envio real (div.reorganize_email_style,font-family: Arial,max-width: 600px— verSendAppComunicado()eminc_functions.php), e mandar o link do Artifact junto com a pergunta de confirmação. - Depois do
--commit: mandar o link de acompanhamentoadmin_comunicados_queue.php?campaign_slug=...(regra que já existia, reforçada aqui).
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:
- Cancelar envio — visível em
draft/scheduled/processing, marca a campanha comocanceled(esse status nunca bate noWHEREdo cron, então ela para de ser processada). - Iniciar envio — visível só quando
canceled, volta prascheduledcomscheduled_datetime = agora(reinicia imediatamente, não retoma o horário antigo).
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
- 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). - Confirmar
product_slugda audiência-alvo. - Rodar o script sem
--commit(dry-run) e mostrar a contagem/exemplos pra Hugo. - Hugo confirma conteúdo e audiência.
- Hugo confirma conteúdo e audiência (ou pede
--testprimeiro) e autoriza no chat — a IA pode rodar o--commitela mesma com essa autorização explícita, por envio (não generaliza pra próximos envios). Ver regra completa emrz-app/kb/operations/procedimento-envio-comunicados-por-ia.md. - Depois do commit, checar status da fila/campanha é permitido (leitura).
Casos de uso conhecidos
- Comunicado semanal de aula (Diretora, MRZ) — skill específica
comunicado-semanal-aula, que usa este script por baixo com o template fixo do produto. - Comunicado de venda de ingresso ICDB de quarta-feira, filtrado por DDD da praça (leads+clientes, sem produto) — skill específica
icdb-comunicado-venda-ingresso-quarta. - Qualquer aviso pontual por email pra audiência de um produto — usar este fluxo genérico diretamente, sem precisar de template/skill dedicada.
Erros a evitar
- Não rodar
--commitcontra produção sem autorização explícita do Hugo no chat pra aquele envio específico — regra de governança emprocedimento-envio-comunicados-por-ia.md. - Não usar
/private/tmpou scripts descartáveis — o script é este, versionado emreorganize-webapp. - Não inventar
product_slug— mas também não perguntar de cara: resolver viaGET /api/v1/products?q=...(ver seção acima) primeiro; só perguntar a Hugo se a busca não achar candidato claro, ou consultarcustom_appreorganize_subscription_consolidada. - Em campanha
marketing, não esquecer a ação de parar de receber no conteúdo.
Relação com outras skills
comunicado-semanal-aula-skillé um caso de uso específico construído em cima desta base (template fixo + cronograma da Diretora/MRZ).procedimento-comunicado-confirmacao-ingresso-icdb.mdé outro caso de uso (confirmação de ingresso ICDB), com dados de edição buscados na API de editions em vez de hardcoded.- Regra de governança (IA pode rodar
--commitcom autorização explícita do Hugo no chat, por envio):procedimento-envio-comunicados-por-ia.md.
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).