R-021 — Régua de emails de trial por percentual consumido

TLDR: um job Celery diário às 10h envia os emails da régua de trial que estiverem devidos — percentual consumido para os quatro de conteúdo, data de expiração para os dois finais — sem repetir nenhum no mesmo ciclo e sem deixar nenhum para trás, mesmo quando mais de um cai no mesmo dia.

Given / When / Then

Dado um UserTrial com trial.active_comunication_email=True, converted_at nulo e user.subscription_status="trial" Quando o job diário roda e o percentual do trial já consumido atinge um marco (10/30/50/70%) ainda não enviado Então o email de conteúdo daquele marco é enviado e registrado em EmailDispatch (com source="trial")

Dado o mesmo UserTrial Quando expires_at cai hoje ou antes Então o email de conversão (plan_ended) é enviado, mesmo que ainda haja marco de conteúdo pendente — os dois saem juntos

Dado o mesmo UserTrial Quando expires_at foi há 5 dias ou mais Então o email de varredura de padrões (patern_scan) é enviado

Dado um UserTrial de trial curto (ex.: 3 dias), onde o percentual anda muito por execução Quando o job roda e mais de um marco está devido ao mesmo tempo Então todos os pendentes são enviados na mesma execução, na ordem da régua — nenhum é descartado

Dado um UserTrial com converted_at preenchido, ou cujo user.subscription_status deixou de ser "trial" Quando o job roda Então nenhum email é enviado, mesmo que algum marco esteja matematicamente atingido

Dado um UserTrial cujo trial.active_comunication_email é False Quando o job roda Então nenhum email é enviado

Dado um email já registrado em EmailDispatch (source="trial") para aquele usuário Quando o job roda novamente (mesmo dia ou dias depois) Então aquele email não é reenviado

Tabela da régua

Ordem email_key Template Âncora Marco
1 daily_audio emails/daily_audio.html started_at 10%
2 normose emails/normose.html started_at 30%
3 habits emails/habits.html started_at 50%
4 chat_ai emails/chat_ai.html started_at 70%
5 plan_ended emails/plan_ended.html expires_at dia da expiração
6 patern_scan emails/patern_scan.html expires_at +5 dias

O email de boas-vindas (trial_welcome.html) não faz parte desta régua — é enviado no cadastro do trial, fora do job.

Restrições

  • resolve_pending_emails devolve todos os emails devidos numa execução, não apenas o primeiro. Isso é deliberado: um trial de poucos dias faz o percentual andar rápido, e restringir a um email por execução descartaria conteúdo da régua. A régua nunca perde email — na pior das hipóteses, mais de um sai no mesmo dia.
  • As comparações de data usam “menor ou igual”, não “igual”. Uma execução do job perdida (falha, deploy) é recuperada na execução seguinte em vez de perder a janela do email para sempre.
  • O filtro de parada por conversão usa dois campos: UserTrial.converted_at e User.subscription_status. converted_at sozinho não basta — ele só é preenchido no fluxo de webhook para usuário já existente (R-012), então um usuário novo que assina durante o trial pode ficar com converted_at nulo. subscription_status é a rede de segurança: user.activate() o troca para enabled em toda conversão, e é isso que corta os envios de fato.
  • A elegibilidade para o job (eligible_user_trials) é restrita a uma janela: UserTrial cujo expires_at já passou de D+5 sai da varredura e nunca mais recebe email algum, mesmo que algum marco não tenha sido enviado. Isso mantém a query limitada a trials recentes em vez de crescer para sempre. Não há margem de recuperação além disso — se o job falhar exatamente no dia em que patern_scan fica devido, aquele email é perdido quando a janela fechar. É consistente com o resto do projeto: nenhum outro job periódico (enqueue_daily_audio_push, send_habit_reminders, sync_analytics_snapshots) compensa dias perdidos.
  • Os quatro emails de conteúdo apontam para DOWNLOAD_PAGE_URL (routes/download.py) como CTA — placeholder até existirem deep links ou universal links por conteúdo. Os dois últimos (plan_ended, patern_scan) apontam para Trial.checkout_url.
  • O contexto passado ao template é só {"name", "cta_url"} — nenhum preço, prazo ou data de oferta. Preço, formas de pagamento e condição vigente ficam exclusivamente no checkout.
  • Nenhum template em apps/emails/templates/emails/ é alterado por esta regra.
  • O job roda em dois níveis: a task do beat só fatia os elegíveis em lotes de 200 ids e delega cada lote para send_trial_journey_emails_batch, que roda em paralelo entre os workers. Dentro de um lote, a reserva em EmailDispatch é feita em blocos de até 200 emails pendentes por vez (uma query por bloco, via ON CONFLICT DO NOTHING RETURNING), sempre antes de enfileirar aquele bloco — por isso o retry automático de um lote não reenvia o que já foi reservado. O efeito colateral é que, se a task morrer durante o enfileiramento de um bloco já reservado, os emails desse bloco que ainda não tinham sido enfileirados ficam marcados como enviados sem terem sido — o dano fica limitado ao tamanho do bloco em andamento, nunca ao lote inteiro.
  • O lote não confia nos ids que recebeu: ele recarrega os UserTrial pela própria query de elegibilidade. Quem converteu entre o enfileiramento e a execução do lote não recebe o email. Pelo mesmo motivo o SEND_EMAIL é checado nos dois níveis: desligar o flag interrompe também os lotes que já estavam na fila.

Teste vinculado

tests/trials/test_trial_journey.py, tests/trials/test_tasks.py, tests/trials/test_beat_schedule.py, tests/emails/test_trial_journey_email.py, tests/emails/test_email_dispatch.py

Referências