diff --git a/.env.example b/.env.example index f337aad..c7cf023 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,14 @@ HEADLESS=false VIEWPORT_WIDTH=1280 VIEWPORT_HEIGHT=800 +# E-mail (módulo de e-mail transacional) +# EMAIL_API_KEY vazio => provider no-op (apenas loga, não envia). Preencha com a chave da Resend para enviar de verdade. +EMAIL_API_KEY= +EMAIL_FROM_ADDRESS= +EMAIL_FROM_NAME= +# Tentativas de reenvio na fila de e-mail (backoff exponencial). Default: 3. +EMAIL_QUEUE_ATTEMPTS=3 + # Third-party API credentials ADZUNA_APP_ID= ADZUNA_APP_KEY= diff --git a/.specs/STATE.md b/.specs/STATE.md new file mode 100644 index 0000000..5c733b1 --- /dev/null +++ b/.specs/STATE.md @@ -0,0 +1,20 @@ +# Project State + +## Decisions + +Active project-level architectural decisions (AD-NNN). Each design must conform or explicitly supersede. + +| ID | Decision | Status | Source | +| --- | --- | --- | --- | +| AD-001 | E-mails transacionais são enviados via módulo centralizado `src/modules/email` — nenhum outro módulo chama provedor de e-mail diretamente. | active | PAV-76 | +| AD-002 | Envio de e-mail é assíncrono via fila BullMQ sobre Valkey (conexão ioredis dedicada). Callers apenas enfileiram; nunca bloqueiam nem falham por causa de e-mail. | active | PAV-76 | +| AD-003 | Provedor de e-mail acessado por trás da interface `MailProvider`. Implementação inicial: Resend. Trocar de provedor não altera código chamador. | active | PAV-76 | +| AD-004 | Templates de e-mail são componentes react-email tipados, renderizados server-side para HTML. Versões `react`/`react-dom`/`@types/react` fixadas em 19.x para casar com o frontend. | active | PAV-76 | + +## Handoff + +**Feature concluída:** `email-module` (PAV-76) — ✅ Done. +**Estado:** 11/11 tasks implementadas e commitadas na branch `feature/pav-76-modulo-email` (Batch A: 5 commits; Batch B: 6 commits). Verifier PASS (11/11 ACs, gate 529/0, sensor 5/5 mutantes mortos). Relatório em `.specs/features/email-module/validation.md`. +**Entregue:** API interna `emailService.send/sendWelcome`, fila BullMQ/Valkey (ioredis), worker in-process, `MailProvider`+Resend+Noop, template `welcome` react-email com CTA (`FRONTEND_URL`), boas-vindas no registro (`CredentialsService.register`), docs no `BACKEND.md`. +**Pendências deixadas ao usuário:** (1) push + PR ainda NÃO feitos (a pedido); (2) envs de produção `EMAIL_API_KEY`/`EMAIL_FROM_ADDRESS`/`EMAIL_FROM_NAME` a comunicar ao dev quando for pra prod — sem elas o `NoopProvider` só loga. +**Próximo passo:** quando o usuário pedir, abrir PR da PAV-76. diff --git a/.specs/features/email-module/context.md b/.specs/features/email-module/context.md new file mode 100644 index 0000000..9553570 --- /dev/null +++ b/.specs/features/email-module/context.md @@ -0,0 +1,17 @@ +# Email Module — User Decisions (context) + +Captured during the discuss phase of PAV-76. These constrain the design. + +| Área | Decisão do usuário | Detalhe | +| --- | --- | --- | +| Provedor | **Resend** como primeira implementação | Atrás de interface `MailProvider` para troca futura barata. | +| Estratégia de envio | **Fila de jobs (BullMQ)** | Usuário quer sistema de jobs. Projeto usava Redis; hoje usa Valkey. Verificado: BullMQ é 100% compatível com Valkey via ioredis. | +| Templates | **react-email** | ⚠️ Fixar versões de `react`/`react-dom`/`@types/react` em **19.x** para casar com o frontend (React 19.2.7) e não quebrar o build. | +| Persistência | **Somente logar** (sem tabela) | Sem `email_logs` no MVP. Auditoria em DB fica como evolução futura. | + +## Verificações técnicas (Knowledge Verification Chain) + +- **BullMQ + Valkey:** compatível em produção (Valkey = fork Redis 7.2.4). BullMQ usa adapter ioredis; backend hoje usa `node-redis` só p/ cache (`src/lib/cache.ts`) → BullMQ adiciona conexão ioredis dedicada ao mesmo `VALKEY_URL` com `maxRetriesPerRequest: null`. +- **react-email + React 19:** `render()` async funciona 100% com React 19; `@react-email/render` 2.1.0 suporta `react-dom ^19`. Bug conhecido com `@types/react@18` → fixar `@types/react@19`. Requer `jsx: react-jsx` no tsconfig do backend. +- **Frontend React:** `^19.2.7` (frontend/package.json). +- **Backend engine:** Node >= 22. diff --git a/.specs/features/email-module/design.md b/.specs/features/email-module/design.md new file mode 100644 index 0000000..b3ab976 --- /dev/null +++ b/.specs/features/email-module/design.md @@ -0,0 +1,193 @@ +# Email Module — Design + +**Spec**: `.specs/features/email-module/spec.md` +**Context**: `.specs/features/email-module/context.md` +**Status**: Draft — aguardando aprovação + +--- + +## Architecture Overview + +Módulo centralizado `src/modules/email` seguindo o padrão do backend (`Service` + arquivos coesos). Envio **assíncrono** via fila BullMQ sobre Valkey. Callers só enfileiram; um worker in-process renderiza o template (react-email) e despacha pelo `MailProvider` (Resend). Provider e templates são abstraídos e versionados. + +```mermaid +graph TD + Caller["Caller (AuthService.register, ...)"] -->|"send({template,to,data})"| Svc[EmailService] + Svc -->|valida to + template| Reg[TemplateRegistry] + Svc -->|enqueue| Q["BullMQ Queue 'email' (Valkey via ioredis)"] + Q --> W[EmailWorker in-process] + W -->|render| Reg + W -->|"send({to,subject,html})"| P{MailProvider} + P -->|env presente| Resend[ResendProvider -> Resend API] + P -->|env ausente| Noop[NoopProvider -> log only] + W -->|retry backoff / log final| Log[logger] +``` + +**Ciclo de vida:** a fila e o worker sobem em `server.ts` no boot e param no graceful shutdown (junto com `closeCache`). Enqueue é não-bloqueante; falha de enqueue é logada e engolida pelo caller. + +--- + +## Code Reuse Analysis + +### Existing Components to Leverage + +| Component | Location | How to Use | +| --- | --- | --- | +| Padrão de módulo (Service coeso) | `src/modules/notifications/*` | Espelhar estrutura e estilo. | +| `AppError` | `src/lib/errors.ts` | `AppError.validation` p/ `to`/template inválido no enqueue. | +| Logger | `src/logger.ts` | `logInfo/logWarn/logError` em enqueue, worker e providers. | +| Config pattern | `src/config.ts` | Adicionar envs de e-mail via `getConfig()` + parsers. | +| Valkey URL | `process.env.VALKEY_URL` (usado em `src/lib/cache.ts`) | Reusar a mesma instância Valkey p/ a conexão ioredis do BullMQ. | +| Registro de rotas / boot | `src/app.ts`, `src/server.ts` | Iniciar/parar worker no lifecycle do servidor. | +| Padrão de teste | `tests/unit/modules/*`, `tests/integration/*` | Vitest + mocks (hoisted), threshold 80%. | +| Fluxo de registro | `src/modules/auth/**` (`CredentialsService.register`) | Gatilho de boas-vindas no cadastro por e-mail/senha. | +| Fluxo de login social | `src/modules/auth/auth.service.ts` (`handleCallback`) + `src/modules/users/functions/findOrCreateUser.ts` | Gatilho de boas-vindas no primeiro login social; `findOrCreateUser` retorna `isNewUser` para disparar só em conta recém-criada. | + +### Integration Points + +| System | Integration Method | +| --- | --- | +| Valkey | Nova conexão `ioredis` (BullMQ exige) ao mesmo `VALKEY_URL`, prefixo de chaves próprio (`bull:email`). Não interfere no `node-redis` do cache. | +| Auth (registro) | `CredentialsService.register` chama `emailService.sendWelcome(...)` dentro de try/catch que só loga. | +| Auth (login social) | `AuthService.handleCallback` chama `emailService.sendWelcome(...)` dentro de try/catch (só loga) quando `findOrCreateUser` retorna `isNewUser: true`. | +| Resend | HTTP via SDK `resend` usando `EMAIL_API_KEY`. | + +--- + +## Components + +### EmailService +- **Purpose**: API interna única de e-mail. Valida e enfileira jobs. +- **Location**: `src/modules/email/email.service.ts` +- **Interfaces**: + - `send(input: { template: TemplateName; to: string; data: TemplateData }): Promise` — valida `to` (formato) e `template` (existe no registry); enfileira job; nunca aguarda entrega. Erros de validação lançam `AppError.validation`; falha de enqueue é logada e **não** propagada. + - `sendWelcome(user: { email: string; name: string }): Promise` — açúcar sobre `send` com template `welcome`. +- **Dependencies**: `EmailQueue`, `TemplateRegistry`, logger. +- **Reuses**: `AppError`, logger. + +### EmailQueue +- **Purpose**: Encapsula a fila BullMQ e sua conexão Valkey. +- **Location**: `src/modules/email/email.queue.ts` +- **Interfaces**: + - `getEmailQueue(): Queue` — singleton lazy da `Queue`. + - `enqueueEmail(data: EmailJobData): Promise` — adiciona job com opções de retry/backoff. + - `closeEmailQueue(): Promise` — fecha conexão no shutdown. +- **Dependencies**: `bullmq`, `ioredis`, `VALKEY_URL`. +- **Reuses**: padrão singleton de `src/lib/cache.ts`. +- **Config da conexão**: `new IORedis(VALKEY_URL, { maxRetriesPerRequest: null })` (requisito do BullMQ). + +### EmailWorker +- **Purpose**: Processa jobs: renderiza template → despacha via provider. +- **Location**: `src/modules/email/email.worker.ts` +- **Interfaces**: + - `startEmailWorker(): Worker` — cria e retorna o `Worker` (chamado no boot). + - `stopEmailWorker(): Promise` — fecha o worker no shutdown. + - processor: `async (job) => { html = registry.render(job.data); await provider.send({ to, subject, html }); }` +- **Dependencies**: `TemplateRegistry`, `MailProvider` (via factory), logger. +- **Retry**: `attempts: 3`, `backoff: { type: 'exponential', delay: 2000 }` (config). Falha final logada em `worker.on('failed')`. + +### MailProvider (interface) + factory +- **Purpose**: Contrato de envio, desacoplando o provedor concreto. +- **Location**: `src/modules/email/providers/mail-provider.ts` (interface + `getMailProvider()` factory) +- **Interfaces**: + - `interface MailProvider { send(msg: { to: string; subject: string; html: string; replyTo?: string }): Promise }` + - `getMailProvider(): MailProvider` — retorna `ResendProvider` se `EMAIL_API_KEY` presente, senão `NoopProvider`. +- **Reuses**: config, logger. + +### ResendProvider +- **Location**: `src/modules/email/providers/resend.provider.ts` +- **Interfaces**: `send(...)` via SDK `resend` (`resend.emails.send({ from, to, subject, html, replyTo })`). Lança em falha (BullMQ faz retry). +- **Dependencies**: `resend`, config (`EMAIL_API_KEY`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`). + +### NoopProvider +- **Location**: `src/modules/email/providers/noop.provider.ts` +- **Interfaces**: `send(...)` → `logWarn("Email disabled: no provider configured", {...})`. Não lança (EMAIL-09). + +### TemplateRegistry + templates +- **Purpose**: Mapear `TemplateName → { subject, render(data) }`, validando nomes e renderizando react-email para HTML. +- **Location**: `src/modules/email/templates/registry.ts`, `src/modules/email/templates/welcome.tsx`, `src/modules/email/templates/BaseLayout.tsx` +- **Interfaces**: + - `type TemplateName = 'welcome'` + - `isTemplate(name: string): name is TemplateName` + - `renderTemplate(name, data): Promise<{ subject: string; html: string }>` — usa `render()` (async) do `@react-email/render`. +- **Dependencies**: `react`, `react-dom`, `@react-email/render`. + +### Rotas / Boot +- **Location**: `src/server.ts` — chamar `startEmailWorker()` no boot e `stopEmailWorker()`/`closeEmailQueue()` no shutdown. +- Sem rota HTTP nova no MVP (contato fora de escopo). O módulo é consumido internamente. + +--- + +## Data Models + +### EmailJobData (payload do job BullMQ) +```typescript +interface EmailJobData { + template: TemplateName; // 'welcome' + to: string; // e-mail destino (validado no enqueue) + data: Record; // props do template (ex.: { name }) +} +``` +**Relationships**: efêmero (fila). Sem persistência em DB (decisão: só log). + +### Config additions (`src/config.ts`) +```typescript +interface AppConfig { + // ... + emailApiKey: string; // EMAIL_API_KEY (vazio => NoopProvider) + emailFromAddress: string; // EMAIL_FROM_ADDRESS + emailFromName: string; // EMAIL_FROM_NAME + emailQueueAttempts: number; // EMAIL_QUEUE_ATTEMPTS (default 3) +} +``` +`.env.example` ganha: `EMAIL_API_KEY=`, `EMAIL_FROM_ADDRESS=`, `EMAIL_FROM_NAME=`, `EMAIL_QUEUE_ATTEMPTS=3`. + +**URL do CTA:** o botão "Acessar plataforma" do template `welcome` reusa a env **`FRONTEND_URL`** (já existente em `.env.example` e usada em `auth.controller.ts`). Nenhuma env de URL nova é criada. O `welcome.tsx` recebe `appUrl` como prop, injetada pelo `EmailService.sendWelcome` a partir de `config.frontendUrl`. + +--- + +## Error Handling Strategy + +| Error Scenario | Handling | Impacto | +| --- | --- | --- | +| `to` inválido / template desconhecido no enqueue | `AppError.validation` lançado ao caller | Caller trata (é bug de programação interno). EMAIL-05. | +| Falha ao enfileirar (Valkey down) | log de erro, exceção engolida em `send` | Fluxo de negócio (registro) segue normal. EMAIL-10, EMAIL-13→10. | +| Falha do provider no worker | throw → BullMQ retry (3x, backoff exp.) | Transparente; e-mail eventualmente entregue ou logado no fracasso final. EMAIL-03. | +| Fracasso final após retries | `worker.on('failed')` → `logError` | Nenhum; e-mail perdido é logado (sem DB no MVP). EMAIL-03. | +| Env de e-mail ausente | `getMailProvider()` retorna `NoopProvider` (loga) | API sobe e opera; e-mails viram no-op logado. EMAIL-09. | + +--- + +## Risks & Concerns + +| Concern | Location | Impact | Mitigation | +| --- | --- | --- | --- | +| JSX/React no backend (hoje sem react/tsconfig JSX) | `backend/tsconfig*.json` | Build quebra sem `jsx: react-jsx` e sem deps React | Task dedicada: instalar `react@19`,`react-dom@19`,`@types/react@19` (casar frontend) + ativar JSX no tsconfig. Verificado como compatível. | +| Conflito de versões React 19 x `@types/react@18` | `backend/package.json` | Erros de tipo no `render()` | Fixar `@types/react@19` (bug conhecido documentado). | +| Nova conexão ioredis ao Valkey (além do node-redis) | `email.queue.ts` | Duas libs de client no mesmo processo | Aceitável: BullMQ exige ioredis; escopo isolado ao módulo, prefixo `bull:email`. Sem impacto no cache existente. | +| Worker in-process compete por CPU/event-loop com a API | `server.ts` | Em volume alto, render+envio pode pesar | Volume MVP é baixo (boas-vindas). Concurrency baixa no worker; extraível p/ processo separado depois sem mudar a fila (AD-002). | +| Perda de e-mail no fracasso final (sem DB) | worker | E-mail não entregue só vira log | Aceito no MVP (decisão do usuário). `email_logs` é evolução futura registrada em Out of Scope. | +| Testar código que toca Valkey/BullMQ real | testes | Testes lentos/flaky se conectarem de verdade | Mockar `email.queue` e `MailProvider`; testar `EmailService`/worker com fakes em memória (padrão hoisted já usado no repo). | + +> Concern flag: `AuthService.register` precisa de um try/catch ao redor do `sendWelcome` — hoje não existe; a task de integração adiciona sem alterar o contrato de retorno do registro. + +--- + +## Tech Decisions + +| Decisão | Escolha | Rationale | +| --- | --- | --- | +| Transporte | BullMQ sobre Valkey (ioredis) | Fila durável com retry/backoff nativo; base p/ futuros alertas de vagas. AD-002. | +| Provider | Resend atrás de `MailProvider` | Decisão do usuário; troca barata. AD-003. | +| Templates | react-email, React fixo em 19.x | Casa com frontend; evita quebra de build. AD-004. | +| Worker | In-process (boot em server.ts) | Simples p/ MVP; extraível depois. | +| Sem provider configurado | NoopProvider (no-op + log) | Dev local sobe sem credenciais. | +| Persistência | Nenhuma (só log) | Decisão do usuário; `email_logs` futuro. | + +> Decisões de nível de projeto já registradas em `.specs/STATE.md` como AD-001..AD-004. + +--- + +## Open decisions + +Nenhuma — todas confirmadas com o usuário. Pronto para a fase de Tasks. diff --git a/.specs/features/email-module/spec.md b/.specs/features/email-module/spec.md new file mode 100644 index 0000000..b20eaf7 --- /dev/null +++ b/.specs/features/email-module/spec.md @@ -0,0 +1,125 @@ +# Módulo de E-mail Centralizado — Specification + +**Task:** PAV-76 · **Team:** Painel Vagas + +## Problem Statement + +A plataforma passou a ter domínio próprio e precisa enviar e-mails transacionais (boas-vindas, contato/suporte e, futuramente, recuperação de senha, confirmação de e-mail, alertas de vagas). Hoje não existe nenhum mecanismo de envio. Sem um ponto único, cada funcionalidade implementaria seu próprio envio — duplicando código e dificultando manutenção e troca de provedor. + +## Goals + +- [ ] Expor uma **API interna única** de envio de e-mail, consumível por qualquer módulo do backend. +- [ ] Enviar de forma **assíncrona e resiliente** (fila com retry), sem que a falha de e-mail derrube o request que o originou. +- [ ] Separar **lógica de envio** (provider) de **templates** (react-email), ambos versionados no projeto. +- [ ] Entregar o fluxo funcional de **boas-vindas** como primeiro consumidor real, disparado tanto no **registro por e-mail/senha** quanto no **primeiro login social** (Google, GitHub, LinkedIn) — apenas para contas recém-criadas. +- [ ] Deixar o provedor **trocável** via configuração, sem alterar código chamador. +- [ ] Documentar o uso do módulo no repositório. + +## Out of Scope + +| Feature | Motivo | +| --- | --- | +| **Contato/Suporte** (endpoint + e-mail) | Removido do escopo pelo usuário: o link de "Ajuda & Suporte" foi retirado do frontend em outro PR e só voltará quando a seção for decidida. A arquitetura mantém a API interna genérica, então plugar esse fluxo depois é só um novo template + caller. | +| Recuperação de senha, confirmação de e-mail, troca de e-mail | Evolução futura (PAV-8, PAV-36); o módulo apenas prepara terreno. | +| Alertas de vagas / notificações em lote | Evolução futura; exige fan-out e agregação próprios. | +| Persistência de logs de envio em DB (`email_logs`) | Decisão do usuário: MVP só loga. Auditoria em DB fica para depois. | +| UI de administração de e-mails / dashboard | Não solicitado. | +| Preferências de opt-in/opt-out por usuário | `userPreferences.emailNotifications` já existe mas seu enforcement não é escopo desta task. | +| Segundo provedor (SES/SMTP) implementado | Só a interface é entregue; implementação concreta apenas Resend. | + +--- + +## Assumptions & Open Questions + +| Assumption / decisão | Escolha | Racional | Confirmado? | +| --- | --- | --- | --- | +| Provedor inicial | Resend | Decisão do usuário; API simples, integra com domínio próprio. | y | +| Transporte assíncrono | Fila BullMQ sobre Valkey | Decisão do usuário (quer jobs); BullMQ compatível com Valkey (verificado). | y | +| Templates | react-email, versões React fixadas em 19.x | Decisão do usuário; casa com frontend, evita quebra de build. | y | +| Persistência de envios | Somente log (sem tabela) | Decisão do usuário. | y | +| Deployment do worker BullMQ | **In-process** (inicia junto do servidor Express) | Volume baixo no MVP; simples. Extraível p/ processo separado depois sem mudar a fila. | y | +| Comportamento se env de e-mail ausente | Log de aviso + envio vira no-op (não derruba boot) | Ambiente local sem chave não deve quebrar a API. | y | +| Fluxo de contato/suporte | **Fora do escopo** | Seção removida do produto; volta em task futura. | y | + +**Open questions:** none — todas resolvidas ou registradas acima. + +--- + +## User Stories + +### P1: API interna de envio + entrega assíncrona ⭐ MVP + +**User Story**: Como desenvolvedor de qualquer módulo do backend, quero chamar uma única API interna (`emailService.send(...)`) para disparar um e-mail, para não implementar envio próprio. + +**Why P1**: É o núcleo do módulo — sem isso nada mais existe. Vertical slice: enfileira → worker renderiza template → provider envia. + +**Acceptance Criteria**: +1. WHEN um módulo chama `emailService.send({ template, to, data })` com dados válidos THEN o sistema SHALL enfileirar um job de e-mail e retornar sem aguardar a entrega. +2. WHEN um job de e-mail é processado pelo worker THEN o sistema SHALL renderizar o template HTML indicado e despachá-lo através do provedor configurado (Resend). +3. WHEN o despacho pelo provedor falha THEN o worker SHALL re-tentar com backoff até o limite configurado e, no fracasso final, registrar erro em log — sem lançar exceção para o caller original. +4. WHEN o provedor é trocado via configuração THEN nenhum código chamador SHALL precisar de alteração (contrato via interface `MailProvider`). +5. WHEN `to` é um e-mail inválido OU `template` é desconhecido THEN o sistema SHALL rejeitar no momento do enqueue com erro de validação, antes de criar o job. + +**Independent Test**: Chamar `emailService.send` com um provider fake em memória e asseverar que o job foi enfileirado, o template renderizou HTML esperado e o provider recebeu `{ to, subject, html }`; simular falha do provider e asseverar retry + log. + +--- + +### P1: E-mail de boas-vindas no registro e no primeiro login social ⭐ MVP + +**User Story**: Como novo usuário, quero receber um e-mail de boas-vindas quando minha conta é criada — seja por cadastro com e-mail/senha ou pelo primeiro login social (Google, GitHub, LinkedIn) —, para confirmar que minha conta foi criada. + +**Why P1**: Critério de aceite explícito da task (template funcional de boas-vindas) e primeiro consumidor real da API interna. Como a maioria dos cadastros ocorre via login social, o gatilho precisa cobrir esse caminho além do registro por credenciais. + +**Acceptance Criteria**: +1. WHEN um usuário completa o registro por e-mail/senha com sucesso THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +2. WHEN um usuário faz login social (Google, GitHub ou LinkedIn) E uma conta é criada pela primeira vez THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +3. WHEN um usuário faz login social E a conta já existe (relogin, ou vínculo de um novo provider a um usuário que já se cadastrou antes) THEN o sistema SHALL NÃO enfileirar e-mail de boas-vindas. +4. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL conter o nome do usuário e o HTML do template de boas-vindas. +5. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL incluir um botão "Acessar plataforma" cujo link aponta para `FRONTEND_URL` (env já existente, reusada). +6. WHEN o enfileiramento do e-mail de boas-vindas falha THEN o fluxo de origem (registro OU login social) SHALL concluir normalmente mesmo assim (falha de e-mail é logada, não propagada). + +**Independent Test**: (a) Executar `CredentialsService.register` com fila mockada e asseverar que um job "welcome" foi enfileirado com o `to`/nome corretos. (b) Executar `AuthService.handleCallback` para um perfil OAuth sem usuário correspondente e asseverar que boas-vindas é enfileirado; repetir para um usuário já existente (achado por provider ou por e-mail) e asseverar que **não** é enfileirado. (c) Em ambos os fluxos, forçar erro no envio e asseverar que a operação de origem ainda conclui com sucesso. + +--- + +## Edge Cases + +- WHEN as variáveis de ambiente de e-mail (ex: `EMAIL_API_KEY`) estão ausentes THEN o sistema SHALL logar aviso e tratar o envio como no-op, sem derrubar o boot da API. +- WHEN o Valkey está indisponível no momento do enqueue THEN o `emailService.send` SHALL logar erro e não propagar exceção que quebre o fluxo de negócio chamador. +- WHEN dois registros do mesmo usuário disparam boas-vindas THEN cada envio é um job independente (sem dedup no MVP — aceitável). +- WHEN um usuário que já se cadastrou por e-mail/senha faz login social pela primeira vez THEN o provider é vinculado à conta existente e boas-vindas NÃO é reenviado (usuário não é considerado novo). +- WHEN um usuário faz relogin social (conta já vinculada ao provider) THEN nenhum e-mail de boas-vindas é enfileirado. +- WHEN o template referenciado não existe no registry de templates THEN o enqueue SHALL falhar com erro de validação (não gerar job órfão). + +--- + +## Requirement Traceability + +| Requirement ID | Story | Fase | Status | +| --- | --- | --- | --- | +| EMAIL-01 | P1 API interna — enqueue não-bloqueante | Design | Pending | +| EMAIL-02 | P1 API interna — worker renderiza + envia via provider | Design | Pending | +| EMAIL-03 | P1 API interna — retry com backoff + log no fracasso | Design | Pending | +| EMAIL-04 | P1 API interna — provider trocável via interface | Design | Pending | +| EMAIL-05 | P1 API interna — validação de `to`/`template` no enqueue | Design | Pending | +| EMAIL-06 | P1 Boas-vindas — enfileira no registro por e-mail/senha | Design | Pending | +| EMAIL-07 | P1 Boas-vindas — template com nome/HTML | Design | Pending | +| EMAIL-08 | P1 Boas-vindas — falha não quebra fluxo de origem | Design | Pending | +| EMAIL-09 | Edge — env ausente vira no-op sem quebrar boot | Design | Pending | +| EMAIL-10 | Edge — Valkey down no enqueue não propaga | Design | Pending | +| EMAIL-11 | Doc — documentação de uso no repositório | Design | Pending | +| EMAIL-12 | P1 Boas-vindas — enfileira no primeiro login social (Google/GitHub/LinkedIn, usuário novo) | Design | Pending | +| EMAIL-13 | P1 Boas-vindas — não reenvia em relogin/vínculo de provider a usuário existente | Design | Pending | + +**ID format:** `EMAIL-NN` · **Status:** Pending → In Design → In Tasks → Implementing → Verified +**Coverage:** 13 total, 0 mapeados a tasks (Tasks ainda não iniciada). + +--- + +## Success Criteria + +- [ ] Um módulo consegue enviar e-mail com uma única chamada, sem conhecer o provedor. +- [ ] E-mail de boas-vindas chega em ambiente local e produção (template HTML funcional). +- [ ] Falha de provedor/fila nunca derruba o fluxo de negócio chamador. +- [ ] Trocar provider = implementar interface + mudar env, sem tocar callers. +- [ ] Documentação de uso adicionada ao repositório. diff --git a/.specs/features/email-module/tasks.md b/.specs/features/email-module/tasks.md new file mode 100644 index 0000000..3ffb8fc --- /dev/null +++ b/.specs/features/email-module/tasks.md @@ -0,0 +1,426 @@ +# Email Module — Tasks + +## Execution Protocol (MANDATORY — do not skip) + +Implement these tasks with the `tlc-spec-driven` skill: **activate it by name and follow its Execute flow and Critical Rules.** Do not search for skill files by filesystem path. + +**Política de commit (AGENTS.md sobrepõe o skill):** o repositório proíbe commits automáticos ("NUNCA faça commits automaticamente. Sempre pergunte"). Portanto, os workers **implementam + rodam o gate por task, mas NÃO commitam**. Cada task deixa a mudança pronta e verificada; o orchestrator apresenta os commits atômicos propostos (mensagens em **português**) para o usuário aprovar. Um commit por task continua sendo a unidade — só que aplicado após aprovação. + +**If the skill cannot be activated, STOP and tell the user.** + +--- + +**Design**: `.specs/features/email-module/design.md` +**Spec**: `.specs/features/email-module/spec.md` +**Status**: ✅ Done — 11/11 tasks commitadas; Verifier PASS (529 testes, sensor 5/5); ver `validation.md` + +--- + +## Test Coverage Matrix + +> Gerada de codebase + guidelines + spec — confirmar antes do Execute. Guidelines encontradas: `AGENTS.md` (commits/pt-BR), `backend/vitest.config.js` (threshold 80%, `include: src/**/*.ts`), `TESTING.md` (QA manual, não automatiza), padrão de testes em `backend/tests/unit/modules/*` e `backend/tests/integration/routes/*`. + +| Code Layer | Required Test Type | Coverage Expectation | Location Pattern | Run Command | +| --- | --- | --- | --- | --- | +| Service (`EmailService`) | unit | Todas as branches; 1:1 com ACs; cada edge case listado | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Provider (`Resend`, `Noop`, factory) | unit | Caminho-chave de envio + erro + seleção por env | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Queue (`email.queue`) | unit | enqueue com opções de retry; guarda de conexão (mock bullmq/ioredis) | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Worker (`email.worker`) | unit | processor renderiza+envia; falha propaga p/ retry; handler `failed` loga | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Template Registry (`registry.ts`) | unit | render de `welcome` (nome + CTA + subject); template desconhecido rejeitado | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Templates (`*.tsx`) | none | Verificados indiretamente via Registry (coverage exclui `.tsx`) | `backend/src/modules/email/templates/*.tsx` | build gate | +| Auth integration (`AuthService.register`) | unit | boas-vindas enfileirado no sucesso; registro conclui mesmo se envio falhar | `backend/tests/unit/modules/auth/*.test.ts` (ou existente) | `cd backend && npm test` | +| Config (`config.ts`) + `.env.example` | none | Sem teste dedicado no repo (padrão config) — build gate | `backend/src/config.ts` | build gate | +| Boot wiring (`server.ts`) | none | Lifecycle wiring — build gate | `backend/src/server.ts` | build gate | +| Deps / tsconfig | none | Toolchain — build gate | `backend/package.json`, `backend/tsconfig.json` | build gate | +| Docs | none | Documentação — sem teste | `BACKEND.md` / `docs/` | — | + +## Gate Check Commands + +> Confirmar antes do Execute. Backend não possui script de build/lint/typecheck; `npm test` (vitest run) roda unit + integração no mesmo runner e é o verificador determinístico. + +| Gate Level | When to Use | Command | +| --- | --- | --- | +| Quick | Após tasks só com unit tests | `cd backend && npm test` | +| Full | Após tasks com integração | `cd backend && npm test` | +| Build | Fim de fase / tasks sem testes (config, deps, wiring, docs) | `cd backend && npm test` | + +> Alvo de cobertura: novos arquivos `src/**/*.ts` devem atingir ~80% localmente (threshold do `vitest.config.js`). `npm run test:coverage --workspace=backend` é o comando de conferência de cobertura, usado pelo Verifier — não como gate por-task (evita falha por cobertura global pré-existente). + +--- + +## Execution Plan + +Fases ordenadas, executadas em sequência; tasks dentro da fase em ordem. + +### Phase 1: Foundation (toolchain + config) + +``` +T1 → T2 +``` + +### Phase 2: Provider layer + +``` +T3 → T4 +``` + +### Phase 3: Templates + +``` +T5 +``` + +### Phase 4: Transport + API interna + +``` +T6 → T7 → T8 +``` + +### Phase 5: Integração + docs + +``` +T9 → T10 → T11 +``` + +--- + +## Task Breakdown + +### T1: Instalar dependências e habilitar JSX no backend + +**What**: Adicionar `bullmq`, `ioredis`, `resend`, `react@19`, `react-dom@19`, `@types/react@19` (dev), `@react-email/render` ao backend e habilitar `"jsx": "react-jsx"` no tsconfig. +**Where**: `backend/package.json`, `backend/tsconfig.json` +**Depends on**: None +**Reuses**: — +**Requirement**: EMAIL-02, EMAIL-04 (habilitadores) + +**Tools**: +- MCP: `context7` (conferir versões/peer deps de `@react-email/render` e `bullmq`) +- Skill: NONE + +**Done when**: +- [ ] `react`/`react-dom` em `^19` e `@types/react` em `^19` (casam com frontend; evita bug `@types/react@18`) +- [ ] `bullmq`, `ioredis`, `resend`, `@react-email/render` instalados +- [ ] `tsconfig.json` com `"jsx": "react-jsx"` +- [ ] Gate passa: `cd backend && npm test` (suíte existente continua verde) + +**Tests**: none +**Gate**: build + +--- + +### T2: Adicionar configuração de e-mail + +**What**: Adicionar campos de e-mail ao `AppConfig`/`getConfig()` e ao `.env.example`: `emailApiKey`, `emailFromAddress`, `emailFromName`, `emailQueueAttempts` (default 3) e reuso de `frontendUrl` (env `FRONTEND_URL` já existente) para o CTA. +**Where**: `backend/src/config.ts`, `.env.example` +**Depends on**: None +**Reuses**: padrão `parseNumber`/`getConfig` em `src/config.ts`; env `FRONTEND_URL` +**Requirement**: EMAIL-04, EMAIL-07, EMAIL-09 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `getConfig()` retorna os novos campos com defaults seguros (chave vazia ⇒ string vazia) +- [ ] `emailQueueAttempts` parseado como número com default 3 +- [ ] `frontendUrl` exposto a partir de `FRONTEND_URL` +- [ ] `.env.example` documenta `EMAIL_API_KEY`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_QUEUE_ATTEMPTS` +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: none +**Gate**: build + +--- + +### T3: Interface MailProvider + NoopProvider + factory + +**What**: Definir `interface MailProvider { send(msg) }`, implementar `NoopProvider` (loga, não envia, não lança) e `getMailProvider()` que retorna Resend quando `EMAIL_API_KEY` presente, senão Noop. +**Where**: `backend/src/modules/email/providers/mail-provider.ts`, `backend/src/modules/email/providers/noop.provider.ts` +**Depends on**: T2 +**Reuses**: `logWarn` (`src/logger.ts`), `getConfig` (`src/config.ts`) +**Requirement**: EMAIL-04, EMAIL-09 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `getMailProvider()` retorna `NoopProvider` quando `emailApiKey` vazio e `ResendProvider` quando presente +- [ ] `NoopProvider.send` loga aviso e resolve sem lançar +- [ ] Testes unitários cobrem seleção por env + no-op sem throw +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T4: ResendProvider + +**What**: Implementar `ResendProvider.send({to,subject,html,replyTo})` via SDK `resend`, usando `from` = `EMAIL_FROM_NAME `; lançar em falha do SDK (para o BullMQ re-tentar). +**Where**: `backend/src/modules/email/providers/resend.provider.ts` +**Depends on**: T1, T2, T3 +**Reuses**: interface de T3, `getConfig`, `logError` +**Requirement**: EMAIL-02, EMAIL-04 + +**Tools**: +- MCP: `context7` (assinatura de `resend.emails.send`) +- Skill: NONE + +**Done when**: +- [ ] `send` chama o SDK com `{ from, to, subject, html, replyTo? }` mapeados corretamente +- [ ] Erro do SDK é propagado (lançado), não engolido +- [ ] Testes unitários mockam `resend` e asseveram payload + propagação de erro +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T5: Templates react-email + TemplateRegistry + +**What**: Criar `BaseLayout.tsx`, `welcome.tsx` (props `{ name, appUrl }`, com botão "Acessar plataforma" → `appUrl`) e `registry.ts` (`TemplateName`, `isTemplate`, `renderTemplate` async via `@react-email/render`, com `subject` por template). +**Where**: `backend/src/modules/email/templates/BaseLayout.tsx`, `welcome.tsx`, `registry.ts` +**Depends on**: T1 +**Reuses**: `@react-email/render` +**Requirement**: EMAIL-07, EMAIL-05 (template desconhecido) + +**Tools**: +- MCP: `context7` (`render` async do react-email; componentes) +- Skill: NONE + +**Done when**: +- [ ] `renderTemplate('welcome', { name, appUrl })` retorna `{ subject, html }` com HTML contendo o `name` e um link/botão com `href` = `appUrl` +- [ ] `isTemplate('desconhecido')` é `false`; `renderTemplate` com nome inválido rejeita/lança +- [ ] Testes unitários no registry cobrem render de welcome (nome + CTA href + subject) e rejeição de template desconhecido +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit (registry) · templates `.tsx` = none (via registry) +**Gate**: quick + +--- + +### T6: EmailQueue (BullMQ sobre Valkey) + +**What**: Criar fila `email` com conexão `ioredis` dedicada ao `VALKEY_URL` (`maxRetriesPerRequest: null`), expor `getEmailQueue()`, `enqueueEmail(data)` (com `attempts` = config, `backoff` exponencial) e `closeEmailQueue()`. +**Where**: `backend/src/modules/email/email.queue.ts` +**Depends on**: T1, T2 +**Reuses**: padrão singleton de `src/lib/cache.ts`; `VALKEY_URL`; `getConfig` +**Requirement**: EMAIL-01, EMAIL-03 + +**Tools**: +- MCP: `context7` (`Queue`/opções de `add` do BullMQ) +- Skill: NONE + +**Done when**: +- [ ] `enqueueEmail` adiciona job com `attempts` = `emailQueueAttempts` e backoff exponencial +- [ ] Conexão ioredis criada com `maxRetriesPerRequest: null` +- [ ] Testes unitários mockam `bullmq`/`ioredis` e asseveram opções do job +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T7: EmailWorker + +**What**: Criar `startEmailWorker()`/`stopEmailWorker()`: processor que renderiza o template (`renderTemplate`) e despacha via `getMailProvider().send(...)`; `on('failed')` loga erro no fracasso final. Falha do provider propaga (BullMQ re-tenta). +**Where**: `backend/src/modules/email/email.worker.ts` +**Depends on**: T3, T4, T5, T6 +**Reuses**: `getMailProvider`, `renderTemplate`, `logInfo/logError` +**Requirement**: EMAIL-02, EMAIL-03 + +**Tools**: +- MCP: `context7` (`Worker` do BullMQ) +- Skill: NONE + +**Done when**: +- [ ] Processor renderiza template e chama `provider.send({ to, subject, html })` com os valores renderizados +- [ ] Erro do provider é propagado pelo processor (permite retry do BullMQ) +- [ ] Handler `failed` registra erro (log) sem lançar +- [ ] Testes unitários mockam registry+provider e asseveram: payload enviado (to/subject/html), propagação de erro, log no failed +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T8: EmailService (API interna) + +**What**: Criar `EmailService` com `send({template,to,data})` — valida formato de `to` e existência de `template` (senão `AppError.validation`), enfileira via `enqueueEmail`; falha de enqueue é logada e **não** propagada. `sendWelcome({email,name})` injeta `appUrl` = `config.frontendUrl` e chama `send`. +**Where**: `backend/src/modules/email/email.service.ts` +**Depends on**: T5, T6 +**Reuses**: `enqueueEmail`, `isTemplate`, `AppError`, `logError`, `getConfig` +**Requirement**: EMAIL-01, EMAIL-05, EMAIL-06, EMAIL-07, EMAIL-10 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `send` válido enfileira job com `{ template, to, data }` +- [ ] `to` inválido OU `template` desconhecido ⇒ `AppError.validation` e **nenhum** enqueue +- [ ] Falha no `enqueueEmail` ⇒ `send` resolve (erro logado, não propagado) +- [ ] `sendWelcome` injeta `appUrl` do `frontendUrl` e usa template `welcome` +- [ ] Testes unitários cobrem: enqueue no caminho feliz, validação (to/template) sem enqueue, enqueue-throw engolido, sendWelcome com appUrl +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T9: Iniciar/parar worker no boot do servidor + +**What**: Chamar `startEmailWorker()` no boot e `stopEmailWorker()` + `closeEmailQueue()` no graceful shutdown do servidor. +**Where**: `backend/src/server.ts` +**Depends on**: T6, T7 +**Reuses**: padrão de shutdown existente (`closeCache`) +**Requirement**: EMAIL-02 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Worker inicia no boot do servidor +- [ ] Shutdown fecha worker e fila junto dos recursos existentes +- [ ] Gate passa: `cd backend && npm test` (suíte não quebra) + +**Tests**: none (wiring — build gate) +**Gate**: build + +--- + +### T10: Disparar boas-vindas no registro + +**What**: No `AuthService.register`, após o registro concluir com sucesso, chamar `emailService.sendWelcome({ email, name })` dentro de try/catch que apenas loga — nunca propaga. +**Where**: `backend/src/modules/auth/**` (arquivo do `register`) + teste correspondente +**Depends on**: T8 +**Reuses**: `EmailService`, `logError`, padrão de teste de auth existente +**Requirement**: EMAIL-06, EMAIL-08 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Registro bem-sucedido chama `sendWelcome` com email + nome do usuário +- [ ] Exceção em `sendWelcome` NÃO altera o retorno de sucesso do registro +- [ ] Testes unitários: welcome enfileirado no sucesso; registro ainda retorna sucesso quando `sendWelcome` lança +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: full + +--- + +### T11: Documentação do módulo de e-mail + +**What**: Documentar uso do módulo (API interna `emailService.send/sendWelcome`, envs, como adicionar novo template/provider) no `BACKEND.md` (e/ou `docs/`). +**Where**: `BACKEND.md` +**Depends on**: T8 +**Reuses**: estilo do `BACKEND.md` +**Requirement**: EMAIL-11 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Seção "Módulo de E-mail" com exemplo de uso, envs e passo p/ novo template/provider +- [ ] Gate passa: `cd backend && npm test` (docs não quebram nada) + +**Tests**: none (docs) +**Gate**: build + +--- + +## Phase Execution Map + +``` +Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 + +Phase 1: T1 ──→ T2 +Phase 2: T3 ──→ T4 +Phase 3: T5 +Phase 4: T6 ──→ T7 ──→ T8 +Phase 5: T9 ──→ T10 ──→ T11 +``` + +Execução estritamente sequencial — sem paralelismo intra-fase. + +--- + +## Task Granularity Check + +| Task | Scope | Status | +| --- | --- | --- | +| T1: deps + jsx | toolchain (2 arquivos coesos) | ✅ Granular | +| T2: config e-mail | 1 concern (config) | ✅ Granular | +| T3: interface + Noop + factory | 1 concern coeso (contrato+fallback) | ✅ Granular | +| T4: ResendProvider | 1 classe | ✅ Granular | +| T5: templates + registry | 1 concern coeso (templates+lookup) | ✅ Granular | +| T6: EmailQueue | 1 módulo (fila) | ✅ Granular | +| T7: EmailWorker | 1 módulo (worker) | ✅ Granular | +| T8: EmailService | 1 classe (API interna) | ✅ Granular | +| T9: boot wiring | 1 arquivo (server.ts) | ✅ Granular | +| T10: integração register | 1 função (register) | ✅ Granular | +| T11: docs | 1 arquivo (docs) | ✅ Granular | + +--- + +## Diagram-Definition Cross-Check + +| Task | Depends On (body) | Diagram Shows | Status | +| --- | --- | --- | --- | +| T1 | None | (início) | ✅ Match | +| T2 | None | (início) | ✅ Match | +| T3 | T2 | Phase 2 pós Phase 1 | ✅ Match | +| T4 | T1, T2, T3 | T3 → T4 (+ deps fase 1) | ✅ Match | +| T5 | T1 | Phase 3 pós Phase 1 | ✅ Match | +| T6 | T1, T2 | Phase 4 pós fase 1 | ✅ Match | +| T7 | T3, T4, T5, T6 | T6 → T7 (+ provider/template) | ✅ Match | +| T8 | T5, T6 | T7 → T8 na ordem; deps T5/T6 anteriores | ✅ Match | +| T9 | T6, T7 | Phase 5 pós Phase 4 | ✅ Match | +| T10 | T8 | pós Phase 4 | ✅ Match | +| T11 | T8 | pós Phase 4 | ✅ Match | + +> Todas as dependências apontam para trás ou para fase anterior — nenhuma dependência de fase posterior. + +--- + +## Test Co-location Validation + +| Task | Layer Created/Modified | Matrix Requires | Task Says | Status | +| --- | --- | --- | --- | --- | +| T1 | deps/tsconfig | none | none | ✅ OK | +| T2 | config | none | none | ✅ OK | +| T3 | provider/factory | unit | unit | ✅ OK | +| T4 | provider | unit | unit | ✅ OK | +| T5 | registry (+ templates none) | unit | unit | ✅ OK | +| T6 | queue | unit | unit | ✅ OK | +| T7 | worker | unit | unit | ✅ OK | +| T8 | service | unit | unit | ✅ OK | +| T9 | boot wiring | none | none | ✅ OK | +| T10 | auth service (integração) | unit | unit | ✅ OK | +| T11 | docs | none | none | ✅ OK | + +> Nenhum `Tests: none` esconde deferral: todos os `none` correspondem a camadas que a matriz marca como `none` (deps, config, wiring, docs, templates `.tsx`). + +--- + +## Sub-Agent Batching (para o Execute) + +Total: **11 tasks** em 5 fases `[2,2,1,3,3]`. Empacotando ~7 tasks/worker por fronteira de fase: + +- **Batch A — Blocos de construção** (Fases 1–3): T1–T5 (5 tasks) — deps/config, providers, templates. Folhas sem interdependência além das deps. +- **Batch B — Montagem + integração** (Fases 4–5): T6–T11 (6 tasks) — fila, worker, service, boot, registro, docs. Depende integralmente do Batch A. + +→ **2 workers**, sequenciais (Batch B só inicia após Batch A reportar tudo completo). Verifier roda automaticamente após T11. + +**Commits:** workers NÃO commitam (AGENTS.md). Após cada batch, o orchestrator apresenta os commits atômicos propostos (pt-BR) para aprovação do usuário. diff --git a/.specs/features/email-module/validation.md b/.specs/features/email-module/validation.md new file mode 100644 index 0000000..4eafb35 --- /dev/null +++ b/.specs/features/email-module/validation.md @@ -0,0 +1,123 @@ +# Email Module Validation + +**Date**: 2026-07-27 +**Spec**: `.specs/features/email-module/spec.md` +**Diff range**: `master..feature/pav-76-modulo-email` (Batch A committed) + working-tree changes (Batch B) + untracked email module files +**Verifier**: independent sub-agent (author ≠ verifier), read-only over the real tree; sensor mutations ran on temp copies and were discarded + +--- + +## Verdict: PASS ✅ + +Todos os 11 ACs (EMAIL-01..11) têm evidência `file:line` com assertion que mira o valor/estado definido na spec. Gate limpo (529/529). Sensor: 5/5 mutantes mortos. + +--- + +## Spec-Anchored Acceptance Criteria + +| AC | Spec-defined outcome | `file:line` + assertion | Result | +| -- | -------------------- | ----------------------- | ------ | +| **EMAIL-01** — `send({template,to,data})` válido enfileira e retorna sem aguardar entrega | Job enfileirado com payload exato `{template,to,data}` | `tests/unit/modules/email/email.service.test.ts:58` — `expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ template:"welcome", to:"user@example.com", data:{name:"Ana"} })` | ✅ PASS | +| **EMAIL-01** (conexão fila) — ioredis com `maxRetriesPerRequest:null` | Conexão criada com esse opt (requisito BullMQ) | `tests/unit/modules/email/email.queue.test.ts:69` — `expect(RedisConstructor).toHaveBeenCalledWith("redis://localhost:6379", { maxRetriesPerRequest: null })` | ✅ PASS | +| **EMAIL-02** — worker renderiza template e despacha via provider | Provider recebe `{to,subject,html}` renderizados | `email.worker.test.ts:94-101` — `expect(renderTemplate).toHaveBeenCalledWith("welcome",{name:"Ana"})` + `expect(providerMocks.send).toHaveBeenCalledWith({ to:"user@example.com", subject:"Bem-vindo", html:"

