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_emailsdevolve 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_ateUser.subscription_status.converted_atsozinho 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 comconverted_atnulo.subscription_statusé a rede de segurança:user.activate()o troca paraenabledem toda conversão, e é isso que corta os envios de fato. - A elegibilidade para o job (
eligible_user_trials) é restrita a uma janela:UserTrialcujoexpires_atjá 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 quepatern_scanfica 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 paraTrial.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 emEmailDispatché feita em blocos de até 200 emails pendentes por vez (uma query por bloco, viaON 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
UserTrialpela própria query de elegibilidade. Quem converteu entre o enfileiramento e a execução do lote não recebe o email. Pelo mesmo motivo oSEND_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