Files
sgse-app/packages/backend/convex/email.ts
deyvisonwanderley 5d2df8077b feat: enhance email handling with improved error reporting and statistics
- Updated the `reenviarEmail` mutation to return detailed error messages for better user feedback.
- Added a new query to obtain email queue statistics, providing insights into email statuses.
- Enhanced the `processarFilaEmails` mutation to track processing failures and successes more effectively.
- Implemented a manual email processing mutation for immediate testing and control over email sending.
- Improved email validation and error handling in the email sending action, ensuring robust delivery processes.
2025-11-04 02:14:07 -03:00

659 lines
18 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;
}
/**
* 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")),
}),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.destinatario)) {
return { sucesso: false };
}
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) {
return { sucesso: false };
}
}
// 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,
});
// 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")),
}),
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 };
}
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) {
return { sucesso: false };
}
}
// 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,
});
// 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;
},
});
/**
* 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)
*/
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;
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++;
}
}
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 };
},
});