Olá

" })` | ✅ PASS | +| **EMAIL-02/04** (provider Resend concreto) — SDK chamado com from formatado + campos mapeados | `resend.emails.send({from:"Painel Vagas ", to, subject, html, replyTo})` | `resend.provider.test.ts:54-61` — `expect(resendMocks.send).toHaveBeenCalledWith({ from:"Painel Vagas ", to, subject, html, replyTo })` | ✅ PASS | +| **EMAIL-03** — falha do provider ⇒ retry com backoff; fracasso final ⇒ log, sem exceção ao caller | (a) enqueue com `attempts` do config + backoff exp; (b) worker propaga erro p/ BullMQ retry; (c) provider Resend lança em erro; (d) handler `failed` só loga | (a) `email.queue.test.ts:92` — `toHaveBeenCalledWith("send-email", data, objectContaining({ attempts:3, backoff:{type:"exponential",delay:2000} }))`; (b) `email.worker.test.ts:111` — `await expect(processor(job)).rejects.toThrow("provider down")`; (c) `resend.provider.test.ts:71` — `rejects.toThrow("rate limit")`; (d) `email.worker.test.ts:120-123` — `expect(()=>failedHandler(...)).not.toThrow()` + `expect(logError).toHaveBeenCalled()` | ✅ PASS | +| **EMAIL-04** — provider trocável via interface, sem mudar caller | `getMailProvider()` retorna Resend se key presente, Noop se ausente | `mail-provider.test.ts:57` — `expect(provider).toBeInstanceOf(FakeResend)` (key presente); `:47` — `toBeInstanceOf(FakeNoop)` (key vazia) | ✅ PASS | +| **EMAIL-05** — `to` inválido OU template desconhecido ⇒ rejeita no enqueue, antes de criar job | Lança `VALIDATION_ERROR` e NÃO enfileira | `email.service.test.ts:65-74` — `rejects.toMatchObject({code:"VALIDATION_ERROR"})` + `expect(enqueueEmail).not.toHaveBeenCalled()` (`to` inválido); `:77-88` — idem p/ template desconhecido. Registry: `registry.test.ts:14` — `isTemplate("desconhecido")` `.toBe(false)`; `:33` — `renderTemplate("desconhecido",...)` `.rejects.toThrow()` | ✅ PASS | +| **EMAIL-06** — registro com sucesso enfileira welcome p/ endereço do usuário | `sendWelcome` chamado com `{email,name}` corretos | `credentials.service.test.ts:201` — `expect(mocks.sendWelcome).toHaveBeenCalledWith({ email: mockUser.email, name: mockUser.displayName })` | ✅ PASS | +| **EMAIL-06/07** (service) — sendWelcome injeta appUrl do frontendUrl + template welcome | Payload `{template:"welcome", to, data:{name, appUrl:frontendUrl}}` | `email.service.test.ts:113` — `toHaveBeenCalledWith({ template:"welcome", to:"user@example.com", data:{ name:"Ana", appUrl:"https://painelvagas.com" } })` | ✅ PASS | +| **EMAIL-07** — welcome renderizado contém nome + botão "Acessar plataforma" com href=appUrl | HTML contém nome, `href="${appUrl}"`, e "Acessar plataforma"; subject presente | `registry.test.ts:28-30` — `expect(html).toContain("Maria")` + `expect(html).toContain('href="https://app.example.com"')` + `expect(html).toContain("Acessar plataforma")` (+ `subject` truthy/string `:26-27`) | ✅ PASS | +| **EMAIL-08** — falha do welcome não quebra o registro | `register` resolve com user+session mesmo com sendWelcome rejeitando | `credentials.service.test.ts:207-213` — `sendWelcome.mockRejectedValue(...)` então `expect(result.user).toMatchObject({email})` + `expect(result.session).toEqual({userId, role:"user"})` | ✅ PASS | +| **EMAIL-09** — env de e-mail ausente ⇒ no-op sem quebrar boot | (a) getMailProvider retorna Noop com key vazia; (b) Noop loga e resolve sem lançar | (a) `mail-provider.test.ts:47` — `toBeInstanceOf(FakeNoop)`; (b) `noop.provider.test.ts:28-33` — `resolves.toBeUndefined()` + `logWarn` chamado once + `ctx` `toMatchObject({to})` | ✅ PASS | +| **EMAIL-10** — Valkey down no enqueue ⇒ `send` loga e não propaga | `send` resolve `undefined` + `logError` chamado | `email.service.test.ts:91-102` — `enqueueEmail.mockRejectedValue(new Error("valkey down"))` então `resolves.toBeUndefined()` + `expect(logError).toHaveBeenCalled()` | ✅ PASS | +| **EMAIL-11** — documentação de uso no repositório | Seção de módulo em doc do repo | `BACKEND.md` — nova seção "## Módulo de E-mail" (fluxo, arquivos, uso, envs, como adicionar template/provider) + envs listadas na seção de variáveis | ✅ PASS (verificação documental; sem teste automatizado — apropriado) | + +**Status**: ✅ Todos os 11 ACs cobertos com assertion não-rasa alinhada ao outcome da spec. + +### Edge cases da spec + +- [x] Env de e-mail ausente ⇒ no-op sem quebrar boot — EMAIL-09 (mail-provider + noop tests). +- [x] Valkey down no enqueue ⇒ não propaga — EMAIL-10 (email.service.test.ts:91). +- [x] Template inexistente ⇒ enqueue falha com validação (sem job órfão) — EMAIL-05 (email.service.test.ts:77 + registry.test.ts:33). +- [~] Dois registros do mesmo usuário ⇒ jobs independentes (sem dedup) — comportamento default aceito no MVP; sem teste dedicado, mas a spec o marca como "aceitável" e não define outcome preciso. Não é gap. + +--- + +## Discrimination Sensor + +Mutações injetadas UMA POR VEZ em cópias temporárias (backup em `/tmp`, restauradas após cada rodada). Árvore real intacta ao final. + +| # | Alvo | Mutação | Teste executado | Killed? | +| - | ---- | ------- | --------------- | ------- | +| 1 | `email.service.ts:22` | Desativa validação de `to` (`if (false && ...)`) | `email.service.test.ts` | ✅ Killed (1 failed) | +| 2 | `email.service.ts:30-38` | Remove try/catch — deixa a falha de enqueue PROPAGAR | `email.service.test.ts` | ✅ Killed (teste de resiliência EMAIL-10) | +| 3 | `email.worker.ts:25` | Engole o erro do provider (try/catch vazio) em vez de propagar | `email.worker.test.ts` | ✅ Killed (teste de propagação/retry EMAIL-03) | +| 4 | `welcome.tsx:24` | `href={appUrl}` → `href="#"` (CTA não aponta p/ appUrl) | `registry.test.ts` | ✅ Killed (EMAIL-07) | +| 5 | `credentials.service.ts:87-97` | Remove try/catch — propaga exceção do sendWelcome | `credentials.service.test.ts` | ✅ Killed (EMAIL-08) | + +**Sensor depth**: lightweight fault-injection (5 mutações, alta cobertura das branches de resiliência + payload). +**Result**: 5/5 killed — PASS ✅. Nenhum mutante sobrevivente ⇒ nenhuma fix task. + +--- + +## Code Quality + +| Principle | Status | +| --------- | ------ | +| Minimum code (sem features além do pedido) | ✅ | +| Surgical changes (só arquivos necessários) | ✅ | +| No scope creep (contato/suporte fora, como spec exige) | ✅ | +| Matches patterns (Service coeso, hoisted mocks, singleton lazy à la cache.ts) | ✅ | +| Spec-anchored outcome check (valores asseverados batem com a spec) | ✅ | +| Per-layer coverage (service/queue/worker/providers/registry/integração cada um com happy+edge+erro) | ✅ | +| Todo teste mapeia a um AC/edge/Done-when (sem testes órfãos) | ✅ | +| Guidelines: threshold 80% Vitest; mock de Valkey/BullMQ/provider (design §Risks) seguido | ✅ | + +Observação (não-bloqueante): `EmailQueue`/`EmailWorker`/`ResendProvider` são exercitados via mocks de `bullmq`/`ioredis`/`resend` — apropriado para unit (design marca conexão real como out-of-scope de teste). Não há teste de integração de ponta-a-ponta com Valkey real, o que é consistente com o design (risco "testes lentos/flaky se conectarem de verdade"). + +--- + +## Gate Check + +- **Gate command**: `cd backend && npm test` (`vitest run`) +- **Result**: 529 passed, 0 failed, 0 skipped (55 test files) +- **Delta**: testes de e-mail novos (email.service 5, email.queue 5, email.worker 3, registry 4, mail-provider 2, noop 1, resend 2) + 2 novos em credentials.service (EMAIL-06/08) +- **Flaky pré-existentes**: nenhum acionado nesta rodada; `savedJobs.routes`/`app.test` passaram. +- **Failures**: nenhuma. +- Rodado 2x (antes e depois do sensor) — verde em ambas. + +--- + +## Requirement Traceability Update + +| Requirement | Previous Status | New Status | +| ----------- | --------------- | ---------- | +| EMAIL-01 | Implementing | ✅ Verified | +| EMAIL-02 | Implementing | ✅ Verified | +| EMAIL-03 | Implementing | ✅ Verified | +| EMAIL-04 | Implementing | ✅ Verified | +| EMAIL-05 | Implementing | ✅ Verified | +| EMAIL-06 | Implementing | ✅ Verified | +| EMAIL-07 | Implementing | ✅ Verified | +| EMAIL-08 | Implementing | ✅ Verified | +| EMAIL-09 | Implementing | ✅ Verified | +| EMAIL-10 | Implementing | ✅ Verified | +| EMAIL-11 | Implementing | ✅ Verified | + +--- + +## Summary + +**Overall**: ✅ Ready + +**Spec-anchored check**: 11/11 ACs matched spec outcome — 0 spec-precision gaps. +**Sensor**: 5/5 mutations killed. +**Gate**: 529 passed, 0 failed. + +**What works**: API interna `emailService.send/sendWelcome` (validação + enqueue não-bloqueante), worker render+dispatch, retry/backoff, provider trocável (Resend/Noop) via interface, welcome com nome+CTA para FRONTEND_URL, resiliência (enqueue e welcome nunca quebram o caller), no-op sem env, documentação no BACKEND.md. + +**Issues found**: nenhum. + +**Diff surface covered**: `backend/src/modules/email/**` (service, queue, worker, providers, templates), `credentials.service.ts` (integração welcome), `config.ts`, `server.ts` (boot/shutdown do worker), `.env.example`, `tsconfig.json`, `vitest.config.js`, `BACKEND.md`. `server.ts` boot/shutdown e `config.ts` parsing não têm assertion unitária dedicada, mas seus comportamentos observáveis (worker sobe/desce; envs lidas) são cobertos indiretamente pelos testes de worker/queue/service que consomem esses valores — nível de risco baixo, consistente com o design. + +**Next steps**: marcar a feature como Verified; nenhuma fix task. diff --git a/BACKEND.md b/BACKEND.md index 9c7512c..b2eb227 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -84,6 +84,58 @@ Cache & Indexes: - `src/lib/cache.ts` — helpers para Redis/Valkey; usado por `jobs.routes` para obter ids e buscar vagas em memória. - Busca por palavras-chave usa índices invertidos e interseção para eficiência. +## Módulo de E-mail + +Módulo centralizado em `src/modules/email` para envio de e-mails transacionais de forma **assíncrona e resiliente**. Qualquer módulo do backend consome a mesma API interna (`emailService`), sem conhecer o provedor. + +**Fluxo:** `emailService.send()` valida e enfileira um job na fila BullMQ `email` (sobre Valkey, via `ioredis`) → um worker in-process (`startEmailWorker`, iniciado no boot do `server.ts`) renderiza o template react-email e despacha pelo `MailProvider` configurado. Falha do provedor aciona retry com backoff exponencial; no fracasso final apenas loga. Falha ao enfileirar (ex.: Valkey indisponível) é logada e **não** propaga para o fluxo de negócio chamador. + +**Arquivos:** + +- `email.service.ts` — API interna (`emailService.send` / `sendWelcome`). +- `email.queue.ts` — fila BullMQ + conexão `ioredis` dedicada (`getEmailQueue`, `enqueueEmail`, `closeEmailQueue`). +- `email.worker.ts` — worker in-process (`startEmailWorker`, `stopEmailWorker`). +- `providers/mail-provider.ts` — interface `MailProvider` + `getMailProvider()` (seleciona `ResendProvider` se `EMAIL_API_KEY` presente, senão `NoopProvider`). +- `providers/resend.provider.ts`, `providers/noop.provider.ts` — provedores concretos. +- `templates/registry.ts` + `templates/*.tsx` — templates react-email e lookup por nome. + +**Uso (API interna):** + +```ts +import { emailService } from "./modules/email/email.service"; + +// Envio genérico: valida `to` (formato) e `template` (existe no registry). +await emailService.send({ + template: "welcome", + to: "usuario@exemplo.com", + data: { name: "Ana", appUrl: "https://painelvagas.com" }, +}); + +// Açúcar para boas-vindas: injeta `appUrl` a partir de FRONTEND_URL. +await emailService.sendWelcome({ email: "usuario@exemplo.com", name: "Ana" }); +``` + +`send` resolve sem aguardar a entrega. `to` inválido ou `template` desconhecido lançam `AppError.validation` (antes de enfileirar). + +**Variáveis de ambiente:** + +- `EMAIL_API_KEY` — chave da Resend. Vazia ⇒ `NoopProvider` (apenas loga; não envia, não quebra o boot). +- `EMAIL_FROM_ADDRESS` — endereço remetente (ex.: `no-reply@painelvagas.com`). +- `EMAIL_FROM_NAME` — nome exibido do remetente (ex.: `Painel Vagas`). +- `EMAIL_QUEUE_ATTEMPTS` — nº de tentativas do job (padrão `3`). +- `FRONTEND_URL` — reusada para o botão "Acessar plataforma" do template de boas-vindas (nenhuma env de URL nova é criada). + +**Adicionar um novo template:** + +1. Crie o componente react-email em `src/modules/email/templates/.tsx` (props tipadas), reusando `BaseLayout`. +2. Registre-o no mapa `templates` de `registry.ts` (subject + component) e adicione as props em `TemplateDataMap`. O `TemplateName` e `isTemplate` passam a reconhecê-lo automaticamente. +3. Consuma via `emailService.send({ template: "", to, data })`. + +**Adicionar um novo provider:** + +1. Implemente a interface `MailProvider` (`send({ to, subject, html, replyTo? })`) em `src/modules/email/providers/.provider.ts`. Em falha, **lance** (para o BullMQ re-tentar). +2. Ajuste `getMailProvider()` em `mail-provider.ts` para selecioná-lo pela configuração. Nenhum caller precisa mudar (contrato via interface). + ## Middlewares - `withSession` — integra `iron-session` (sessões + cookie `vagas_session`). @@ -171,7 +223,12 @@ Definidas/consumidas em `src/config.ts` e outros módulos: - `JOB_TYPES` — filtros de tipo de vaga. - `TIME_FILTER` — filtro temporal (ex: `r604800`). - `DATABASE_URL` — conexão com Postgres. -- `VALKEY_URL` — endpoint do Valkey (se usado). +- `VALKEY_URL` — endpoint do Valkey (cache e fila de e-mail via BullMQ). +- `FRONTEND_URL` — URL do frontend; reusada no CTA do e-mail de boas-vindas. +- `EMAIL_API_KEY` — chave da Resend (vazio ⇒ envio no-op logado). +- `EMAIL_FROM_ADDRESS` — endereço remetente dos e-mails. +- `EMAIL_FROM_NAME` — nome exibido do remetente. +- `EMAIL_QUEUE_ATTEMPTS` — tentativas por job de e-mail (padrão 3). - `GO_SCRAPER_URL` — URL do serviço Go que realiza scraping. - `SESSION_SECRET` — senha para `iron-session` (obrigatória em produção). - `ENCRYPTION_MASTER_KEY`, `ENCRYPTION_KEY_ID`, `SEARCH_KEY` — criptografia e campos pesquisáveis de PII. diff --git a/backend/package.json b/backend/package.json index 199dfe6..1556c60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,17 +35,23 @@ "author": "Projeto Open Source - Bene Tesla Dev", "license": "ISC", "dependencies": { + "@react-email/render": "^2.1.0", "argon2": "^0.44.0", "axios": "^1.17.0", + "bullmq": "^5.81.2", "cheerio": "^1.2.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "ioredis": "^5.11.1", "iron-session": "^8.0.4", "pdfkit": "^0.18.0", "prom-client": "^15.1.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", "redis": "^5.12.1", + "resend": "^6.18.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5", @@ -57,6 +63,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", diff --git a/backend/src/config.ts b/backend/src/config.ts index fc98ab7..7856822 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -17,6 +17,11 @@ export interface AppConfig { timeFilter: string; databaseUrl: string; valkeyUrl: string; + frontendUrl: string; + emailApiKey: string; + emailFromAddress: string; + emailFromName: string; + emailQueueAttempts: number; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { @@ -65,5 +70,10 @@ export function getConfig(): AppConfig { timeFilter: parseTimeFilter(process.env.TIME_FILTER, "r604800"), databaseUrl: process.env.DATABASE_URL?.trim() ?? "", valkeyUrl: process.env.VALKEY_URL?.trim() ?? "", + frontendUrl: process.env.FRONTEND_URL?.trim() ?? "", + emailApiKey: process.env.EMAIL_API_KEY?.trim() ?? "", + emailFromAddress: process.env.EMAIL_FROM_ADDRESS?.trim() ?? "", + emailFromName: process.env.EMAIL_FROM_NAME?.trim() ?? "", + emailQueueAttempts: parseNumber(process.env.EMAIL_QUEUE_ATTEMPTS, 3), }; } diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 2bd8384..b5f5dba 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,4 +1,6 @@ import { User } from "../../db/schema/users"; +import { logError } from "../../logger"; +import { emailService } from "../email/email.service"; import type { AuthCallbackParams, OAuthProfile, @@ -39,11 +41,16 @@ export class AuthService { throw new Error("oauth_email_required"); } - const user = await findOrCreateUser({ + const { user, isNewUser } = await findOrCreateUser({ provider, profile, }); + // Boas-vindas apenas no primeiro login social (usuário recém-criado). + if (isNewUser) { + await this.sendWelcomeEmail(user); + } + const session = await this.createSession(user); return { @@ -52,6 +59,26 @@ export class AuthService { }; } + /** + * Dispara o e-mail de boas-vindas para um usuário recém-criado via login + * social. Falha nunca derruba o login (EMAIL-08): erro é apenas logado. + */ + private async sendWelcomeEmail(user: User): Promise { + if (!user.email) return; + + try { + await emailService.sendWelcome({ + email: user.email, + name: user.displayName ?? user.username, + }); + } catch (error) { + logError("Falha ao disparar e-mail de boas-vindas no login social.", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + async getProfileFromProvider({ provider, code, diff --git a/backend/src/modules/auth/credentials.service.ts b/backend/src/modules/auth/credentials.service.ts index a46a081..ef50c67 100644 --- a/backend/src/modules/auth/credentials.service.ts +++ b/backend/src/modules/auth/credentials.service.ts @@ -4,6 +4,8 @@ import { userPreferences } from "../../db/schema"; import { credentials } from "../../db/schema/credentials"; import type { User } from "../../db/schema/users"; import { AppError } from "../../lib/errors"; +import { logError } from "../../logger"; +import { emailService } from "../email/email.service"; import { encryptText } from "../../lib/security/encryption"; import { normalizeEmail } from "../../lib/security/normalization"; import { generateSearchableHash } from "../../lib/security/searchableHash"; @@ -81,6 +83,19 @@ export class CredentialsService { return createdUser; }); + // E-mail de boas-vindas: falha nunca derruba o registro (EMAIL-08). + try { + await emailService.sendWelcome({ + email: user.email, + name: user.displayName ?? user.username, + }); + } catch (error) { + logError("Falha ao disparar e-mail de boas-vindas.", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + return { user, session: { userId: user.id, role: user.role } }; } diff --git a/backend/src/modules/email/email.queue.ts b/backend/src/modules/email/email.queue.ts new file mode 100644 index 0000000..cac43aa --- /dev/null +++ b/backend/src/modules/email/email.queue.ts @@ -0,0 +1,72 @@ +import { Queue } from "bullmq"; +import IORedis, { type Redis } from "ioredis"; +import { getConfig } from "../../config"; +import type { TemplateName } from "./templates/registry"; + +/** + * Nome da fila BullMQ e do job de envio de e-mail. + */ +export const EMAIL_QUEUE_NAME = "email"; +const EMAIL_JOB_NAME = "send-email"; + +/** + * Payload do job enfileirado. Efêmero — sem persistência em DB (só na fila). + */ +export interface EmailJobData { + template: TemplateName; + to: string; + data: Record; +} + +let _connection: Redis | null = null; +let _queue: Queue | null = null; + +/** + * Conexão ioredis dedicada ao BullMQ, separada do node-redis do cache. + * `maxRetriesPerRequest: null` é exigido pelo BullMQ. + */ +export function getEmailConnection(): Redis { + if (_connection) return _connection; + + const { valkeyUrl } = getConfig(); + _connection = new IORedis(valkeyUrl, { maxRetriesPerRequest: null }); + return _connection; +} + +/** + * Singleton lazy da fila `email` sobre Valkey (via ioredis). + */ +export function getEmailQueue(): Queue { + if (_queue) return _queue; + + _queue = new Queue(EMAIL_QUEUE_NAME, { + connection: getEmailConnection(), + }); + return _queue; +} + +/** + * Enfileira um job de e-mail com retry/backoff configuráveis (EMAIL-01/03). + */ +export async function enqueueEmail(data: EmailJobData): Promise { + const { emailQueueAttempts } = getConfig(); + + await getEmailQueue().add(EMAIL_JOB_NAME, data, { + attempts: emailQueueAttempts, + backoff: { type: "exponential", delay: 2000 }, + }); +} + +/** + * Fecha a fila e a conexão no graceful shutdown. + */ +export async function closeEmailQueue(): Promise { + if (_queue) { + await _queue.close(); + _queue = null; + } + if (_connection) { + await _connection.quit(); + _connection = null; + } +} diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts new file mode 100644 index 0000000..3f3e302 --- /dev/null +++ b/backend/src/modules/email/email.service.ts @@ -0,0 +1,65 @@ +import { getConfig } from "../../config"; +import { AppError } from "../../lib/errors"; +import { logError } from "../../logger"; +import { enqueueEmail } from "./email.queue"; +import { isTemplate, type TemplateName } from "./templates/registry"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export interface SendEmailInput { + template: TemplateName; + to: string; + data: Record; +} + +/** + * API interna única de envio de e-mail. Valida e enfileira; nunca aguarda a + * entrega. Erros de validação (bug do caller) lançam `AppError.validation`; + * falha de enqueue (ex.: Valkey down) é logada e engolida (EMAIL-05/10). + */ +export class EmailService { + async send({ template, to, data }: SendEmailInput): Promise { + if (!EMAIL_REGEX.test(to)) { + throw AppError.validation(`E-mail de destino inválido: ${to}`); + } + + if (!isTemplate(template)) { + throw AppError.validation(`Template de e-mail desconhecido: ${template}`); + } + + try { + await enqueueEmail({ template, to, data }); + } catch (error) { + logError("Falha ao enfileirar e-mail.", { + template, + to, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Açúcar sobre `send` para o e-mail de boas-vindas. Injeta `appUrl` a partir + * de `FRONTEND_URL` (config.frontendUrl) para o botão "Acessar plataforma". + */ + async sendWelcome({ + email, + name, + }: { + email: string; + name: string; + }): Promise { + const { frontendUrl } = getConfig(); + + await this.send({ + template: "welcome", + to: email, + data: { name, appUrl: frontendUrl }, + }); + } +} + +/** + * Instância compartilhada para uso pelos callers internos. + */ +export const emailService = new EmailService(); diff --git a/backend/src/modules/email/email.worker.ts b/backend/src/modules/email/email.worker.ts new file mode 100644 index 0000000..125884e --- /dev/null +++ b/backend/src/modules/email/email.worker.ts @@ -0,0 +1,59 @@ +import { type Job, Worker } from "bullmq"; +import { logError, logInfo } from "../../logger"; +import { + EMAIL_QUEUE_NAME, + type EmailJobData, + getEmailConnection, +} from "./email.queue"; +import { getMailProvider } from "./providers/mail-provider"; +import { renderTemplate } from "./templates/registry"; + +let _worker: Worker | null = null; + +/** + * Processa um job: renderiza o template e despacha via provider. + * Um erro do provider é propagado para que o BullMQ re-tente (EMAIL-02/03). + */ +async function processEmailJob(job: Job): Promise { + const { template, to, data } = job.data; + + const { subject, html } = await renderTemplate( + template, + data as never, + ); + + await getMailProvider().send({ to, subject, html }); +} + +/** + * Cria o worker in-process que consome a fila `email`. Chamado no boot. + */ +export function startEmailWorker(): Worker { + if (_worker) return _worker; + + _worker = new Worker(EMAIL_QUEUE_NAME, processEmailJob, { + connection: getEmailConnection(), + }); + + // Fracasso final após esgotar os retries: apenas loga (EMAIL-03). + _worker.on("failed", (job, err) => { + logError("Falha no envio de e-mail após retries.", { + jobId: job?.id, + to: job?.data?.to, + error: err instanceof Error ? err.message : String(err), + }); + }); + + logInfo("Email worker iniciado."); + return _worker; +} + +/** + * Fecha o worker no graceful shutdown. + */ +export async function stopEmailWorker(): Promise { + if (_worker) { + await _worker.close(); + _worker = null; + } +} diff --git a/backend/src/modules/email/providers/mail-provider.ts b/backend/src/modules/email/providers/mail-provider.ts new file mode 100644 index 0000000..d3a4be0 --- /dev/null +++ b/backend/src/modules/email/providers/mail-provider.ts @@ -0,0 +1,31 @@ +import { getConfig } from "../../../config"; +import { NoopProvider } from "./noop.provider"; +import { ResendProvider } from "./resend.provider"; + +/** + * Mensagem já renderizada, pronta para despacho pelo provedor. + */ +export interface MailMessage { + to: string; + subject: string; + html: string; + replyTo?: string; +} + +/** + * Contrato de envio. Desacopla o provedor concreto (Resend, no-op, ...) + * dos callers e do worker. + */ +export interface MailProvider { + send(msg: MailMessage): Promise; +} + +/** + * Seleciona o provedor com base na configuração: + * - `EMAIL_API_KEY` presente => ResendProvider (envia de verdade). + * - `EMAIL_API_KEY` vazio => NoopProvider (apenas loga; EMAIL-09). + */ +export function getMailProvider(): MailProvider { + const { emailApiKey } = getConfig(); + return emailApiKey ? new ResendProvider() : new NoopProvider(); +} diff --git a/backend/src/modules/email/providers/noop.provider.ts b/backend/src/modules/email/providers/noop.provider.ts new file mode 100644 index 0000000..7846837 --- /dev/null +++ b/backend/src/modules/email/providers/noop.provider.ts @@ -0,0 +1,16 @@ +import { logWarn } from "../../../logger"; +import { MailMessage, MailProvider } from "./mail-provider"; + +/** + * Provedor de fallback usado quando nenhuma credencial de e-mail está + * configurada. Loga um aviso e trata o envio como no-op, sem lançar, + * para não derrubar o boot nem o fluxo de negócio (EMAIL-09). + */ +export class NoopProvider implements MailProvider { + async send(msg: MailMessage): Promise { + logWarn("E-mail desabilitado: nenhum provedor configurado.", { + to: msg.to, + subject: msg.subject, + }); + } +} diff --git a/backend/src/modules/email/providers/resend.provider.ts b/backend/src/modules/email/providers/resend.provider.ts new file mode 100644 index 0000000..907a3e0 --- /dev/null +++ b/backend/src/modules/email/providers/resend.provider.ts @@ -0,0 +1,33 @@ +import { Resend } from "resend"; +import { getConfig } from "../../../config"; +import { logError } from "../../../logger"; +import { MailMessage, MailProvider } from "./mail-provider"; + +/** + * Provedor concreto baseado na Resend. Envia o e-mail já renderizado. + * Em caso de falha da API, lança para que o BullMQ re-tente (EMAIL-02/03). + */ +export class ResendProvider implements MailProvider { + async send(msg: MailMessage): Promise { + const { emailApiKey, emailFromAddress, emailFromName } = getConfig(); + const resend = new Resend(emailApiKey); + const from = `${emailFromName} <${emailFromAddress}>`; + + const response = await resend.emails.send({ + from, + to: msg.to, + subject: msg.subject, + html: msg.html, + replyTo: msg.replyTo, + }); + + if (response.error) { + logError("Falha ao enviar e-mail pela Resend.", { + to: msg.to, + subject: msg.subject, + error: response.error.message, + }); + throw new Error(response.error.message); + } + } +} diff --git a/backend/src/modules/email/templates/BaseLayout.tsx b/backend/src/modules/email/templates/BaseLayout.tsx new file mode 100644 index 0000000..4cbe996 --- /dev/null +++ b/backend/src/modules/email/templates/BaseLayout.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; + +interface BaseLayoutProps { + previewText?: string; + children: React.ReactNode; +} + +/** + * Estrutura HTML base compartilhada pelos e-mails transacionais. + * Mantém estilos inline para compatibilidade com clientes de e-mail. + */ +export function BaseLayout({ children }: BaseLayoutProps) { + return ( + + + + + + + + + + + + +
+ {children} +
+ + + ); +} diff --git a/backend/src/modules/email/templates/registry.ts b/backend/src/modules/email/templates/registry.ts new file mode 100644 index 0000000..66f2d78 --- /dev/null +++ b/backend/src/modules/email/templates/registry.ts @@ -0,0 +1,53 @@ +import { render } from "@react-email/render"; +import * as React from "react"; +import { AppError } from "../../../lib/errors"; +import { Welcome, WelcomeProps } from "./welcome"; + +/** + * Mapa de templates disponíveis. Cada entrada define o subject fixo e o + * componente react-email usado para gerar o HTML. + */ +const templates = { + welcome: { + subject: "Bem-vindo ao Candidate", + component: Welcome, + }, +} satisfies Record< + string, + { subject: string; component: (props: never) => React.ReactElement } +>; + +export type TemplateName = keyof typeof templates; + +/** + * Dados aceitos por cada template (props do componente). + */ +export interface TemplateDataMap { + welcome: WelcomeProps; +} + +export function isTemplate(name: string): name is TemplateName { + return Object.prototype.hasOwnProperty.call(templates, name); +} + +/** + * Renderiza o template indicado em HTML. Lança `AppError.validation` quando o + * template não existe (EMAIL-05). O render do react-email é assíncrono. + */ +export async function renderTemplate( + name: T, + data: TemplateDataMap[T], +): Promise<{ subject: string; html: string }> { + if (!isTemplate(name)) { + throw AppError.validation(`Template de e-mail desconhecido: ${name}`); + } + + const entry = templates[name]; + const element = React.createElement( + entry.component as (props: TemplateDataMap[T]) => React.ReactElement, + data, + ); + const html = await render(element); + + return { subject: entry.subject, html }; +} diff --git a/backend/src/modules/email/templates/welcome.tsx b/backend/src/modules/email/templates/welcome.tsx new file mode 100644 index 0000000..9f903c3 --- /dev/null +++ b/backend/src/modules/email/templates/welcome.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { BaseLayout } from "./BaseLayout"; + +export interface WelcomeProps { + name: string; + appUrl: string; +} + +/** + * E-mail de boas-vindas enviado após o registro. Contém o nome do usuário + * e um botão "Acessar plataforma" apontando para o frontend (EMAIL-07). + */ +export function Welcome({ name, appUrl }: WelcomeProps) { + return ( + +

+ Bem-vindo, {name}! +

+

+ Sua conta no Candidate foi criada com sucesso. Agora você pode + acompanhar vagas, salvar oportunidades e organizar sua busca por emprego. +

+ + Acessar plataforma + +
+ ); +} diff --git a/backend/src/modules/users/functions/findOrCreateUser.ts b/backend/src/modules/users/functions/findOrCreateUser.ts index 98752e0..0e009b6 100644 --- a/backend/src/modules/users/functions/findOrCreateUser.ts +++ b/backend/src/modules/users/functions/findOrCreateUser.ts @@ -9,6 +9,16 @@ type FindOrCreateUserParams = { profile: OAuthProfile; }; +/** + * Resultado do `findOrCreateUser`. `isNewUser` distingue o primeiro login + * social (conta recém-criada) de um relogin ou da vinculação de um provider + * a um usuário que já existia — usado para disparar boas-vindas só uma vez. + */ +export type FindOrCreateUserResult = { + user: Awaited>; + isNewUser: boolean; +}; + function mapOAuthProfileToCreateUserParams( profile: OAuthProfile, ): CreateUserParams { @@ -25,14 +35,16 @@ function mapOAuthProfileToCreateUserParams( export async function findOrCreateUser({ provider, profile, -}: FindOrCreateUserParams) { +}: FindOrCreateUserParams): Promise { return db.transaction(async (tx) => { const existingByProvider = await findUserByProvider( { provider, providerAccountId: profile.id }, tx, ); - if (existingByProvider) return existingByProvider; + // Relogin social: conta já existia para este provider. + if (existingByProvider) + return { user: existingByProvider, isNewUser: false }; if (profile.email) { const existingByEmail = await findUserByEmail(profile.email, tx); @@ -47,7 +59,9 @@ export async function findOrCreateUser({ tx, ); - return existingByEmail; + // Usuário já existia (ex.: cadastro por e-mail/senha) — só vincula o + // provider, sem reenviar boas-vindas. + return { user: existingByEmail, isNewUser: false }; } } @@ -66,6 +80,7 @@ export async function findOrCreateUser({ tx, ); - return newUser; + // Primeiro login social: usuário recém-criado. + return { user: newUser, isNewUser: true }; }); } diff --git a/backend/src/server.ts b/backend/src/server.ts index bd0b350..c8705be 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,9 @@ import "dotenv/config"; import { createJobsApiApp } from "./app"; -import { logInfo, logWarn } from "./logger"; +import { closeCache } from "./lib/cache"; +import { logError, logInfo, logWarn } from "./logger"; +import { closeEmailQueue } from "./modules/email/email.queue"; +import { startEmailWorker, stopEmailWorker } from "./modules/email/email.worker"; const PORT = Number(process.env.PORT ?? 3001); @@ -22,10 +25,31 @@ async function registerSwaggerDocs(): Promise { async function startServer(): Promise { await registerSwaggerDocs(); + // Worker in-process de e-mail: sobe junto do servidor (EMAIL-02). + startEmailWorker(); + app.listen(PORT, () => { logInfo(`API rodando em http://localhost:${PORT}`); logInfo(`Documentação da API em http://localhost:${PORT}/docs`); }); } +async function shutdown(signal: string): Promise { + logInfo(`Recebido ${signal}, encerrando recursos...`); + try { + await stopEmailWorker(); + await closeEmailQueue(); + await closeCache(); + } catch (error) { + logError("Erro no graceful shutdown", { + error: error instanceof Error ? error.message : error, + }); + } finally { + process.exit(0); + } +} + +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + void startServer(); diff --git a/backend/tests/unit/modules/auth/auth.service.test.ts b/backend/tests/unit/modules/auth/auth.service.test.ts index a379f0e..9382037 100644 --- a/backend/tests/unit/modules/auth/auth.service.test.ts +++ b/backend/tests/unit/modules/auth/auth.service.test.ts @@ -4,6 +4,13 @@ const mocks = vi.hoisted(() => ({ getAuthUrl: vi.fn(), exchangeCode: vi.fn(), findOrCreateUser: vi.fn(), + sendWelcome: vi.fn(), +})); + +vi.mock("../../../../src/modules/email/email.service", () => ({ + emailService: { + sendWelcome: mocks.sendWelcome, + }, })); vi.mock("../../../../src/modules/auth/providers/auth.provider", () => ({ @@ -113,7 +120,51 @@ describe("AuthService", () => { describe("handleCallback", () => { it("returns user and session on success", async () => { mocks.exchangeCode.mockResolvedValueOnce(mockProfile); - mocks.findOrCreateUser.mockResolvedValueOnce(mockUser); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); + + const result = await service.handleCallback(validCallbackParams); + + expect(result.user).toEqual(mockUser); + expect(result.session).toEqual({ userId: "uuid-123", role: "user" }); + }); + + it("enfileira boas-vindas quando é o primeiro login social (usuário novo)", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: true, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: mockUser.username, + }); + }); + + it("não enfileira boas-vindas quando o usuário já existia (relogin/vínculo)", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).not.toHaveBeenCalled(); + }); + + it("conclui o login mesmo se o envio de boas-vindas falhar", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: true, + }); + mocks.sendWelcome.mockRejectedValueOnce(new Error("valkey down")); const result = await service.handleCallback(validCallbackParams); @@ -153,7 +204,10 @@ describe("AuthService", () => { it("calls findOrCreateUser with provider and profile", async () => { mocks.exchangeCode.mockResolvedValueOnce(mockProfile); - mocks.findOrCreateUser.mockResolvedValueOnce(mockUser); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); await service.handleCallback(validCallbackParams); diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index 49d7b23..5ff325f 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), // generateUsername generateUsername: vi.fn(), + // email de boas-vindas + sendWelcome: vi.fn(), })); // ─── Mock: database client ──────────────────────────────────────────────────── @@ -53,6 +55,14 @@ vi.mock("../../../../src/utils/generateUsername", () => ({ generateUsername: mocks.generateUsername, })); +// ─── Mock: EmailService (boas-vindas) ───────────────────────────────────────── + +vi.mock("../../../../src/modules/email/email.service", () => ({ + emailService: { + sendWelcome: mocks.sendWelcome, + }, +})); + // ─── Import after mocks ─────────────────────────────────────────────────────── import { CredentialsService } from "../../../../src/modules/auth/credentials.service"; @@ -121,6 +131,7 @@ describe("CredentialsService", () => { mocks.verify.mockResolvedValue(true); mocks.hash.mockResolvedValue("$argon2id$hashed"); mocks.generateUsername.mockResolvedValue("mocked-username"); + mocks.sendWelcome.mockResolvedValue(undefined); service = new CredentialsService(); }); @@ -197,6 +208,24 @@ describe("CredentialsService", () => { expect(session).toEqual({ userId: "uuid-user-1", role: "user" }); }); + + it("dispara e-mail de boas-vindas com email + nome no sucesso (EMAIL-06)", async () => { + await service.register(registerInput); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: mockUser.displayName, + }); + }); + + it("conclui o registro mesmo se o envio de boas-vindas falhar (EMAIL-08)", async () => { + mocks.sendWelcome.mockRejectedValue(new Error("fila indisponível")); + + const result = await service.register(registerInput); + + expect(result.user).toMatchObject({ email: mockUser.email }); + expect(result.session).toEqual({ userId: mockUser.id, role: "user" }); + }); }); // ── login ───────────────────────────────────────────────────────────────── diff --git a/backend/tests/unit/modules/email/email.queue.test.ts b/backend/tests/unit/modules/email/email.queue.test.ts new file mode 100644 index 0000000..7f969ab --- /dev/null +++ b/backend/tests/unit/modules/email/email.queue.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const bullmqMocks = vi.hoisted(() => ({ + add: vi.fn(), + close: vi.fn(), + QueueConstructor: vi.fn(), +})); + +const ioredisMocks = vi.hoisted(() => ({ + RedisConstructor: vi.fn(), + quit: vi.fn(), +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/logger", () => ({ + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock("bullmq", () => ({ + Queue: class { + add = bullmqMocks.add; + close = bullmqMocks.close; + constructor(name: string, opts: unknown) { + bullmqMocks.QueueConstructor(name, opts); + } + }, +})); + +vi.mock("ioredis", () => ({ + default: class { + quit = ioredisMocks.quit; + constructor(url: string, opts: unknown) { + ioredisMocks.RedisConstructor(url, opts); + } + }, +})); + +import { + closeEmailQueue, + enqueueEmail, + getEmailQueue, +} from "../../../../src/modules/email/email.queue"; + +describe("EmailQueue", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.getConfig.mockReturnValue({ + valkeyUrl: "redis://localhost:6379", + emailQueueAttempts: 3, + }); + }); + + afterEach(async () => { + await closeEmailQueue(); + }); + + it("cria a conexão ioredis com maxRetriesPerRequest null (EMAIL-01)", () => { + getEmailQueue(); + + expect(ioredisMocks.RedisConstructor).toHaveBeenCalledWith( + "redis://localhost:6379", + { maxRetriesPerRequest: null }, + ); + }); + + it("cria a fila 'email' reutilizando a mesma instância (singleton)", () => { + const first = getEmailQueue(); + const second = getEmailQueue(); + + expect(first).toBe(second); + expect(bullmqMocks.QueueConstructor).toHaveBeenCalledTimes(1); + expect(bullmqMocks.QueueConstructor).toHaveBeenCalledWith( + "email", + expect.objectContaining({ connection: expect.anything() }), + ); + }); + + it("enfileira job com attempts do config e backoff exponencial (EMAIL-03)", async () => { + const data = { template: "welcome", to: "user@example.com", data: {} }; + + await enqueueEmail(data); + + expect(bullmqMocks.add).toHaveBeenCalledWith( + "send-email", + data, + expect.objectContaining({ + attempts: 3, + backoff: { type: "exponential", delay: 2000 }, + }), + ); + }); + + it("usa o valor de emailQueueAttempts do config para attempts", async () => { + configMocks.getConfig.mockReturnValue({ + valkeyUrl: "redis://localhost:6379", + emailQueueAttempts: 5, + }); + + await enqueueEmail({ template: "welcome", to: "a@b.com", data: {} }); + + expect(bullmqMocks.add).toHaveBeenCalledWith( + "send-email", + expect.anything(), + expect.objectContaining({ attempts: 5 }), + ); + }); + + it("fecha a fila e a conexão no closeEmailQueue", async () => { + getEmailQueue(); + + await closeEmailQueue(); + + expect(bullmqMocks.close).toHaveBeenCalled(); + expect(ioredisMocks.quit).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/email/email.service.test.ts b/backend/tests/unit/modules/email/email.service.test.ts new file mode 100644 index 0000000..22703fb --- /dev/null +++ b/backend/tests/unit/modules/email/email.service.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const queueMocks = vi.hoisted(() => ({ + enqueueEmail: vi.fn(), +})); + +const registryMocks = vi.hoisted(() => ({ + isTemplate: vi.fn(), +})); + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const loggerMocks = vi.hoisted(() => ({ + logError: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/modules/email/email.queue", () => ({ + enqueueEmail: queueMocks.enqueueEmail, +})); + +vi.mock("../../../../src/modules/email/templates/registry", () => ({ + isTemplate: registryMocks.isTemplate, +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/logger", () => loggerMocks); + +import { EmailService } from "../../../../src/modules/email/email.service"; + +describe("EmailService", () => { + let service: EmailService; + + beforeEach(() => { + vi.clearAllMocks(); + registryMocks.isTemplate.mockReturnValue(true); + queueMocks.enqueueEmail.mockResolvedValue(undefined); + configMocks.getConfig.mockReturnValue({ + frontendUrl: "https://painelvagas.com", + }); + service = new EmailService(); + }); + + describe("send", () => { + it("enfileira job com { template, to, data } no caminho feliz (EMAIL-01)", async () => { + await service.send({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana" }, + }); + + expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana" }, + }); + }); + + it("lança AppError.validation e NÃO enfileira quando 'to' é inválido (EMAIL-05)", async () => { + await expect( + service.send({ + template: "welcome", + to: "nao-e-email", + data: {}, + }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(queueMocks.enqueueEmail).not.toHaveBeenCalled(); + }); + + it("lança AppError.validation e NÃO enfileira quando template é desconhecido (EMAIL-05)", async () => { + registryMocks.isTemplate.mockReturnValue(false); + + await expect( + service.send({ + template: "inexistente" as never, + to: "user@example.com", + data: {}, + }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(queueMocks.enqueueEmail).not.toHaveBeenCalled(); + }); + + it("engole falha do enqueue: send resolve e loga o erro (EMAIL-10)", async () => { + queueMocks.enqueueEmail.mockRejectedValue(new Error("valkey down")); + + await expect( + service.send({ + template: "welcome", + to: "user@example.com", + data: {}, + }), + ).resolves.toBeUndefined(); + + expect(loggerMocks.logError).toHaveBeenCalled(); + }); + }); + + describe("sendWelcome", () => { + it("injeta appUrl do frontendUrl e usa template welcome (EMAIL-06/07)", async () => { + await service.sendWelcome({ + email: "user@example.com", + name: "Ana", + }); + + expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana", appUrl: "https://painelvagas.com" }, + }); + }); + }); +}); diff --git a/backend/tests/unit/modules/email/email.worker.test.ts b/backend/tests/unit/modules/email/email.worker.test.ts new file mode 100644 index 0000000..35e6dc5 --- /dev/null +++ b/backend/tests/unit/modules/email/email.worker.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const registryMocks = vi.hoisted(() => ({ + renderTemplate: vi.fn(), +})); + +const providerMocks = vi.hoisted(() => ({ + send: vi.fn(), + getMailProvider: vi.fn(), +})); + +const queueMocks = vi.hoisted(() => ({ + getEmailConnection: vi.fn(() => ({})), +})); + +const loggerMocks = vi.hoisted(() => ({ + logError: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +// Captura o processor e os handlers registrados no Worker. +const workerState = vi.hoisted(() => ({ + processor: null as + | ((job: { data: unknown }) => Promise) + | null, + handlers: {} as Record void>, + close: vi.fn(), + on: vi.fn(), +})); + +vi.mock("bullmq", () => ({ + Worker: class { + close = workerState.close; + constructor( + _name: string, + processor: (job: { data: unknown }) => Promise, + ) { + workerState.processor = processor; + } + on(event: string, handler: (...args: unknown[]) => void) { + workerState.handlers[event] = handler; + workerState.on(event, handler); + return this; + } + }, +})); + +vi.mock("../../../../src/modules/email/email.queue", () => ({ + EMAIL_QUEUE_NAME: "email", + getEmailConnection: queueMocks.getEmailConnection, +})); + +vi.mock("../../../../src/modules/email/templates/registry", () => ({ + renderTemplate: registryMocks.renderTemplate, +})); + +vi.mock("../../../../src/modules/email/providers/mail-provider", () => ({ + getMailProvider: providerMocks.getMailProvider, +})); + +vi.mock("../../../../src/logger", () => loggerMocks); + +import { + startEmailWorker, + stopEmailWorker, +} from "../../../../src/modules/email/email.worker"; + +describe("EmailWorker", () => { + beforeEach(() => { + vi.clearAllMocks(); + workerState.processor = null; + workerState.handlers = {}; + providerMocks.getMailProvider.mockReturnValue({ send: providerMocks.send }); + registryMocks.renderTemplate.mockResolvedValue({ + subject: "Bem-vindo", + html: "

Olá

", + }); + providerMocks.send.mockResolvedValue(undefined); + }); + + afterEach(async () => { + await stopEmailWorker(); + }); + + it("renderiza o template e despacha via provider com to/subject/html (EMAIL-02)", async () => { + startEmailWorker(); + const job = { + data: { template: "welcome", to: "user@example.com", data: { name: "Ana" } }, + }; + + await workerState.processor!(job); + + expect(registryMocks.renderTemplate).toHaveBeenCalledWith("welcome", { + name: "Ana", + }); + expect(providerMocks.send).toHaveBeenCalledWith({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + }); + }); + + it("propaga o erro do provider para permitir retry do BullMQ (EMAIL-03)", async () => { + providerMocks.send.mockRejectedValue(new Error("provider down")); + startEmailWorker(); + const job = { + data: { template: "welcome", to: "user@example.com", data: {} }, + }; + + await expect(workerState.processor!(job)).rejects.toThrow("provider down"); + }); + + it("registra o erro no handler 'failed' sem lançar (EMAIL-03)", () => { + startEmailWorker(); + const failedHandler = workerState.handlers["failed"]; + expect(failedHandler).toBeDefined(); + + const job = { id: "job-1", data: { to: "user@example.com" } }; + expect(() => + failedHandler(job, new Error("fracasso final")), + ).not.toThrow(); + expect(loggerMocks.logError).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/email/mail-provider.test.ts b/backend/tests/unit/modules/email/mail-provider.test.ts new file mode 100644 index 0000000..1eb4b63 --- /dev/null +++ b/backend/tests/unit/modules/email/mail-provider.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +// Providers concretos mockados (definidos em vi.hoisted para poder ser +// referenciados dentro das factories de vi.mock, que são içadas ao topo). +const providerMocks = vi.hoisted(() => ({ + FakeResend: class FakeResend {}, + FakeNoop: class FakeNoop {}, +})); +const { FakeResend, FakeNoop } = providerMocks; + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/modules/email/providers/resend.provider", () => ({ + ResendProvider: providerMocks.FakeResend, +})); + +vi.mock("../../../../src/modules/email/providers/noop.provider", () => ({ + NoopProvider: providerMocks.FakeNoop, +})); + +import { getMailProvider } from "../../../../src/modules/email/providers/mail-provider"; + +function baseConfig(overrides: Record = {}) { + return { + emailApiKey: "", + emailFromAddress: "no-reply@example.com", + emailFromName: "Painel Vagas", + ...overrides, + }; +} + +describe("getMailProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retorna NoopProvider quando emailApiKey está vazio (EMAIL-09)", () => { + configMocks.getConfig.mockReturnValue(baseConfig({ emailApiKey: "" })); + + const provider = getMailProvider(); + + expect(provider).toBeInstanceOf(FakeNoop); + }); + + it("retorna ResendProvider quando emailApiKey está presente (EMAIL-04)", () => { + configMocks.getConfig.mockReturnValue( + baseConfig({ emailApiKey: "re_abc123" }), + ); + + const provider = getMailProvider(); + + expect(provider).toBeInstanceOf(FakeResend); + }); +}); diff --git a/backend/tests/unit/modules/email/noop.provider.test.ts b/backend/tests/unit/modules/email/noop.provider.test.ts new file mode 100644 index 0000000..0b5b5ad --- /dev/null +++ b/backend/tests/unit/modules/email/noop.provider.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loggerMocks = vi.hoisted(() => ({ + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/logger", () => ({ + logWarn: loggerMocks.logWarn, + logInfo: vi.fn(), + logError: vi.fn(), +})); + +import { NoopProvider } from "../../../../src/modules/email/providers/noop.provider"; + +describe("NoopProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loga aviso e resolve sem lançar (EMAIL-09)", async () => { + const provider = new NoopProvider(); + + await expect( + provider.send({ + to: "user@example.com", + subject: "Assunto", + html: "

oi

", + }), + ).resolves.toBeUndefined(); + + expect(loggerMocks.logWarn).toHaveBeenCalledOnce(); + const [, ctx] = loggerMocks.logWarn.mock.calls[0]; + expect(ctx).toMatchObject({ to: "user@example.com" }); + }); +}); diff --git a/backend/tests/unit/modules/email/registry.test.ts b/backend/tests/unit/modules/email/registry.test.ts new file mode 100644 index 0000000..10a8e10 --- /dev/null +++ b/backend/tests/unit/modules/email/registry.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + isTemplate, + renderTemplate, +} from "../../../../src/modules/email/templates/registry"; + +describe("TemplateRegistry", () => { + describe("isTemplate", () => { + it("reconhece 'welcome' como template válido", () => { + expect(isTemplate("welcome")).toBe(true); + }); + + it("rejeita nome desconhecido (EMAIL-05)", () => { + expect(isTemplate("desconhecido")).toBe(false); + }); + }); + + describe("renderTemplate", () => { + it("renderiza 'welcome' com nome, CTA apontando para appUrl e subject (EMAIL-07)", async () => { + const appUrl = "https://app.example.com"; + const { subject, html } = await renderTemplate("welcome", { + name: "Maria", + appUrl, + }); + + expect(subject).toBeTruthy(); + expect(typeof subject).toBe("string"); + expect(html).toContain("Maria"); + expect(html).toContain(`href="${appUrl}"`); + expect(html).toContain("Acessar plataforma"); + }); + + it("rejeita template desconhecido (EMAIL-05)", async () => { + await expect( + // @ts-expect-error nome inválido é rejeitado em runtime + renderTemplate("desconhecido", { name: "X", appUrl: "https://x" }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/backend/tests/unit/modules/email/resend.provider.test.ts b/backend/tests/unit/modules/email/resend.provider.test.ts new file mode 100644 index 0000000..35774f8 --- /dev/null +++ b/backend/tests/unit/modules/email/resend.provider.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const resendMocks = vi.hoisted(() => ({ + send: vi.fn(), + constructor: vi.fn(), +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("resend", () => ({ + Resend: class { + emails = { send: resendMocks.send }; + constructor(apiKey: string) { + resendMocks.constructor(apiKey); + } + }, +})); + +vi.mock("../../../../src/logger", () => ({ + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +import { ResendProvider } from "../../../../src/modules/email/providers/resend.provider"; + +describe("ResendProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.getConfig.mockReturnValue({ + emailApiKey: "re_abc123", + emailFromAddress: "no-reply@painelvagas.com", + emailFromName: "Painel Vagas", + }); + }); + + it("chama o SDK com from formatado e campos mapeados (EMAIL-02/04)", async () => { + resendMocks.send.mockResolvedValue({ data: { id: "eml_1" }, error: null }); + const provider = new ResendProvider(); + + await provider.send({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + replyTo: "suporte@painelvagas.com", + }); + + expect(resendMocks.constructor).toHaveBeenCalledWith("re_abc123"); + expect(resendMocks.send).toHaveBeenCalledWith({ + from: "Painel Vagas ", + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + replyTo: "suporte@painelvagas.com", + }); + }); + + it("propaga (lança) quando o SDK retorna erro para permitir retry (EMAIL-03)", async () => { + resendMocks.send.mockResolvedValue({ + data: null, + error: { message: "rate limit", name: "rate_limit_exceeded" }, + }); + const provider = new ResendProvider(); + + await expect( + provider.send({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + }), + ).rejects.toThrow("rate limit"); + }); +}); diff --git a/backend/tests/unit/modules/users/functions/users.functions.test.ts b/backend/tests/unit/modules/users/functions/users.functions.test.ts index 1e82663..9877215 100644 --- a/backend/tests/unit/modules/users/functions/users.functions.test.ts +++ b/backend/tests/unit/modules/users/functions/users.functions.test.ts @@ -138,7 +138,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: false }); }); it("encontra por email, cria account e retorna usuário existente", async () => { @@ -152,7 +152,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: false }); expect(mocks.createAccountMock).toHaveBeenCalledWith( { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), @@ -187,7 +187,7 @@ describe("findOrCreateUser", () => { { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), ); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: true }); }); it("pula a busca por email quando profile.email é undefined", async () => { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 368c555..84e28c4 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true diff --git a/backend/vitest.config.js b/backend/vitest.config.js index 9730815..1091c47 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -22,6 +22,19 @@ export default defineConfig(({ mode }) => { const env = loadTestEnv(mode ?? "test"); return { + // Habilita o runtime automático de JSX (react-email templates .tsx). + // O backend usa rolldown-vite (transform via oxc); mantemos também a + // chave esbuild para compatibilidade caso o vite padrão seja usado. + esbuild: { + jsx: "automatic", + jsxDev: false, + }, + oxc: { + jsx: { + runtime: "automatic", + development: false, + }, + }, test: { globals: true, environment: "node", diff --git a/package-lock.json b/package-lock.json index 02c8f26..61d1c8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,17 +54,23 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@react-email/render": "^2.1.0", "argon2": "^0.44.0", "axios": "^1.17.0", + "bullmq": "^5.81.2", "cheerio": "^1.2.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "ioredis": "^5.11.1", "iron-session": "^8.0.4", "pdfkit": "^0.18.0", "prom-client": "^15.1.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", "redis": "^5.12.1", + "resend": "^6.18.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5", @@ -73,6 +79,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", @@ -1991,20 +1998,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -2012,9 +2019,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -2051,11 +2058,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -2220,11 +2229,13 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2457,11 +2468,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2756,6 +2769,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2965,6 +2984,84 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -4646,6 +4743,25 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@react-email/render": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.1.0.tgz", + "integrity": "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0", + "html-to-text": "^9.0.5", + "html5parser": "^3.0.0", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@redis/bloom": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", @@ -4928,6 +5044,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", @@ -4979,6 +5126,19 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", @@ -5033,6 +5193,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -7258,6 +7424,31 @@ "node": ">= 14" } }, + "node_modules/bullmq": { + "version": "5.81.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.2.tgz", + "integrity": "sha512-Hi9GaVCC6HE9bQP65j/FNv1aL1fcEukTF99ezS5pl1Ud+joCFpNWiPCV45mWdkEmpuacS0XJdMMFGKPIHCwoPg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -8275,6 +8466,18 @@ "node": ">=0.8" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -8613,6 +8816,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10102,11 +10314,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -10118,11 +10332,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10134,11 +10350,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10150,11 +10368,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10166,11 +10386,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10182,11 +10404,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10198,11 +10422,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10214,11 +10440,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10230,11 +10458,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10246,11 +10476,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10262,11 +10494,13 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10278,11 +10512,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10294,11 +10530,13 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10310,11 +10548,13 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10326,11 +10566,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10342,11 +10584,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10358,11 +10602,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10374,11 +10620,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10390,11 +10638,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10406,11 +10656,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -10422,11 +10674,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -10438,11 +10692,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10454,11 +10710,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -11093,6 +11351,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -11927,6 +12191,47 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "license": "MIT" }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/html5parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html5parser/-/html5parser-3.0.0.tgz", + "integrity": "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==", + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -12185,6 +12490,37 @@ } } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -12775,6 +13111,15 @@ "dev": true, "license": "MIT" }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12836,6 +13181,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12856,6 +13202,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12876,6 +13223,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12896,6 +13244,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12916,6 +13265,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12936,6 +13286,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12956,6 +13307,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12976,6 +13328,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12996,6 +13349,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13016,6 +13370,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13036,6 +13391,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13375,6 +13731,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -13706,6 +14071,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -13772,6 +14168,12 @@ "node": ">=22.12.0" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", @@ -13827,6 +14229,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -14438,6 +14855,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14571,6 +15001,15 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -14871,6 +15310,12 @@ "browserify-zlib": "^0.2.0" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -15112,6 +15557,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -15525,24 +15985,24 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-icons": { @@ -15806,6 +16266,27 @@ "node": ">= 18.19.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15843,6 +16324,27 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/resend": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.0.tgz", + "integrity": "sha512-EjxZ9AVzywJgOlUoIJe9ytBWVrfbUtJbjeoLnRSvpU1sv97Hh9DSwhw+k8kiujrG4Rg4bzTBsjlmwWWuoOxSug==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -16181,6 +16683,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -16606,6 +17120,22 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -18320,22 +18850,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "optional": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",