- Updated type definitions in ChatWindow and MessageList components for better type safety. - Improved MessageInput to handle message responses, including a preview feature for replying to messages. - Enhanced the chat message handling logic to support message references and improve user interaction. - Refactored notification utility functions to support push notifications and rate limiting for email sending. - Updated backend schema to accommodate new features related to message responses and notifications.
909 lines
26 KiB
TypeScript
909 lines
26 KiB
TypeScript
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<Doc<"usuarios"> | 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<void> {
|
|
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<Doc<"notificacoesEmail">[]> => {
|
|
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<Array<Doc<"notificacoesEmail"> & { 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<Id<"usuarios">, Array<Doc<"notificacoesEmail">>>();
|
|
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 };
|
|
},
|
|
});
|