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}
+
+
+
+
+
+
+
+ Atualização automática
+
+
+
+
+
+ {#if processando}
+
+ Processando...
+ {:else}
+ 🔄 Processar Fila Manualmente
+ {/if}
+
+
+
+
+
+
+
+
+
Fila de Emails
+
+ {#if filaEmails?.data && filaEmails.data.length > 0}
+
+
+
+
+ Destinatário
+ Assunto
+ Status
+ Tentativas
+ Criado em
+ Última tentativa
+ Erro
+
+
+
+ {#each filaEmails.data as email}
+
+
+ {email.destinatario}
+
+
+
+ {email.assunto}
+
+
+
+
+ {getStatusLabel(email.status)}
+
+
+ {email.tentativas || 0}
+ {formatarData(email.criadoEm)}
+
+ {formatarData(email.ultimaTentativa)}
+
+
+ {#if email.erroDetalhes}
+
+
+ ⚠️ Ver erro
+
+
+ {:else}
+ -
+ {/if}
+
+
+ {/each}
+
+
+
+ {:else if filaEmails?.data !== undefined}
+
+ {: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 };
},
});