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 o href="#" de chat_ai.html e a duplicação audio_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) nem UserTrial.
  • 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) == hoje
  • patern_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 de email_key para CTA. plan_ended e patern_scan usam trial.checkout_url (já existe e é obrigatório no cadastro); os quatro de conteúdo usam DOWNLOAD_PAGE_URL de routes/download.py como placeholder até as rotas reais existirem, com comentário explícito. DownloadRedirectView já 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 de email_key devidos 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:

  1. os marcos percentuais 10/30/50/70 cujo pct >= marco, em ordem crescente
  2. plan_ended — se localdate(expires_at) <= today
  3. patern_scan — se localdate(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_ended sai junto com um marco de conteúdo ainda pendente no dia da expiração.
  • patern_scan exatamente em D+5 e em nenhum outro dia.
  • Nenhum email quando active_comunication_email é False.
  • Nenhum email quando converted_at está preenchido.
  • Nenhum email quando subscription_status deixou de ser trial, mesmo com converted_at nulo (o furo do R-012).
  • UserTrial fora da janela D+5 não entra na queryset.
  • Reexecução do job no mesmo dia não reenvia (unique_together).
  • Com SEND_EMAIL=False nenhum EmailDispatch é 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

  1. make migrate aplica 0001_emaildispatch (app emails) sem erro.
  2. make test — suíte de trials e emails verde, incluindo os novos testes.
  3. Em shell_plus, com um UserTrial de 7 dias criado há 1 dia e o Trial com active_comunication_email=True, chamar resolve_pending_emails devolve ["daily_audio"]; após gravar o dispatch, devolve [].
  4. Rodar send_trial_journey_emails() manualmente com SEND_EMAIL=True em staging e confirmar no inbox que o email chega renderizado e com o botão apontando para DOWNLOAD_PAGE_URL (emails 2–5) ou para trial.checkout_url (emails 6 e 7).
  5. Rodar a task duas vezes seguidas e confirmar que o segundo envio não acontece.
  6. Marcar converted_at num UserTrial e confirmar que ele sai da queryset.
  7. 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.md com 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.md com 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_URL até 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 esquema onionapp:// não abre em email aberto no desktop ou em webmail, então a substituição precisa ser por link https.
  • chat_ai.html tem href="#" hardcoded e ignora cta_url — o email 5 sai sem destino até o template ser corrigido, o que está fora do escopo desta mudança.
  • audio_diario.html fica órfão no repo após a escolha de daily_audio.html.
  • Não existe opt-out de email no projeto — a régua não tem descadastro.