diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index c5c577b..e19700e 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -75,6 +75,7 @@ dependencies = [ "sysinfo", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", @@ -545,6 +546,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -653,6 +674,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -850,6 +877,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1539,6 +1575,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -2673,6 +2715,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3429,6 +3481,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -4217,6 +4279,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -4523,6 +4606,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -5404,6 +5496,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.1.2" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c86cd0e..ccb98f0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-plugin-store = "2.4.0" tauri-plugin-updater = "2.9.0" tauri-plugin-process = "2.3.0" tauri-plugin-notification = "2" +tauri-plugin-deep-link = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 396e9de..7e45c7d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -8,7 +8,7 @@ use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfi use chat::{ChatRuntime, ChatSession, ChatMessagesResponse, SendMessageResponse}; use chrono::Local; use usb_control::{UsbPolicy, UsbPolicyResult}; -use tauri::{Emitter, Manager, WindowEvent}; +use tauri::{Emitter, Listener, Manager, WindowEvent}; use tauri_plugin_store::Builder as StorePluginBuilder; use std::fs::OpenOptions; use std::io::Write; @@ -348,6 +348,81 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool) chat::set_chat_minimized(&app, &ticket_id, minimized) } +// ============================================================================ +// Handler de Deep Link (raven://) +// ============================================================================ + +/// Processa URLs do protocolo raven:// +/// Formatos suportados: +/// - raven://ticket/{token} - Abre visualizacao do chamado +/// - raven://chat/{ticketId}?token={token} - Abre chat do chamado +/// - raven://rate/{token} - Abre avaliacao do chamado +fn handle_deep_link(app: &tauri::AppHandle, url: &str) { + log_info!("Processando deep link: {url}"); + + // Remove o prefixo raven:// + let path = url.trim_start_matches("raven://"); + + // Parse do path + let parts: Vec<&str> = path.split('/').collect(); + + if parts.is_empty() { + log_warn!("Deep link invalido: path vazio"); + return; + } + + match parts[0] { + "ticket" => { + if parts.len() > 1 { + let token = parts[1].split('?').next().unwrap_or(parts[1]); + log_info!("Abrindo ticket com token: {token}"); + + // Mostra a janela principal + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + + // Emite evento para o frontend navegar para o ticket + let _ = app.emit("raven://deep-link/ticket", serde_json::json!({ + "token": token + })); + } + } + } + "chat" => { + if parts.len() > 1 { + let ticket_id = parts[1].split('?').next().unwrap_or(parts[1]); + log_info!("Abrindo chat do ticket: {ticket_id}"); + + // Abre janela de chat + if let Err(e) = chat::open_chat_window(app, ticket_id) { + log_error!("Falha ao abrir chat: {e}"); + } + } + } + "rate" => { + if parts.len() > 1 { + let token = parts[1].split('?').next().unwrap_or(parts[1]); + log_info!("Abrindo avaliacao com token: {token}"); + + // Mostra a janela principal + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + + // Emite evento para o frontend navegar para avaliacao + let _ = app.emit("raven://deep-link/rate", serde_json::json!({ + "token": token + })); + } + } + } + _ => { + log_warn!("Deep link desconhecido: {path}"); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -358,6 +433,7 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_deep_link::init()) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -372,6 +448,17 @@ pub fn run() { log_info!("Raven iniciando..."); + // Configura handler de deep link (raven://) + #[cfg(desktop)] + { + let handle = app.handle().clone(); + app.listen("deep-link://new-url", move |event| { + let urls = event.payload(); + log_info!("Deep link recebido: {urls}"); + handle_deep_link(&handle, urls); + }); + } + #[cfg(target_os = "windows")] { setup_raven_autostart(); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index ea5aeaf..7703ac6 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -33,6 +33,11 @@ "dialog": true, "active": true, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK" + }, + "deep-link": { + "desktop": { + "schemes": ["raven"] + } } }, "bundle": { diff --git a/docs/EMAIL-NOTIFICATIONS.md b/docs/EMAIL-NOTIFICATIONS.md new file mode 100644 index 0000000..77eed8b --- /dev/null +++ b/docs/EMAIL-NOTIFICATIONS.md @@ -0,0 +1,476 @@ +# Sistema de Notificacoes por E-mail + +Este documento descreve o sistema completo de notificacoes por e-mail implementado na plataforma Raven. + +## Visao Geral + +O sistema de notificacoes por e-mail permite que usuarios sejam informados sobre eventos importantes relacionados aos seus chamados, alem de notificacoes de seguranca e autenticacao. + +### Principais Funcionalidades + +- Notificacoes de ciclo de vida do chamado (abertura, resolucao, atribuicao) +- Notificacoes de comunicacao (comentarios publicos, respostas) +- Sistema de avaliacao de chamados (estrelas 1-5) +- Acesso direto ao chamado via protocolo `raven://` +- Templates HTML responsivos +- Sistema de preferencias configuravel +- Alertas de SLA para staff +- Notificacoes de seguranca (reset de senha, novo login) + +--- + +## Tipos de Notificacoes + +### Ciclo de Vida do Chamado + +| Tipo | Codigo | Destinatario | Pode Desativar? | +|------|--------|-------------|-----------------| +| Abertura de chamado | `ticket_created` | Solicitante | Nao (obrigatorio) | +| Atribuicao de chamado | `ticket_assigned` | Solicitante + Agente | Sim | +| Resolucao de chamado | `ticket_resolved` | Solicitante | Nao (obrigatorio) | +| Reabertura de chamado | `ticket_reopened` | Solicitante + Agente | Sim | +| Mudanca de status | `ticket_status_changed` | Solicitante | Sim | +| Mudanca de prioridade | `ticket_priority_changed` | Agente atribuido | Sim | + +**Descricoes detalhadas:** + +- **Abertura de chamado**: Enviado ao solicitante quando um novo chamado e criado, confirmando o numero de referencia e detalhes do chamado. + +- **Atribuicao de chamado**: Notifica tanto o solicitante quanto o agente atribuido quando um chamado e designado para atendimento. + +- **Resolucao de chamado**: Enviado ao solicitante quando o chamado e marcado como resolvido. Inclui link para avaliacao com estrelas (1-5). + +- **Reabertura de chamado**: Notifica todas as partes quando um chamado fechado e reaberto. + +- **Mudanca de status**: Informa o solicitante sobre alteracoes de status (Em andamento, Pausado, etc). + +- **Mudanca de prioridade**: Notifica o agente atribuido quando a prioridade do chamado e alterada. + +### Comunicacao + +| Tipo | Codigo | Destinatario | Pode Desativar? | +|------|--------|-------------|-----------------| +| Comentario publico | `comment_public` | Solicitante | Sim (apenas solicitante) | +| Resposta do solicitante | `comment_response` | Agente atribuido | Sim | +| Mencao em comentario | `comment_mention` | Usuario mencionado | Sim | + +**Descricoes detalhadas:** + +- **Comentario publico**: Enviado ao solicitante quando um agente adiciona um comentario publico ao chamado. + +- **Resposta do solicitante**: Notifica o agente atribuido quando o solicitante responde ao chamado. + +- **Mencao em comentario**: Notifica um usuario quando ele e mencionado (@usuario) em um comentario. + +### SLA e Alertas (Apenas Staff) + +| Tipo | Codigo | Destinatario | Pode Desativar? | +|------|--------|-------------|-----------------| +| SLA em risco | `sla_at_risk` | Agente + Supervisor | Sim | +| SLA violado | `sla_breached` | Agente + Admin | Sim | +| Resumo diario de SLA | `sla_daily_digest` | Admin | Sim | + +**Descricoes detalhadas:** + +- **SLA em risco**: Enviado quando o chamado atinge 80% do tempo limite de resposta/resolucao. + +- **SLA violado**: Notificacao urgente quando o SLA e excedido. + +- **Resumo diario de SLA**: Relatorio enviado diariamente com metricas de SLA do dia anterior. + +### Seguranca e Autenticacao + +| Tipo | Codigo | Destinatario | Pode Desativar? | +|------|--------|-------------|-----------------| +| Reset de senha | `security_password_reset` | Usuario | Nao (obrigatorio) | +| Verificacao de e-mail | `security_email_verify` | Usuario | Nao (obrigatorio) | +| Alteracao de e-mail | `security_email_change` | E-mail antigo | Nao (obrigatorio) | +| Novo login detectado | `security_new_login` | Usuario | Sim | +| Convite de usuario | `security_invite` | Novo usuario | Nao (obrigatorio) | + +**Descricoes detalhadas:** + +- **Reset de senha**: Link para redefinicao de senha. Expira em 1 hora. + +- **Verificacao de e-mail**: Link para confirmar novo endereco de e-mail. + +- **Alteracao de e-mail**: Notifica o e-mail antigo quando o endereco e alterado. + +- **Novo login detectado**: Alerta quando um login e realizado de um novo dispositivo ou localizacao. + +- **Convite de usuario**: Convite para novos usuarios criarem conta na plataforma. + +--- + +## Sistema de Avaliacao de Chamados + +Quando um chamado e resolvido, o solicitante recebe um e-mail com opcao de avaliar o atendimento usando estrelas de 1 a 5. + +### Fluxo de Avaliacao + +1. Chamado e marcado como resolvido +2. Solicitante recebe e-mail com 5 estrelas clicaveis +3. Ao clicar em uma estrela, usuario e redirecionado para pagina de avaliacao +4. Na pagina, pode adicionar um comentario opcional +5. Avaliacao e registrada e associada ao chamado + +### Endpoints + +- `GET /api/tickets/rate?token={token}&rating={1-5}` - Registra avaliacao via link do e-mail +- `POST /api/tickets/rate` - Registra avaliacao com comentario + +### Modelo de Dados + +```prisma +model TicketRating { + id String @id @default(cuid()) + tenantId String + ticketId String @unique + userId String + rating Int // 1-5 estrelas + comment String? // Feedback opcional + createdAt DateTime @default(now()) +} +``` + +--- + +## Acesso Direto ao Chamado (Deep Linking) + +### Protocolo raven:// + +O sistema suporta o protocolo `raven://` para abrir chamados diretamente no aplicativo desktop Raven. + +**Formatos suportados:** + +``` +raven://ticket/{ticketId}?token={accessToken} +raven://chat/{ticketId}?token={accessToken} +raven://rate/{ticketId}?rating={1-5}&token={accessToken} +``` + +### Fluxo de Acesso + +1. Usuario clica no link do e-mail (ex: `/ticket-view/{token}`) +2. Pagina web tenta abrir o protocolo `raven://` +3. Se o Raven estiver instalado, abre diretamente no app +4. Se nao estiver instalado (timeout de 3s), exibe o chamado na web + +### Tokens de Acesso + +Tokens sao gerados para permitir acesso seguro sem necessidade de login. + +```typescript +interface TicketAccessToken { + token: string // Token unico + ticketId: string // Chamado associado + userId: string // Usuario autorizado + machineId?: string // Maquina esperada (opcional) + scope: string // 'view', 'interact', 'rate' + expiresAt: Date // Expira em 7 dias +} +``` + +### Endpoints + +- `GET /api/ticket-access/{token}` - Valida token e retorna informacoes +- `POST /api/ticket-access/{token}` - Marca token como usado + +--- + +## Preferencias de Notificacao + +### Para Staff (Admin, Manager, Agent) + +Staff tem controle total sobre suas preferencias: + +- Ativar/desativar e-mails globalmente +- Configurar horario de silencio +- Escolher frequencia de resumo (imediato, diario, semanal) +- Ativar/desativar cada tipo de notificacao individualmente +- Configurar preferencias por categoria + +**Acesso:** `/settings/notifications` + +### Para Solicitantes (Collaborators) + +Solicitantes tem opcoes limitadas: + +**Podem desativar:** +- Comentarios publicos +- Mudancas de status +- Mudancas de prioridade +- Novo login detectado + +**NAO podem desativar (obrigatorios):** +- Abertura de chamado +- Resolucao de chamado +- Reset de senha +- Verificacao/alteracao de e-mail +- Convites + +**Acesso:** `/portal/profile/notifications` + +### Modelo de Dados + +```prisma +model NotificationPreferences { + id String @id @default(cuid()) + userId String @unique + tenantId String + emailEnabled Boolean @default(true) + quietHoursStart String? // "22:00" + quietHoursEnd String? // "08:00" + timezone String @default("America/Sao_Paulo") + digestFrequency String @default("immediate") + typePreferences Json @default("{}") + categoryPreferences Json @default("{}") +} +``` + +### API de Preferencias + +- `GET /api/notifications/preferences` - Retorna preferencias do usuario +- `PUT /api/notifications/preferences` - Atualiza preferencias + +--- + +## Templates de E-mail + +Todos os e-mails utilizam templates HTML responsivos com design consistente com a plataforma. + +### Templates Disponiveis + +| Template | Arquivo | Descricao | +|----------|---------|-----------| +| Teste | `test` | Template para testes de envio | +| Abertura | `ticket_created` | Confirmacao de abertura de chamado | +| Resolucao | `ticket_resolved` | Notificacao de resolucao com avaliacao | +| Atribuicao | `ticket_assigned` | Notificacao de atribuicao | +| Status | `ticket_status` | Mudanca de status | +| Comentario | `ticket_comment` | Novo comentario publico | +| Reset de senha | `password_reset` | Link para redefinir senha | +| Verificar e-mail | `email_verify` | Confirmacao de e-mail | +| Convite | `invite` | Convite para nova conta | +| Novo login | `new_login` | Alerta de novo dispositivo | +| SLA em risco | `sla_warning` | Alerta de SLA proximo do limite | +| SLA violado | `sla_breached` | Alerta de SLA excedido | + +### Tokens de Design + +Os templates utilizam os mesmos tokens de design da plataforma: + +```css +/* Cores principais */ +--primary: #00e8ff +--primary-dark: #00c4d6 +--background: #f7f8fb + +/* Status */ +--status-pending: #64748b +--status-progress: #0ea5e9 +--status-paused: #f59e0b +--status-resolved: #10b981 + +/* Prioridade */ +--priority-low: #64748b +--priority-medium: #0ea5e9 +--priority-high: #f97316 +--priority-urgent: #ef4444 +``` + +--- + +## Arquitetura + +### Estrutura de Arquivos + +``` +src/ +├── server/ +│ ├── email/ +│ │ ├── email-service.ts # Servico principal de envio +│ │ ├── email-templates.ts # Renderizacao de templates +│ │ └── index.ts # Exports +│ └── notification/ +│ ├── notification-service.ts # Orquestrador de notificacoes +│ └── token-service.ts # Gerenciamento de tokens +├── app/ +│ ├── api/ +│ │ ├── notifications/ +│ │ │ └── preferences/ +│ │ │ └── route.ts # API de preferencias +│ │ ├── ticket-access/ +│ │ │ └── [token]/ +│ │ │ └── route.ts # API de tokens de acesso +│ │ └── tickets/ +│ │ └── rate/ +│ │ └── route.ts # API de avaliacao +│ ├── ticket-view/ +│ │ └── [token]/ +│ │ └── page.tsx # Pagina de visualizacao +│ ├── rate/ +│ │ └── [token]/ +│ │ └── page.tsx # Pagina de avaliacao +│ ├── settings/ +│ │ └── notifications/ +│ │ └── page.tsx # Preferencias (staff) +│ └── portal/ +│ └── profile/ +│ └── notifications/ +│ └── page.tsx # Preferencias (colaboradores) +└── components/ + └── settings/ + └── notification-preferences-form.tsx +``` + +### Servicos Principais + +#### EmailService (`email-service.ts`) + +Responsavel pelo envio de e-mails: + +```typescript +await sendEmail({ + to: 'usuario@email.com', + subject: 'Assunto do e-mail', + html: '...', + text: 'Versao texto', +}) +``` + +#### NotificationService (`notification-service.ts`) + +Orquestra o envio de notificacoes: + +```typescript +// Notifica abertura de chamado +await notifyTicketCreated(ticket) + +// Notifica resolucao +await notifyTicketResolved(ticket, resolutionSummary) + +// Notifica comentario +await notifyPublicComment(ticket, comment) +``` + +#### TokenService (`token-service.ts`) + +Gerencia tokens de acesso: + +```typescript +// Gera token +const token = await generateAccessToken({ + ticketId: 'xxx', + userId: 'xxx', + scope: 'view', + expiresInDays: 7, +}) + +// Valida token +const validated = await validateAccessToken(token) +``` + +--- + +## Configuracao SMTP + +O sistema utiliza SMTP para envio de e-mails. Consulte `docs/SMTP.md` para configuracao detalhada. + +### Variaveis de Ambiente + +```env +SMTP_HOST=smtp.exemplo.com +SMTP_PORT=587 +SMTP_USER=usuario +SMTP_PASS=senha +SMTP_FROM=noreply@exemplo.com +SMTP_FROM_NAME=Raven +``` + +--- + +## Seguranca + +### Protecoes Implementadas + +1. **Tokens expiraveis**: Tokens de acesso expiram em 7 dias +2. **Uso unico para acoes**: Tokens de avaliacao sao invalidados apos uso +3. **Rate limiting**: Limite de envios por usuario/minuto +4. **Sanitizacao HTML**: Todo conteudo dinamico e escapado +5. **Links HTTPS**: Todos os links utilizam HTTPS +6. **Preferencias obrigatorias**: Notificacoes de seguranca nao podem ser desativadas + +### Validacao de Tokens + +```typescript +// Verifica escopo do token +if (!hasScope(token.scope, 'rate')) { + return { error: 'Permissao insuficiente' } +} + +// Verifica expiracao +if (new Date() > token.expiresAt) { + return { error: 'Token expirado' } +} +``` + +--- + +## Testes + +### E-mail de Teste + +Para testar o sistema, utilize o endpoint de teste: + +```typescript +POST /api/email/test +{ + "to": "email@teste.com" +} +``` + +### Variaveis de Teste + +```env +TEST_EMAIL_RECIPIENT=monkeyesdras@gmail.com +``` + +--- + +## Compatibilidade + +### Clientes de E-mail Suportados + +- Gmail (web + app) +- Outlook (web + desktop + app) +- Apple Mail (macOS + iOS) +- Yahoo Mail +- Thunderbird + +### Tecnicas de Compatibilidade + +- Layout com tabelas (nao flexbox/grid) +- CSS inline (nao blocos `