Job diário de emails da régua de trial por percentual consumido
TLDR: um job Celery diário às 10h envia os emails da régua de trial que estiverem devidos, escolhendo pelo percentual do período já consumido (10/30/50/70%) para os de conteúdo e pela data de expiração para os de conversão e pós-trial, sem repetir nenhum template no mesmo ciclo e sem deixar nenhum para trás.
Contexto
Os templates da régua de comunicação de trial já existem em apps/emails/templates/emails/, mas nenhum deles nunca foi enviado: cta_url não é passado por nenhum código do projeto — a variável só aparece no mock de preview (apps/emails/views.py:14, valor "#"). Hoje o único email de trial que sai de verdade é o de boas-vindas (send_trial_welcome, disparado em apps/trials/services/user_trial.py:93) e o de rejeição de cadastro (send_trial_negative_response, R-014).
O modelo Trial já tem o gancho para essa feature: active_comunication_email (apps/trials/models/trial.py:52) existe com o help_text “Ative para que os usuários deste Trial recebam os e-mails cadastrados”, mas nenhuma lógica lê esse campo. UserTrial já guarda started_at, expires_at e converted_at, que é tudo o que o cálculo precisa.
Como Trial.duration_days é configurável por trial (o seed tem trials de 7, 14 e 30 dias), a régua não pode ser ancorada em dias fixos: precisa ser proporcional à duração para que o mesmo desenho de comunicação sirva um trial de 7 e um de 30 dias.
Objetivos
- Criar um job Celery diário, às 10h (horário de Brasília), que envia os 6 emails da régua de trial.
- Escolher o email pelo percentual do período de trial já consumido, para que a régua se adapte a qualquer
duration_days. - Garantir que cada template seja enviado no máximo uma vez por
UserTrial(ciclo), com a garantia no banco e não só no código. - Parar todos os envios assim que a assinatura for confirmada.
- Respeitar o gate
Trial.active_comunication_email. - Não deixar nenhum email da régua para trás: quando mais de um marco fica devido no mesmo dia — o que acontece em trials curtos, onde o percentual anda muito por dia — todos são enviados naquele dia, em ordem.
- Manter o histórico de qual email cada usuário recebeu e quando, consultável para suporte.
Fora de escopo
- Não altera nenhum template de email. Os arquivos em
apps/emails/templates/emails/ficam exatamente como estão, incluindo ohref="#"dechat_ai.htmle a duplicaçãoaudio_diario.html/daily_audio.html. - Não envia o email de boas-vindas — ele já sai no cadastro e continua fora do job.
- Não cria rotas de deep link nem universal links para os CTAs de conteúdo.
- Não cria opt-out / descadastro de email (não existe no projeto hoje).
- Não altera o fluxo de conversão (
converted_at, webhook de checkout) nemUserTrial. - Não cria régua configurável por Trial no admin — a régua é uma constante no código.
A régua
Seis emails, todos enviados pelo mesmo job. O de boas-vindas (trial_welcome.html) não entra: já é enviado no cadastro.
| Ordem | email_key |
Template | Âncora | Marco | Subject |
|---|---|---|---|---|---|
| 2 | daily_audio |
emails/daily_audio.html |
started_at |
10% | Áudio diário | Onion |
| 3 | normose |
emails/normose.html |
started_at |
30% | Conheça a normose |
| 4 | habits |
emails/habits.html |
started_at |
50% | Micro-hábitos |
| 5 | chat_ai |
emails/chat_ai.html |
started_at |
70% | Conheça a Nathalia |
| 6 | plan_ended |
emails/plan_ended.html |
expires_at |
dia da expiração | Sua jornada continua |
| 7 | patern_scan |
emails/patern_scan.html |
expires_at |
+5 dias | Varredura de Padrões |
daily_audio.html foi escolhido em vez de audio_diario.html (mesmo email, dois arquivos) por usar {% static %} na imagem de header. audio_diario.html fica órfão no repo e não é removido nesta mudança.
Por que 10/30/50/70
UserTrial.started_at é timezone.now() no momento do cadastro (apps/trials/services/user_trial.py:63), então carrega a hora — a normalização de meia-noite do spec 20260824135425_trial_dates_without_time.md vale só para Trial.start_date/end_date, não para o UserTrial. O percentual no dia N depende da hora em que a pessoa se cadastrou, e o pior caso é o cadastro pouco antes das 10h, que perde a primeira execução do job.
Como o job envia todos os marcos devidos, nenhum conjunto de percentuais perde email — 20/40/60/80 também entregaria os seis. A escolha de 10/30/50/70 é por distribuição, não por cobertura: ela adianta o primeiro contato e evita que o último marco de conteúdo colida com o email de conversão. Num trial de 7 dias cadastrado às 9h59, com 20/40/60/80 o marco de 80% só é atingido no dia da expiração e sairia junto com o plan_ended; com 10/30/50/70 o marco de 70% sai no dia 6 e a conversão fica sozinha no dia 7.
| Duração | Cadastro 09h59 (pior caso) | Cadastro 14h (típico) |
|---|---|---|
| 7 dias | dias 2, 4, 5, 6 + conversão dia 7 | dias 1, 3, 4, 6 + conversão dia 7 |
| 14 dias | todos entregues | dias 2, 5, 8, 11 + conversão dia 14 |
| 30 dias | todos entregues | dias 3, 9, 15, 21 + conversão dia 30 |
Garantia: todos os emails da régua são entregues em qualquer duração de trial e qualquer hora de cadastro. Como o job envia todos os marcos devidos de uma vez, e não um por execução, não existe mais o limite de “um email por dia” que descartaria marcos em trial curto.
Num trial de 3 dias o percentual anda ~33% ao dia, então o dia 2 cruza os marcos de 30% e 50% juntos e os dois saem no mesmo dia; no dia da expiração, um marco de conteúdo ainda pendente sai junto com o email de conversão. É o comportamento desejado — nenhum conteúdo se perde. Em trials de 7 dias ou mais isso praticamente não ocorre: o percentual anda ~14% ao dia contra um espaçamento de 20 pontos entre marcos, então na prática sai no máximo um email por dia sem que seja preciso impor essa regra.
Por que os emails 6 e 7 não são percentuais
O email de conversão não pode ser o marco de 100%: às 10h do dia da expiração o percentual é ~97%, não 100%, porque expires_at carrega a hora do cadastro. E “5 dias após expirar” não é um percentual constante — daria 150% num trial de 10 dias e 200% num de 5.
Os dois são ancorados em data de expiração:
plan_ended—localdate(expires_at) == hojepatern_scan—localdate(expires_at) == hoje - 5 dias
Isso torna o D+5 uma linha de filtro em vez de uma task agendada com countdown. É deliberado: uma task com ETA de 5 dias se perde em restart do RabbitMQ e em redeploy, exige revoke() para cancelar quando o usuário assina, e não é reprocessável se falhar. A varredura diária sobrevive a tudo isso e cancelar é só o filtro de conversão.
Mudanças
apps/emails/models/email_dispatch.py (novo)
O histórico de envio é genérico desde o início — não fica em apps/trials, e sim em apps/emails, o app dono de SendEmails, tasks.py e templates. Isso permite que qualquer régua futura (não só trial) registre envio na mesma tabela, com um campo source identificando o sistema de origem.
```python class EmailDispatch(BaseModel): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name=”email_dispatches”) source = models.CharField(max_length=32) email_key = models.CharField(max_length=32) sent_at = models.DateTimeField()
class Meta:
unique_together = ("user", "source", "email_key") ```
source é um CharField livre, sem choices fixas em apps/emails — cadastrar um novo sistema de comunicação não exige editar esse model. A convenção fica em cada app dono do envio, que define sua própria constante e importa onde for gravar, em vez de espalhar string solta. Pra trial, a constante é TRIAL_EMAIL_SOURCE = "trial" em apps/trials/services/trial_journey.py.
A unique_together é a garantia real de “cada template uma vez por ciclo” — protege contra retry do BaseTaskWithRetry, beat duplicado e redeploy, que um if no código não cobre. A chave é (user, source, email_key) em vez de (user_trial, email_key): como não existe lógica de renovação de trial no código (Trial.allow_renewal é um campo sem uso — apps/trials/models/trial.py:35), um usuário nunca tem mais de um UserTrial ativo gerando o mesmo email_key em ciclos diferentes, então identificar por user direto é suficiente. Não há GenericForeignKey nem campo de referência de ciclo — YAGNI enquanto essa renovação não existir de fato.
apps/trials/models/__init__.py
Sem mudança — o histórico de envio não é um model de trials.
apps/emails/migrations/0001_emaildispatch.py (novo)
CreateModel com a constraint unique_together. Primeira migration do app emails, que hoje não tem models.py. Sem data migration — trials em curso simplesmente entram na régua a partir do marco atual.
apps/trials/services/trial_journey.py (novo)
Concentra a régua e a decisão. Sem acesso a rede, para ser testável direto.
TRIAL_JOURNEY— a constante com os 6 itens (key, template, subject, âncora, marco).TRIAL_JOURNEY_CTA— mapa deemail_keypara CTA.plan_endedepatern_scanusamtrial.checkout_url(já existe e é obrigatório no cadastro); os quatro de conteúdo usamDOWNLOAD_PAGE_URLderoutes/download.pycomo placeholder até as rotas reais existirem, com comentário explícito.DownloadRedirectViewjá redireciona por user-agent para a loja certa, então o botão funciona desde o primeiro envio. Trocar depois é uma linha, sem tocar em template.eligible_user_trials()— a queryset do job.resolve_pending_emails(user_trial, now)— devolve a lista deemail_keydevidos hoje, na ordem da régua.
Queryset:
python
UserTrial.objects.select_related("user", "trial").filter(
trial__active_comunication_email=True,
converted_at__isnull=True,
user__subscription_status__iexact=User.SUBSCRIPTION_STATUS_TRIAL,
started_at__lte=now,
expires_at__date__gte=today - timedelta(days=PATTERN_SCAN_DELAY_DAYS),
).exclude(user__email="")
O filtro duplo de conversão é intencional. UserTrial.converted_at sozinho não basta: segundo R-012, ele só é preenchido no fluxo de webhook para usuário já existente (user_created is False). user.subscription_status é a rede de segurança, porque user.activate() troca o status para enabled em toda conversão. É o que cumpre “interromper os próximos disparos assim que a assinatura for confirmada” e “não enviar a conversão do último dia nem o pós-trial a quem assinou”. A comparação é __iexact porque User.is_trial também ignora caixa (R-015).
O corte por expires_at implementa “acabou a régua, não envia mais nada”: o email mais tardio é o D+5, então passado isso o UserTrial nunca mais tem o que enviar e sai da varredura. Sem esse corte o job varreria todo UserTrial que já existiu, todo dia, 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. É uma decisão deliberada e consistente com o resto do projeto — nenhum outro job periódico (enqueue_daily_audio_push, send_habit_reminders, sync_analytics_snapshots) compensa dias perdidos, e blindar só este email criaria uma exceção sem contrapartida real: o cenário exige o Celery Beat parado por dias, um incidente de infra que já afeta o sistema inteiro.
Resolução dos emails devidos hoje — resolve_pending_emails devolve uma lista de email_key, na ordem da régua, com tudo o que está devido e ainda não foi enviado:
- os marcos percentuais 10/30/50/70 cujo
pct >= marco, em ordem crescente plan_ended— selocaldate(expires_at) <= todaypatern_scan— selocaldate(expires_at) <= today - 5 dias
Pendente significa “sem registro em EmailDispatch com source=TRIAL_EMAIL_SOURCE”. O percentual é (now - started_at) / (expires_at - started_at) * 100. Trial.duration_days é PositiveIntegerField e aceita 0, o que tornaria expires_at == started_at e a divisão indefinida — nesse caso os marcos percentuais são omitidos da lista, deixando apenas os emails ancorados em data.
Devolver todos os pendentes, e não apenas o primeiro, é o que garante que nenhum email da régua se perca. Em trial curto o percentual anda muito por dia e mais de um marco fica devido ao mesmo tempo; todos saem, na ordem da narrativa — áudio diário, normose, micro-hábitos, Nathalia. No dia da expiração, um marco de conteúdo ainda pendente sai junto com o email de conversão em vez de ser descartado.
As comparações de data usam <= e não == de propósito: se o job falhar num dia, ou o UserTrial entrar na régua atrasado, o email ainda é recuperado na execução seguinte em vez de perder a janela para sempre.
apps/trials/tasks.py (novo)
python
@app.task(base=BaseTaskWithRetry, queue=resolve_queue("default"))
def send_trial_journey_emails():
Itera eligible_user_trials(), chama resolve_pending_emails para cada um e faz fan-out com send_trial_journey_email.delay(...) para cada email devido. Não envia inline: um SMTP lento travaria o lote inteiro e o retry do BaseTaskWithRetry reprocessaria todos os usuários. Retorna um dict com contagem por email_key, seguindo o padrão de enqueue_daily_audio_push.
A reserva do dispatch acontece aqui, antes de enfileirar, não na task de envio:
python
for key in resolve_pending_emails(user_trial, now):
_, created = EmailDispatch.objects.get_or_create(
user=user_trial.user, source=TRIAL_EMAIL_SOURCE, email_key=key, defaults={"sent_at": now}
)
if created:
send_trial_journey_email.delay(...)
Gravar antes é deliberado. Se o dispatch fosse gravado só depois do envio, duas execuções próximas do job (beat duplicado, retry do orquestrador, redeploy) fariam fan-out em duplicidade antes de qualquer linha existir, e o usuário receberia o mesmo email duas vezes — a unique_together só barraria a segunda gravação, com o email já entregue. Reservando antes, a constraint serializa antes do envio e o pior caso vira um email não enviado em vez de um email duplicado, que é a troca certa para comunicação de marketing.
O risco assumido é que uma falha definitiva de SMTP deixe o dispatch gravado sem email enviado. O BaseTaskWithRetry já dá 3 retries com backoff até 10 minutos, então isso exige indisponibilidade prolongada; nesse caso o email daquele marco é perdido e a régua segue no marco seguinte.
Quando settings.SEND_EMAIL é False, o orquestrador retorna cedo sem varrer nada — assim nenhum dispatch é reservado em ambiente com envio desligado, e os usuários não perdem os emails quando ele for religado.
apps/emails/tasks.py
Nova task send_trial_journey_email(data: dict), wrapper fino de SendEmails.trial_journey(data), no mesmo padrão das existentes. Recebe já resolvidos to, subject, template_name e cta_url — não consulta o banco nem decide nada.
apps/emails/send_emails.py
Um único método trial_journey(data), parametrizado por template_name e subject, em vez de seis métodos quase idênticos. Monta o email_config no mesmo formato dos existentes.
O contexto passado ao template é apenas {"name": ..., "cta_url": ...}. Nenhum template da régua pede preço, prazo ou data, e o job não passa days_left nem expires_at — é o que cumpre “preço, formas de pagamento, prazo e condição vigente ficam no checkout” e “não informar preço ou data que não estejam garantidos” por construção, sem depender de revisão de template.
config/celery_defaults.py
Uma entrada em CELERY_BEAT_SCHEDULE:
python
'send-trial-journey-emails': {
'task': 'apps.trials.tasks.send_trial_journey_emails',
'schedule': crontab(hour=10, minute=0),
},
CELERY_TIMEZONE já é America/Sao_Paulo (linha 10), então hour=10 é 10h de Brasília sem conversão.
tests/trials/test_trial_journey.py (novo)
- Percentual resolve o marco certo para trials de 5, 7, 14 e 30 dias, incluindo o pior caso de cadastro às 9h59.
- Dois marcos cruzados no mesmo dia devolvem os dois, em ordem crescente.
- Trial de 3 dias entrega os 6 emails da régua, mesmo com mais de um saindo no mesmo dia.
plan_endedsai junto com um marco de conteúdo ainda pendente no dia da expiração.patern_scanexatamente em D+5 e em nenhum outro dia.- Nenhum email quando
active_comunication_emailéFalse. - Nenhum email quando
converted_atestá preenchido. - Nenhum email quando
subscription_statusdeixou de sertrial, mesmo comconverted_atnulo (o furo do R-012). UserTrialfora da janela D+5 não entra na queryset.- Reexecução do job no mesmo dia não reenvia (
unique_together). - Com
SEND_EMAIL=FalsenenhumEmailDispatché criado.
tests/trials/test_tasks.py (novo)
Task orquestradora faz fan-out uma vez por usuário elegível, com send_trial_journey_email.delay mockado.
tests/emails/test_email_dispatch.py (novo)
Unicidade (user, source, email_key): mesmo user + source + email_key duas vezes levanta IntegrityError; email_key ou source diferentes não colidem.
Como verificar
make migrateaplica0001_emaildispatch(appemails) sem erro.make test— suíte de trials e emails verde, incluindo os novos testes.- Em
shell_plus, com umUserTrialde 7 dias criado há 1 dia e o Trial comactive_comunication_email=True, chamarresolve_pending_emailsdevolve["daily_audio"]; após gravar o dispatch, devolve[]. - Rodar
send_trial_journey_emails()manualmente comSEND_EMAIL=Trueem staging e confirmar no inbox que o email chega renderizado e com o botão apontando paraDOWNLOAD_PAGE_URL(emails 2–5) ou paratrial.checkout_url(emails 6 e 7). - Rodar a task duas vezes seguidas e confirmar que o segundo envio não acontece.
- Marcar
converted_atnumUserTriale confirmar que ele sai da queryset. - Confirmar no RabbitMQ que a task aparece na fila
onion-{env}e que o beat a agenda às 10h.
Antes de ligar em produção: nenhum dos 6 templates nunca passou pelo SMTP. Enviar os seis manualmente em staging e revisar a renderização (imagens de header via {% static %}, botão, assunto) antes de registrar o job no beat.
Documentação
- Criar
.project/docs/rules/trials/trial_journey_emails.mdcom a regra de negócio: a régua, os marcos, a resolução por lista de pendentes, as condições de parada (conversão, fim da régua, janela D+5) e a garantia de que nenhum email da régua se perde, inclusive em trial curto onde mais de um sai no mesmo dia. - Atualizar
.project/docs/README.mdcom o novo doc de regra.
Pendências conhecidas
Nenhuma bloqueia a implementação, mas ficam registradas:
- CTAs de conteúdo — os emails 2–5 apontam para
DOWNLOAD_PAGE_URLaté o time mobile definir os deep links ou universal links de cada conteúdo. O único deep link que existe no projeto éonionapp://habits(apps/habits/services.py:310), e o esquemaonionapp://não abre em email aberto no desktop ou em webmail, então a substituição precisa ser por link https. chat_ai.htmltemhref="#"hardcoded e ignoracta_url— o email 5 sai sem destino até o template ser corrigido, o que está fora do escopo desta mudança.audio_diario.htmlfica órfão no repo após a escolha dedaily_audio.html.- Não existe opt-out de email no projeto — a régua não tem descadastro.