import { v } from "convex/values"; import { mutation, query, action, internalMutation, internalQuery, } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; import type { QueryCtx, MutationCtx } from "./_generated/server"; import { renderizarTemplate } from "./templatesMensagens"; import { internal, api } from "./_generated/api"; // ========== HELPERS ========== /** * Helper function para obter usuário autenticado (Better Auth ou Sessão) */ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise | null> { // Tentar autenticação via Better Auth primeiro const identity = await ctx.auth.getUserIdentity(); let usuarioAtual: Doc<"usuarios"> | null = null; if (identity && identity.email) { usuarioAtual = await ctx.db .query("usuarios") .withIndex("by_email", (q) => q.eq("email", identity.email!)) .first(); } // Se não encontrou via Better Auth, tentar via sessão mais recente if (!usuarioAtual) { const sessaoAtiva = await ctx.db .query("sessoes") .filter((q) => q.eq(q.field("ativo"), true)) .order("desc") .first(); if (sessaoAtiva) { usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); } } return usuarioAtual; } /** * Configurações padrão de rate limiting */ const RATE_LIMIT_CONFIG = { emailsPorMinuto: 10, emailsPorHora: 100, } as const; /** * Verifica rate limiting para um remetente * Retorna true se pode enviar, false se excedeu limite */ async function verificarRateLimit( ctx: MutationCtx, remetenteId: Id<"usuarios"> ): Promise<{ permitido: boolean; motivo?: string }> { const agora = Date.now(); const umMinutoAtras = agora - 60 * 1000; const umaHoraAtras = agora - 60 * 60 * 1000; // Verificar limite por minuto const emailsUltimoMinuto = await ctx.db .query("rateLimitEmails") .withIndex("by_remetente_periodo", (q) => q.eq("remetenteId", remetenteId).eq("periodo", "minuto") ) .filter((q) => q.gte(q.field("timestamp"), umMinutoAtras)) .collect(); const totalUltimoMinuto = emailsUltimoMinuto.reduce( (sum, rl) => sum + rl.contador, 0 ); if (totalUltimoMinuto >= RATE_LIMIT_CONFIG.emailsPorMinuto) { return { permitido: false, motivo: `Limite de ${RATE_LIMIT_CONFIG.emailsPorMinuto} emails por minuto excedido. Tente novamente em alguns instantes.`, }; } // Verificar limite por hora const emailsUltimaHora = await ctx.db .query("rateLimitEmails") .withIndex("by_remetente_periodo", (q) => q.eq("remetenteId", remetenteId).eq("periodo", "hora") ) .filter((q) => q.gte(q.field("timestamp"), umaHoraAtras)) .collect(); const totalUltimaHora = emailsUltimaHora.reduce( (sum, rl) => sum + rl.contador, 0 ); if (totalUltimaHora >= RATE_LIMIT_CONFIG.emailsPorHora) { return { permitido: false, motivo: `Limite de ${RATE_LIMIT_CONFIG.emailsPorHora} emails por hora excedido. Tente novamente mais tarde.`, }; } return { permitido: true }; } /** * Registra envio de email para rate limiting */ async function registrarEnvioRateLimit( ctx: MutationCtx, remetenteId: Id<"usuarios"> ): Promise { const agora = Date.now(); // Limpar registros antigos (mais de 1 hora) const umaHoraAtras = agora - 60 * 60 * 1000; const registrosAntigos = await ctx.db .query("rateLimitEmails") .withIndex("by_timestamp") .filter((q) => q.lt(q.field("timestamp"), umaHoraAtras)) .collect(); for (const registro of registrosAntigos) { await ctx.db.delete(registro._id); } // Criar ou atualizar registro do minuto atual const minutoAtual = Math.floor(agora / 60000) * 60000; // Arredondar para o minuto const registroMinuto = await ctx.db .query("rateLimitEmails") .withIndex("by_remetente_periodo", (q) => q.eq("remetenteId", remetenteId).eq("periodo", "minuto") ) .filter((q) => q.eq(q.field("timestamp"), minutoAtual)) .first(); if (registroMinuto) { await ctx.db.patch(registroMinuto._id, { contador: registroMinuto.contador + 1, }); } else { await ctx.db.insert("rateLimitEmails", { remetenteId, timestamp: minutoAtual, contador: 1, periodo: "minuto", }); } // Criar ou atualizar registro da hora atual const horaAtual = Math.floor(agora / 3600000) * 3600000; // Arredondar para a hora const registroHora = await ctx.db .query("rateLimitEmails") .withIndex("by_remetente_periodo", (q) => q.eq("remetenteId", remetenteId).eq("periodo", "hora") ) .filter((q) => q.eq(q.field("timestamp"), horaAtual)) .first(); if (registroHora) { await ctx.db.patch(registroHora._id, { contador: registroHora.contador + 1, }); } else { await ctx.db.insert("rateLimitEmails", { remetenteId, timestamp: horaAtual, contador: 1, periodo: "hora", }); } } /** * Enfileirar email para envio */ export const enfileirarEmail = mutation({ args: { destinatario: v.string(), // email destinatarioId: v.optional(v.id("usuarios")), assunto: v.string(), corpo: v.string(), templateId: v.optional(v.id("templatesMensagens")), enviadoPorId: v.id("usuarios"), agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento }, returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")), erro: v.optional(v.string()), }), handler: async (ctx, args) => { // Validar email const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(args.destinatario)) { return { sucesso: false, erro: "Email destinatário inválido" }; } // Validar agendamento se fornecido if (args.agendadaPara !== undefined) { if (args.agendadaPara <= Date.now()) { return { sucesso: false, erro: "Data de agendamento deve ser futura" }; } } // Verificar rate limiting (apenas para envios imediatos, não agendados) if (args.agendadaPara === undefined) { const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId); if (!rateLimitCheck.permitido) { return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" }; } } // Adicionar à fila const emailId = await ctx.db.insert("notificacoesEmail", { destinatario: args.destinatario, destinatarioId: args.destinatarioId, assunto: args.assunto, corpo: args.corpo, templateId: args.templateId, status: "pendente", tentativas: 0, enviadoPor: args.enviadoPorId, criadoEm: Date.now(), agendadaPara: args.agendadaPara, }); // Registrar rate limit apenas para envios imediatos if (args.agendadaPara === undefined) { await registrarEnvioRateLimit(ctx, args.enviadoPorId); } // Agendar envio if (args.agendadaPara !== undefined) { // Agendar para o momento especificado const delayMs = args.agendadaPara - Date.now(); await ctx.scheduler.runAfter(delayMs, api.actions.email.enviar, { emailId, }); } else { // Envio imediato await ctx.scheduler.runAfter(0, api.actions.email.enviar, { emailId, }); } return { sucesso: true, emailId }; }, }); /** * Enviar email usando template */ export const enviarEmailComTemplate = mutation({ args: { destinatario: v.string(), destinatarioId: v.optional(v.id("usuarios")), templateCodigo: v.string(), variaveis: v.record(v.string(), v.string()), enviadoPorId: v.id("usuarios"), agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento }, returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")), erro: v.optional(v.string()), }), handler: async (ctx, args) => { // Buscar template const template = await ctx.db .query("templatesMensagens") .withIndex("by_codigo", (q) => q.eq("codigo", args.templateCodigo)) .first(); if (!template) { console.error("Template não encontrado:", args.templateCodigo); return { sucesso: false, erro: `Template "${args.templateCodigo}" não encontrado` }; } // Validar agendamento se fornecido if (args.agendadaPara !== undefined) { if (args.agendadaPara <= Date.now()) { return { sucesso: false, erro: "Data de agendamento deve ser futura" }; } } // Verificar rate limiting (apenas para envios imediatos, não agendados) if (args.agendadaPara === undefined) { const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId); if (!rateLimitCheck.permitido) { return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" }; } } // Renderizar template const assunto = renderizarTemplate(template.titulo, args.variaveis); const corpo = renderizarTemplate(template.corpo, args.variaveis); // Enfileirar email const emailId = await ctx.db.insert("notificacoesEmail", { destinatario: args.destinatario, destinatarioId: args.destinatarioId, assunto, corpo, templateId: template._id, status: "pendente", tentativas: 0, enviadoPor: args.enviadoPorId, criadoEm: Date.now(), agendadaPara: args.agendadaPara, }); // Registrar rate limit apenas para envios imediatos if (args.agendadaPara === undefined) { await registrarEnvioRateLimit(ctx, args.enviadoPorId); } // Agendar envio if (args.agendadaPara !== undefined) { // Agendar para o momento especificado const delayMs = args.agendadaPara - Date.now(); await ctx.scheduler.runAfter(delayMs, api.actions.email.enviar, { emailId, }); } else { // Envio imediato await ctx.scheduler.runAfter(0, api.actions.email.enviar, { emailId, }); } return { sucesso: true, emailId }; }, }); /** * Listar emails na fila */ export const listarFilaEmails = query({ args: { status: v.optional( v.union( v.literal("pendente"), v.literal("enviando"), v.literal("enviado"), v.literal("falha") ) ), limite: v.optional(v.number()), }, // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { if (args.status) { const emails = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", args.status!)) .order("desc") .take(args.limite ?? 100); return emails; } const emails = await ctx.db .query("notificacoesEmail") .withIndex("by_criado_em") .order("desc") .take(args.limite ?? 100); return emails; }, }); /** * Reenviar email falhado */ export const reenviarEmail = mutation({ args: { emailId: v.id("notificacoesEmail"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { const email = await ctx.db.get(args.emailId); if (!email) { return { sucesso: false, erro: "Email não encontrado" }; } // Verificar se o email não foi enviado com sucesso ainda if (email.status === "enviado") { return { sucesso: false, erro: "Este email já foi enviado com sucesso" }; } // Verificar se ainda não excedeu o limite de tentativas (max 3) if ((email.tentativas || 0) >= 3 && email.status !== "falha") { return { sucesso: false, erro: "Número máximo de tentativas excedido. Crie um novo email." }; } // Resetar status para pendente await ctx.db.patch(args.emailId, { status: "pendente", tentativas: 0, ultimaTentativa: undefined, erroDetalhes: undefined, }); // Agendar envio imediato await ctx.scheduler.runAfter(0, api.actions.email.enviar, { emailId: args.emailId, }); return { sucesso: true }; }, }); /** * Cancelar agendamento de email */ export const cancelarAgendamentoEmail = mutation({ args: { emailId: v.id("notificacoesEmail"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Usuário não autenticado" }; } const email = await ctx.db.get(args.emailId); if (!email) { return { sucesso: false, erro: "Email não encontrado" }; } // Verificar se o email pertence ao usuário atual if (email.enviadoPor !== usuarioAtual._id) { return { sucesso: false, erro: "Você não tem permissão para cancelar este agendamento" }; } // Verificar se o email está agendado if (!email.agendadaPara) { return { sucesso: false, erro: "Este email não está agendado" }; } // Verificar se ainda não foi enviado if (email.status === "enviado") { return { sucesso: false, erro: "Este email já foi enviado" }; } // Verificar se já passou a data de agendamento if (email.agendadaPara <= Date.now()) { return { sucesso: false, erro: "A data de agendamento já passou" }; } // Deletar o email agendado await ctx.db.delete(args.emailId); return { sucesso: true }; }, }); /** * Action para enviar email (será implementado com nodemailer) * * NOTA: Este é um placeholder. Implementação real requer nodemailer. */ export const getEmailById = internalQuery({ args: { emailId: v.id("notificacoesEmail") }, // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { return await ctx.db.get(args.emailId); }, }); /** * Buscar emails por IDs (query pública) */ export const buscarEmailsPorIds = query({ args: { emailIds: v.array(v.id("notificacoesEmail")), }, handler: async (ctx, args): Promise[]> => { const emails: Doc<"notificacoesEmail">[] = []; for (const emailId of args.emailIds) { const email = await ctx.db.get(emailId); if (email) { emails.push(email); } } return emails; }, }); /** * Obter estatísticas da fila de emails */ export const obterEstatisticasFilaEmails = query({ args: {}, returns: v.object({ total: v.number(), pendentes: v.number(), enviando: v.number(), enviados: v.number(), falhas: v.number(), comErro: v.number(), ultimaExecucaoCron: v.optional(v.number()), }), handler: async (ctx) => { const todosEmails = await ctx.db .query("notificacoesEmail") .collect(); const estatisticas = { total: todosEmails.length, pendentes: 0, enviando: 0, enviados: 0, falhas: 0, comErro: 0, }; for (const email of todosEmails) { switch (email.status) { case "pendente": estatisticas.pendentes++; break; case "enviando": estatisticas.enviando++; break; case "enviado": estatisticas.enviados++; break; case "falha": estatisticas.falhas++; if (email.erroDetalhes) { estatisticas.comErro++; } break; } } return estatisticas; }, }); /** * Obter estatísticas de rate limiting para um usuário */ export const obterEstatisticasRateLimit = query({ args: { remetenteId: v.id("usuarios"), }, returns: v.object({ emailsUltimoMinuto: v.number(), emailsUltimaHora: v.number(), limiteMinuto: v.number(), limiteHora: v.number(), podeEnviar: v.boolean(), }), handler: async (ctx, args) => { const agora = Date.now(); const umMinutoAtras = agora - 60 * 1000; const umaHoraAtras = agora - 60 * 60 * 1000; // Contar emails do último minuto const emailsUltimoMinuto = await ctx.db .query("rateLimitEmails") .withIndex("by_remetente_periodo", (q) => q.eq("remetenteId", args.remetenteId).eq("periodo", "minuto") ) .filter((q) => q.gte(q.field("timestamp"), umMinutoAtras)) .collect(); const totalUltimoMinuto = emailsUltimoMinuto.reduce( (sum, rl) => sum + rl.contador, 0 ); // Contar emails da última hora const emailsUltimaHora = await ctx.db .query("rateLimitEmails") .withIndex("by_remetente_periodo", (q) => q.eq("remetenteId", args.remetenteId).eq("periodo", "hora") ) .filter((q) => q.gte(q.field("timestamp"), umaHoraAtras)) .collect(); const totalUltimaHora = emailsUltimaHora.reduce( (sum, rl) => sum + rl.contador, 0 ); return { emailsUltimoMinuto: totalUltimoMinuto, emailsUltimaHora: totalUltimaHora, limiteMinuto: RATE_LIMIT_CONFIG.emailsPorMinuto, limiteHora: RATE_LIMIT_CONFIG.emailsPorHora, podeEnviar: totalUltimoMinuto < RATE_LIMIT_CONFIG.emailsPorMinuto && totalUltimaHora < RATE_LIMIT_CONFIG.emailsPorHora, }; }, }); /** * Listar agendamentos de email do usuário atual */ export const listarAgendamentosEmail = query({ args: {}, handler: async (ctx): Promise & { destinatarioInfo: Doc<"usuarios"> | null; templateInfo: Doc<"templatesMensagens"> | null }>> => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return []; } // Buscar todos os emails do usuário const todosEmails = await ctx.db .query("notificacoesEmail") .withIndex("by_enviado_por", (q) => q.eq("enviadoPor", usuarioAtual._id)) .collect(); // Filtrar apenas os que têm agendamento (passados ou futuros) const emailsAgendados = todosEmails.filter((email) => email.agendadaPara !== undefined); // Enriquecer com informações do destinatário e template const emailsEnriquecidos = await Promise.all( emailsAgendados.map(async (email) => { let destinatarioInfo: Doc<"usuarios"> | null = null; let templateInfo: Doc<"templatesMensagens"> | null = null; if (email.destinatarioId) { destinatarioInfo = await ctx.db.get(email.destinatarioId); } if (email.templateId) { templateInfo = await ctx.db.get(email.templateId); } return { ...email, destinatarioInfo, templateInfo, }; }) ); // Ordenar por data de agendamento (mais próximos primeiro) return emailsEnriquecidos.sort((a, b) => { const dataA = a.agendadaPara ?? 0; const dataB = b.agendadaPara ?? 0; return dataA - dataB; }); }, }); export const getActiveEmailConfig = internalQuery({ args: {}, // Tipo inferido automaticamente pelo Convex handler: async (ctx) => { return await ctx.db .query("configuracaoEmail") .withIndex("by_ativo", (q) => q.eq("ativo", true)) .first(); }, }); // Query interna para obter configuração com senha descriptografada export const getActiveEmailConfigWithPassword = internalQuery({ args: {}, handler: async (ctx) => { const { decryptSMTPPassword } = await import("./auth/utils"); const config = await ctx.db .query("configuracaoEmail") .withIndex("by_ativo", (q) => q.eq("ativo", true)) .first(); if (!config) { return null; } // Descriptografar senha const senhaDescriptografada = await decryptSMTPPassword(config.senhaHash); return { ...config, senha: senhaDescriptografada, }; }, }); export const markEmailEnviando = internalMutation({ args: { emailId: v.id("notificacoesEmail") }, returns: v.null(), handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); if (!email) return null; await ctx.db.patch(args.emailId, { status: "enviando", tentativas: (email.tentativas || 0) + 1, ultimaTentativa: Date.now(), }); return null; }, }); export const markEmailEnviado = internalMutation({ args: { emailId: v.id("notificacoesEmail") }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.emailId, { status: "enviado", enviadoEm: Date.now(), }); return null; }, }); export const markEmailFalha = internalMutation({ args: { emailId: v.id("notificacoesEmail"), erro: v.string() }, returns: v.null(), handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); if (!email) return null; await ctx.db.patch(args.emailId, { status: "falha", erroDetalhes: args.erro, tentativas: (email.tentativas || 0) + 1, }); return null; }, }); // Action de envio foi movida para `actions/email.ts` /** * Processar fila de emails (cron job - processa emails pendentes) * Implementa delay exponencial entre envios para evitar bloqueio SMTP */ export const processarFilaEmails = internalMutation({ args: {}, returns: v.object({ processados: v.number(), falhas: v.number() }), handler: async (ctx) => { // Buscar emails pendentes que não estão agendados para o futuro (max 10 por execução) const agora = Date.now(); const emailsPendentes = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", "pendente")) .filter((q) => q.or( q.eq(q.field("agendadaPara"), undefined), q.lte(q.field("agendadaPara"), agora) ) ) .take(10); let processados = 0; let falhas = 0; // Agrupar emails por remetente para aplicar rate limiting e delay const emailsPorRemetente = new Map, Array>>(); for (const email of emailsPendentes) { if (!emailsPorRemetente.has(email.enviadoPor)) { emailsPorRemetente.set(email.enviadoPor, []); } emailsPorRemetente.get(email.enviadoPor)!.push(email); } for (const [remetenteId, emails] of emailsPorRemetente.entries()) { // Verificar rate limit do remetente const rateLimitCheck = await verificarRateLimit(ctx, remetenteId); for (let i = 0; i < emails.length; i++) { const email = emails[i]; // Verificar se não excedeu tentativas (max 3) if ((email.tentativas || 0) >= 3) { await ctx.db.patch(email._id, { status: "falha", erroDetalhes: "Número máximo de tentativas excedido", }); falhas++; continue; } // Se rate limit excedido, pular este lote if (!rateLimitCheck.permitido && i === 0) { console.log(`⏸️ Rate limit excedido para remetente ${remetenteId}, aguardando...`); break; } // Delay exponencial baseado na tentativa (primeira: 0ms, segunda: 2s, terceira: 4s) const delayExponencial = email.tentativas ? Math.min(2000 * Math.pow(2, email.tentativas - 1), 10000) // Máximo 10s : 0; // Delay adicional entre emails do mesmo remetente (1 segundo) const delayEntreEmails = i * 1000; // Agendar envio via action com delay try { await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, { emailId: email._id, }); processados++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Erro ao agendar email ${email._id}:`, errorMessage); await ctx.db.patch(email._id, { status: "falha", erroDetalhes: `Erro ao agendar envio: ${errorMessage}`, tentativas: (email.tentativas || 0) + 1, }); falhas++; } } } if (processados > 0 || falhas > 0) { console.log( `📧 Fila de emails processada: ${processados} emails agendados, ${falhas} falhas` ); } return { processados, falhas }; }, }); /** * Processar fila de emails manualmente (para testes e envio imediato) */ export const processarFilaEmailsManual = mutation({ args: { limite: v.optional(v.number()), }, returns: v.object({ sucesso: v.boolean(), processados: v.number(), falhas: v.number(), erro: v.optional(v.string()) }), handler: async (ctx, args): Promise<{ sucesso: boolean; processados: number; falhas: number; erro?: string }> => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, processados: 0, falhas: 0, erro: "Usuário não autenticado" }; } // Verificar se usuário tem permissão (TI_MASTER ou admin) const role = await ctx.db.get(usuarioAtual.roleId); if (!role || (role.nivel !== 0 && role.nivel !== 1)) { return { sucesso: false, processados: 0, falhas: 0, erro: "Permissão negada" }; } const limite = args.limite || 10; const agora = Date.now(); // Buscar emails pendentes que não estão agendados para o futuro const emailsPendentes = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", "pendente")) .filter((q) => q.or( q.eq(q.field("agendadaPara"), undefined), q.lte(q.field("agendadaPara"), agora) ) ) .take(limite); let processados = 0; let falhas = 0; for (const email of emailsPendentes) { // Verificar se não excedeu tentativas (max 3) if ((email.tentativas || 0) >= 3) { await ctx.db.patch(email._id, { status: "falha", erroDetalhes: "Número máximo de tentativas excedido", }); falhas++; continue; } // Agendar envio via action try { await ctx.scheduler.runAfter(0, api.actions.email.enviar, { emailId: email._id, }); processados++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Erro ao agendar email ${email._id}:`, errorMessage); await ctx.db.patch(email._id, { status: "falha", erroDetalhes: `Erro ao agendar envio: ${errorMessage}`, tentativas: (email.tentativas || 0) + 1, }); falhas++; } } return { sucesso: true, processados, falhas }; }, });