From f6671e0f162c0c1b24804872dd8547856dd80b44 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 21:27:48 -0300 Subject: [PATCH] feat: enhance email monitoring and management features - Added a new section for monitoring email status, allowing users to track the email queue and identify sending issues. - Updated the backend to support new internal queries for listing pending emails and retrieving email configurations. - Refactored email-related mutations to improve error handling and streamline the email sending process. - Enhanced the overall email management experience by providing clearer feedback and monitoring capabilities. --- .../src/routes/(dashboard)/ti/+page.svelte | 13 + .../ti/monitoramento-emails/+page.svelte | 265 +++++ packages/backend/convex/_generated/api.d.ts | 10 + packages/backend/convex/actions/email.ts | 10 +- .../convex/actions/utils/nodeCrypto.ts | 1 + packages/backend/convex/ausencias.ts | 6 +- packages/backend/convex/chat.ts | 4 +- packages/backend/convex/email.ts | 968 ++++-------------- 8 files changed, 505 insertions(+), 772 deletions(-) create mode 100644 apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index b59a15e..185e28e 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -218,6 +218,19 @@ palette: "secondary", icon: "envelope", }, + { + title: "Monitoramento de Emails", + description: + "Acompanhe o status da fila de emails, identifique problemas de envio e processe manualmente quando necessário.", + ctaLabel: "Monitorar Emails", + href: "/ti/monitoramento-emails", + palette: "info", + icon: "envelope", + highlightBadges: [ + { label: "Tempo Real", variant: "solid" }, + { label: "Debug", variant: "outline" }, + ], + }, { title: "Gerenciar Usuários", description: diff --git a/apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte b/apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte new file mode 100644 index 0000000..fd10ff0 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/monitoramento-emails/+page.svelte @@ -0,0 +1,265 @@ + + +
+
+

📧 Monitoramento de Emails

+

+ Acompanhe o status da fila de emails e identifique problemas de envio +

+
+ + + {#if estatisticas?.data} +
+
+
Total
+
{estatisticas.data.total}
+
Emails na fila
+
+ +
+
Pendentes
+
{estatisticas.data.pendentes}
+
Aguardando envio
+
+ +
+
Enviando
+
{estatisticas.data.enviando}
+
Em processamento
+
+ +
+
Enviados
+
{estatisticas.data.enviados}
+
Concluídos
+
+ +
+
Falhas
+
{estatisticas.data.falhas}
+
Com erro
+
+
+ {:else if estatisticas === undefined} +
+ +
+ {/if} + + +
+
+
+
+ +
+ + +
+
+
+ + +
+
+

Fila de Emails

+ + {#if filaEmails?.data && filaEmails.data.length > 0} +
+ + + + + + + + + + + + + + {#each filaEmails.data as email} + + + + + + + + + + {/each} + +
DestinatárioAssuntoStatusTentativasCriado emÚltima tentativaErro
+
{email.destinatario}
+
+
+ {email.assunto} +
+
+ + {getStatusLabel(email.status)} + + {email.tentativas || 0}{formatarData(email.criadoEm)} + {formatarData(email.ultimaTentativa)} + + {#if email.erroDetalhes} +
+ + ⚠️ Ver erro + +
+ {:else} + - + {/if} +
+
+ {:else if filaEmails?.data !== undefined} +
+

Nenhum email na fila

+
+ {:else} +
+ +
+ {/if} +
+
+ + +
+
+

🔍 Troubleshooting

+
+

+ Emails pendentes não estão sendo enviados? +

+
    +
  • Verifique se a configuração SMTP está ativa em Configurações de Email
  • +
  • Confirme se o cron job está rodando (verifique logs do Convex)
  • +
  • Clique em "Processar Fila Manualmente" para forçar o processamento
  • +
+ +

+ Emails com status "Falha"? +

+
    +
  • Verifique as credenciais SMTP em Configurações de Email
  • +
  • Confirme se o servidor SMTP está acessível
  • +
  • Verifique os logs do Convex para detalhes do erro
  • +
  • Teste a conexão SMTP na página de configurações
  • +
+
+
+
+
+ diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index e33dc3d..4f3f6f4 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -9,7 +9,10 @@ */ import type * as actions_email from "../actions/email.js"; +import type * as actions_linkPreview from "../actions/linkPreview.js"; +import type * as actions_pushNotifications from "../actions/pushNotifications.js"; import type * as actions_smtp from "../actions/smtp.js"; +import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js"; import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as ausencias from "../ausencias.js"; import type * as autenticacao from "../autenticacao.js"; @@ -31,6 +34,8 @@ import type * as logsAtividades from "../logsAtividades.js"; import type * as logsLogin from "../logsLogin.js"; import type * as monitoramento from "../monitoramento.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; +import type * as preferenciasNotificacao from "../preferenciasNotificacao.js"; +import type * as pushNotifications from "../pushNotifications.js"; import type * as roles from "../roles.js"; import type * as saldoFerias from "../saldoFerias.js"; import type * as seed from "../seed.js"; @@ -59,7 +64,10 @@ import type { */ declare const fullApi: ApiFromModules<{ "actions/email": typeof actions_email; + "actions/linkPreview": typeof actions_linkPreview; + "actions/pushNotifications": typeof actions_pushNotifications; "actions/smtp": typeof actions_smtp; + "actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto; atestadosLicencas: typeof atestadosLicencas; ausencias: typeof ausencias; autenticacao: typeof autenticacao; @@ -81,6 +89,8 @@ declare const fullApi: ApiFromModules<{ logsLogin: typeof logsLogin; monitoramento: typeof monitoramento; permissoesAcoes: typeof permissoesAcoes; + preferenciasNotificacao: typeof preferenciasNotificacao; + pushNotifications: typeof pushNotifications; roles: typeof roles; saldoFerias: typeof saldoFerias; seed: typeof seed; diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts index 9ea65a4..ecff2de 100644 --- a/packages/backend/convex/actions/email.ts +++ b/packages/backend/convex/actions/email.ts @@ -28,11 +28,19 @@ export const enviar = action({ const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {}); if (!configRaw) { + console.error("❌ Configuração SMTP não encontrada ou inativa para email:", email.destinatario); return { sucesso: false, - erro: "Configuração de email não encontrada ou inativa", + erro: "Configuração de email não encontrada ou inativa. Verifique as configurações SMTP no painel de TI.", }; } + + console.log("📧 Tentando enviar email:", { + para: email.destinatario, + assunto: email.assunto, + servidor: configRaw.servidor, + porta: configRaw.porta, + }); // Descriptografar senha usando função compatível com Node.js let senhaDescriptografada: string; diff --git a/packages/backend/convex/actions/utils/nodeCrypto.ts b/packages/backend/convex/actions/utils/nodeCrypto.ts index 0fc4b44..72c89ff 100644 --- a/packages/backend/convex/actions/utils/nodeCrypto.ts +++ b/packages/backend/convex/actions/utils/nodeCrypto.ts @@ -1,3 +1,4 @@ +"use node"; /** * Utilitários de criptografia compatíveis com Node.js * Para uso em actions que rodam em ambiente Node.js diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index 93306bd..c48a5d0 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -370,7 +370,7 @@ export const criarSolicitacao = mutation({
  • Motivo: ${args.motivo}
  • Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

    `, - enviadoPorId: funcionarioUsuario._id, + enviadoPor: funcionarioUsuario._id, }); // Criar ou obter conversa entre gestor e funcionário @@ -486,7 +486,7 @@ export const aprovar = mutation({
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • Motivo: ${solicitacao.motivo}
  • `, - enviadoPorId: args.gestorId, + enviadoPor: args.gestorId, }); // Criar ou obter conversa @@ -605,7 +605,7 @@ export const reprovar = mutation({
  • Motivo: ${solicitacao.motivo}
  • Motivo da Reprovação: ${args.motivoReprovacao}
  • `, - enviadoPorId: args.gestorId, + enviadoPor: args.gestorId, }); // Criar ou obter conversa diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index 38c64fa..694cefa 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -370,10 +370,10 @@ export const enviarMensagem = mutation({ variaveis: { remetente: usuarioAtual.nome, mensagem: descricao, - conversaId: args.conversaId, + conversaId: args.conversaId.toString(), urlSistema, }, - enviadoPorId: usuarioAtual._id, + enviadoPor: usuarioAtual._id, }).catch((error) => { console.error(`Erro ao agendar email para usuário ${participanteId}:`, error); }); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index dcbc783..e3fcef5 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -1,223 +1,123 @@ 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 { mutation, query, internalMutation, internalQuery, action } from "./_generated/server"; import { internal, api } from "./_generated/api"; +import { renderizarTemplate } from "./templatesMensagens"; -// ========== HELPERS ========== +// ========== INTERNAL QUERIES ========== /** - * Helper function para obter usuário autenticado (Better Auth ou Sessão) + * Obter email por ID (internal query) */ -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; +export const getEmailById = internalQuery({ + args: { + emailId: v.id("notificacoesEmail"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.emailId); + }, +}); - 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") +/** + * Obter configuração SMTP ativa (internal query) + */ +export const getActiveEmailConfig = internalQuery({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) .first(); - if (sessaoAtiva) { - usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); - } - } - - return usuarioAtual; -} + return config; + }, +}); /** - * Configurações padrão de rate limiting + * Listar emails pendentes (internal query) */ -const RATE_LIMIT_CONFIG = { - emailsPorMinuto: 10, - emailsPorHora: 100, -} as const; +export const listarEmailsPendentes = internalQuery({ + args: { + limite: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const emails = await ctx.db + .query("notificacoesEmail") + .withIndex("by_status", (q) => q.eq("status", "pendente")) + .order("asc") // Mais antigos primeiro + .take(args.limite || 10); + + return emails; + }, +}); + +// ========== INTERNAL MUTATIONS ========== /** - * Verifica rate limiting para um remetente - * Retorna true se pode enviar, false se excedeu limite + * Marcar email como enviando (internal mutation) */ -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; +export const markEmailEnviando = internalMutation({ + args: { + emailId: v.id("notificacoesEmail"), + }, + handler: async (ctx, args) => { + const email = await ctx.db.get(args.emailId); + if (!email) return; - // 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 }; -} + await ctx.db.patch(args.emailId, { + status: "enviando", + ultimaTentativa: Date.now(), + tentativas: email.tentativas + 1, + }); + }, +}); /** - * Registra envio de email para rate limiting + * Marcar email como enviado (internal mutation) */ -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, +export const markEmailEnviado = internalMutation({ + args: { + emailId: v.id("notificacoesEmail"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.emailId, { + status: "enviado", + enviadoEm: Date.now(), }); - } 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 + * Marcar email como falha (internal mutation) + */ +export const markEmailFalha = internalMutation({ + args: { + emailId: v.id("notificacoesEmail"), + erro: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.emailId, { + status: "falha", + erroDetalhes: args.erro, + ultimaTentativa: Date.now(), + }); + }, +}); + +// ========== PUBLIC MUTATIONS ========== + +/** + * Enfileirar email para envio assíncrono */ export const enfileirarEmail = mutation({ args: { - destinatario: v.string(), // email + destinatario: v.string(), 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 + enviadoPor: v.id("usuarios"), // Obrigatório conforme schema }, - 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, @@ -226,299 +126,144 @@ export const enfileirarEmail = mutation({ templateId: args.templateId, status: "pendente", tentativas: 0, - enviadoPor: args.enviadoPorId, criadoEm: Date.now(), - agendadaPara: args.agendadaPara, + enviadoPor: args.enviadoPor, }); - // 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 }; + return emailId; }, }); /** * Enviar email usando template */ -export const enviarEmailComTemplate = mutation({ +export const enviarEmailComTemplate = action({ 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 + variaveis: v.optional(v.record(v.string(), v.string())), + enviadoPor: v.id("usuarios"), // Obrigatório conforme schema }, - 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, + const template = await ctx.runQuery(api.templatesMensagens.obterTemplatePorCodigo, { + codigo: args.templateCodigo, }); - // Registrar rate limit apenas para envios imediatos - if (args.agendadaPara === undefined) { - await registrarEnvioRateLimit(ctx, args.enviadoPorId); + if (!template) { + throw new Error(`Template não encontrado: ${args.templateCodigo}`); } - // 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, - }); - } + // Renderizar template com variáveis + const variaveisTemplate = args.variaveis || {}; + const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate); + const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate); - return { sucesso: true, emailId }; + // Enfileirar email + const emailId = await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: args.destinatario, + destinatarioId: args.destinatarioId, + assunto: tituloRenderizado, + corpo: corpoRenderizado, + templateId: template._id, + enviadoPor: args.enviadoPor, + }); + + return emailId; }, }); +// ========== INTERNAL MUTATION (CRON) ========== + /** - * Listar emails na fila + * Processar fila de emails pendentes (chamado pelo cron) + */ +export const processarFilaEmails = internalMutation({ + args: {}, + handler: async (ctx) => { + // Buscar emails pendentes (limitado a 10 por vez para não sobrecarregar) + const emailsPendentes = await ctx.db + .query("notificacoesEmail") + .filter((q) => q.eq(q.field("status"), "pendente")) + .order("asc") // Mais antigos primeiro + .take(10); + + if (emailsPendentes.length === 0) { + return { processados: 0 }; + } + + // Agendar envio de cada email via action + for (const email of emailsPendentes) { + // Agendar envio assíncrono (não bloqueia o cron) + ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: email._id, + }).catch((error) => { + console.error(`Erro ao agendar envio de email ${email._id}:`, error); + }); + } + + return { processados: emailsPendentes.length }; + }, +}); + +// ========== QUERIES ========== + +/** + * Listar emails da fila (para monitoramento) */ 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()), + status: v.optional(v.union( + v.literal("pendente"), + v.literal("enviando"), + v.literal("enviado"), + v.literal("falha") + )), }, - // Tipo inferido automaticamente pelo Convex handler: async (ctx, args) => { + let emails; + + // Filtrar por status se fornecido if (args.status) { - const emails = await ctx.db + emails = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", args.status!)) .order("desc") - .take(args.limite ?? 100); - return emails; + .take(args.limite || 50); + } else { + // Sem filtro, buscar todos e ordenar por data de criação + const todosEmails = await ctx.db.query("notificacoesEmail").collect(); + todosEmails.sort((a, b) => b.criadoEm - a.criadoEm); + emails = todosEmails.slice(0, args.limite || 50); } - 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 + * Obter estatísticas da fila de emails (para debug e monitoramento) */ 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()), + total: v.number(), }), handler: async (ctx) => { - const todosEmails = await ctx.db - .query("notificacoesEmail") - .collect(); - + const todosEmails = await ctx.db.query("notificacoesEmail").collect(); + const estatisticas = { - total: todosEmails.length, pendentes: 0, enviando: 0, enviados: 0, falhas: 0, - comErro: 0, + total: todosEmails.length, }; for (const email of todosEmails) { @@ -534,9 +279,6 @@ export const obterEstatisticasFilaEmails = query({ break; case "falha": estatisticas.falhas++; - if (email.erroDetalhes) { - estatisticas.comErro++; - } break; } } @@ -545,364 +287,58 @@ export const obterEstatisticasFilaEmails = query({ }, }); +// ========== PUBLIC MUTATIONS (MANUAL) ========== + /** - * Obter estatísticas de rate limiting para um usuário + * Processar fila de emails manualmente (para uso em interface) */ -export const obterEstatisticasRateLimit = query({ +export const processarFilaEmailsManual = action({ args: { - remetenteId: v.id("usuarios"), + limite: v.optional(v.number()), }, returns: v.object({ - emailsUltimoMinuto: v.number(), - emailsUltimaHora: v.number(), - limiteMinuto: v.number(), - limiteHora: v.number(), - podeEnviar: v.boolean(), + sucesso: v.boolean(), + processados: v.number(), + falhas: v.number(), + erro: v.optional(v.string()), }), handler: async (ctx, args) => { - const agora = Date.now(); - const umMinutoAtras = agora - 60 * 1000; - const umaHoraAtras = agora - 60 * 60 * 1000; + try { + // Buscar emails pendentes + const emailsPendentes = await ctx.runQuery(internal.email.listarEmailsPendentes, { + limite: args.limite || 10, + }); - // 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, []); + if (emailsPendentes.length === 0) { + return { sucesso: true, processados: 0, falhas: 0 }; } - 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; - } + let processados = 0; + let falhas = 0; - // 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 + // Processar cada email + for (const email of emailsPendentes) { try { - await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, { + // Agendar envio via action + 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, - }); + console.error(`Erro ao agendar envio de email ${email._id}:`, error); falhas++; } } - } - if (processados > 0 || falhas > 0) { - console.log( - `📧 Fila de emails processada: ${processados} emails agendados, ${falhas} falhas` - ); + return { sucesso: true, processados, falhas }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + sucesso: false, + processados: 0, + falhas: 0, + erro: errorMessage, + }; } - - 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 }; }, });