feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.
This commit is contained in:
@@ -1,233 +1,216 @@
|
||||
"use node";
|
||||
'use node';
|
||||
|
||||
import { action } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "../_generated/api";
|
||||
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import { v } from 'convex/values';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { internal } from '../_generated/api';
|
||||
import { action } from '../_generated/server';
|
||||
import { decryptSMTPPasswordNode } from './utils/nodeCrypto';
|
||||
|
||||
export const enviar = action({
|
||||
args: {
|
||||
emailId: v.id("notificacoesEmail"),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
"use node";
|
||||
args: {
|
||||
emailId: v.id('notificacoesEmail')
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
'use node';
|
||||
|
||||
let email;
|
||||
try {
|
||||
// Buscar email da fila
|
||||
email = await ctx.runQuery(internal.email.getEmailById, {
|
||||
emailId: args.emailId,
|
||||
});
|
||||
let email;
|
||||
try {
|
||||
// Buscar email da fila
|
||||
email = await ctx.runQuery(internal.email.getEmailById, {
|
||||
emailId: args.emailId
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
return { sucesso: false, erro: "Email não encontrado" };
|
||||
}
|
||||
if (!email) {
|
||||
return { sucesso: false, erro: 'Email não encontrado' };
|
||||
}
|
||||
|
||||
// Buscar configuração SMTP ativa
|
||||
const configRaw = await ctx.runQuery(
|
||||
internal.email.getActiveEmailConfig,
|
||||
{}
|
||||
);
|
||||
// Buscar configuração SMTP ativa
|
||||
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. Verifique as configurações SMTP no painel de TI.",
|
||||
};
|
||||
}
|
||||
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. 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,
|
||||
});
|
||||
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;
|
||||
try {
|
||||
senhaDescriptografada = await decryptSMTPPasswordNode(
|
||||
configRaw.senhaHash
|
||||
);
|
||||
} catch (decryptError) {
|
||||
const decryptErrorMessage =
|
||||
decryptError instanceof Error
|
||||
? decryptError.message
|
||||
: String(decryptError);
|
||||
console.error(
|
||||
"Erro ao descriptografar senha SMTP:",
|
||||
decryptErrorMessage
|
||||
);
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`,
|
||||
};
|
||||
}
|
||||
// Descriptografar senha usando função compatível com Node.js
|
||||
let senhaDescriptografada: string;
|
||||
try {
|
||||
senhaDescriptografada = await decryptSMTPPasswordNode(configRaw.senhaHash);
|
||||
} catch (decryptError) {
|
||||
const decryptErrorMessage =
|
||||
decryptError instanceof Error ? decryptError.message : String(decryptError);
|
||||
console.error('Erro ao descriptografar senha SMTP:', decryptErrorMessage);
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const config = {
|
||||
...configRaw,
|
||||
senha: senhaDescriptografada,
|
||||
};
|
||||
const config = {
|
||||
...configRaw,
|
||||
senha: senhaDescriptografada
|
||||
};
|
||||
|
||||
// Config já foi validado acima
|
||||
// Config já foi validado acima
|
||||
|
||||
// Avisar mas não bloquear se não foi testado
|
||||
if (!config.testadoEm) {
|
||||
console.warn(
|
||||
"⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim..."
|
||||
);
|
||||
}
|
||||
// Avisar mas não bloquear se não foi testado
|
||||
if (!config.testadoEm) {
|
||||
console.warn('⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim...');
|
||||
}
|
||||
|
||||
// Marcar como enviando
|
||||
await ctx.runMutation(internal.email.markEmailEnviando, {
|
||||
emailId: args.emailId,
|
||||
});
|
||||
// Marcar como enviando
|
||||
await ctx.runMutation(internal.email.markEmailEnviando, {
|
||||
emailId: args.emailId
|
||||
});
|
||||
|
||||
// Criar transporter do nodemailer com configuração melhorada
|
||||
const transporterOptions: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
requireTLS?: boolean;
|
||||
auth: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
tls?: {
|
||||
rejectUnauthorized: boolean;
|
||||
ciphers?: string;
|
||||
};
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
socketTimeout: number;
|
||||
pool?: boolean;
|
||||
maxConnections?: number;
|
||||
maxMessages?: number;
|
||||
} = {
|
||||
host: config.servidor,
|
||||
port: config.porta,
|
||||
secure: config.usarSSL,
|
||||
auth: {
|
||||
user: config.usuario,
|
||||
pass: config.senha, // Senha já descriptografada
|
||||
},
|
||||
connectionTimeout: 15000, // 15 segundos
|
||||
greetingTimeout: 15000,
|
||||
socketTimeout: 15000,
|
||||
pool: true, // Usar pool de conexões
|
||||
maxConnections: 5,
|
||||
maxMessages: 100,
|
||||
};
|
||||
// Criar transporter do nodemailer com configuração melhorada
|
||||
const transporterOptions: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
requireTLS?: boolean;
|
||||
auth: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
tls?: {
|
||||
rejectUnauthorized: boolean;
|
||||
ciphers?: string;
|
||||
};
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
socketTimeout: number;
|
||||
pool?: boolean;
|
||||
maxConnections?: number;
|
||||
maxMessages?: number;
|
||||
} = {
|
||||
host: config.servidor,
|
||||
port: config.porta,
|
||||
secure: config.usarSSL,
|
||||
auth: {
|
||||
user: config.usuario,
|
||||
pass: config.senha // Senha já descriptografada
|
||||
},
|
||||
connectionTimeout: 15000, // 15 segundos
|
||||
greetingTimeout: 15000,
|
||||
socketTimeout: 15000,
|
||||
pool: true, // Usar pool de conexões
|
||||
maxConnections: 5,
|
||||
maxMessages: 100
|
||||
};
|
||||
|
||||
// Adicionar TLS apenas se necessário
|
||||
if (config.usarTLS) {
|
||||
transporterOptions.requireTLS = true;
|
||||
transporterOptions.tls = {
|
||||
rejectUnauthorized: false, // Permitir certificados autoassinados
|
||||
};
|
||||
} else if (config.usarSSL) {
|
||||
transporterOptions.tls = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
// Adicionar TLS apenas se necessário
|
||||
if (config.usarTLS) {
|
||||
transporterOptions.requireTLS = true;
|
||||
transporterOptions.tls = {
|
||||
rejectUnauthorized: false // Permitir certificados autoassinados
|
||||
};
|
||||
} else if (config.usarSSL) {
|
||||
transporterOptions.tls = {
|
||||
rejectUnauthorized: false
|
||||
};
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
// Verificar conexão antes de enviar
|
||||
try {
|
||||
await transporter.verify();
|
||||
console.log("✅ Conexão SMTP verificada com sucesso");
|
||||
} catch (verifyError) {
|
||||
const verifyErrorMessage =
|
||||
verifyError instanceof Error
|
||||
? verifyError.message
|
||||
: String(verifyError);
|
||||
console.warn(
|
||||
"⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:",
|
||||
verifyErrorMessage
|
||||
);
|
||||
// Não bloquear envio por falha na verificação, apenas avisar
|
||||
}
|
||||
// Verificar conexão antes de enviar
|
||||
try {
|
||||
await transporter.verify();
|
||||
console.log('✅ Conexão SMTP verificada com sucesso');
|
||||
} catch (verifyError) {
|
||||
const verifyErrorMessage =
|
||||
verifyError instanceof Error ? verifyError.message : String(verifyError);
|
||||
console.warn(
|
||||
'⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:',
|
||||
verifyErrorMessage
|
||||
);
|
||||
// Não bloquear envio por falha na verificação, apenas avisar
|
||||
}
|
||||
|
||||
// Validar email destinatário antes de enviar
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email.destinatario)) {
|
||||
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
|
||||
}
|
||||
// Validar email destinatário antes de enviar
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email.destinatario)) {
|
||||
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
|
||||
}
|
||||
|
||||
// Criar versão texto do HTML (remover tags e decodificar entidades básicas)
|
||||
const textoPlano = email.corpo
|
||||
.replace(/<[^>]*>/g, "") // Remover tags HTML
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
// Criar versão texto do HTML (remover tags e decodificar entidades básicas)
|
||||
const textoPlano = email.corpo
|
||||
.replace(/<[^>]*>/g, '') // Remover tags HTML
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
// Enviar email
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
|
||||
to: email.destinatario,
|
||||
subject: email.assunto,
|
||||
html: email.corpo,
|
||||
text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
|
||||
headers: {
|
||||
"X-Mailer": "SGSE-Sistema-de-Gerenciamento-de-Secretaria",
|
||||
"X-Priority": "3",
|
||||
},
|
||||
});
|
||||
// Enviar email
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
|
||||
to: email.destinatario,
|
||||
subject: email.assunto,
|
||||
html: email.corpo,
|
||||
text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
|
||||
headers: {
|
||||
'X-Mailer': 'SGSE-Sistema-de-Gerenciamento-de-Secretaria',
|
||||
'X-Priority': '3'
|
||||
}
|
||||
});
|
||||
|
||||
interface MessageInfo {
|
||||
messageId?: string;
|
||||
response?: string;
|
||||
}
|
||||
interface MessageInfo {
|
||||
messageId?: string;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
const messageInfo = info as MessageInfo;
|
||||
const messageInfo = info as MessageInfo;
|
||||
|
||||
console.log("✅ Email enviado com sucesso!", {
|
||||
para: email.destinatario,
|
||||
assunto: email.assunto,
|
||||
messageId: messageInfo.messageId,
|
||||
response: messageInfo.response,
|
||||
});
|
||||
console.log('✅ Email enviado com sucesso!', {
|
||||
para: email.destinatario,
|
||||
assunto: email.assunto,
|
||||
messageId: messageInfo.messageId,
|
||||
response: messageInfo.response
|
||||
});
|
||||
|
||||
// Marcar como enviado
|
||||
await ctx.runMutation(internal.email.markEmailEnviado, {
|
||||
emailId: args.emailId,
|
||||
});
|
||||
// Marcar como enviado
|
||||
await ctx.runMutation(internal.email.markEmailEnviado, {
|
||||
emailId: args.emailId
|
||||
});
|
||||
|
||||
return { sucesso: true };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
return { sucesso: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
console.error("❌ Erro ao enviar email:", {
|
||||
emailId: args.emailId,
|
||||
destinatario: email?.destinatario,
|
||||
erro: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
console.error('❌ Erro ao enviar email:', {
|
||||
emailId: args.emailId,
|
||||
destinatario: email?.destinatario,
|
||||
erro: errorMessage,
|
||||
stack: errorStack
|
||||
});
|
||||
|
||||
// Marcar como falha com detalhes completos
|
||||
const erroCompleto = errorStack
|
||||
? `${errorMessage}\n\nStack: ${errorStack}`
|
||||
: errorMessage;
|
||||
// Marcar como falha com detalhes completos
|
||||
const erroCompleto = errorStack ? `${errorMessage}\n\nStack: ${errorStack}` : errorMessage;
|
||||
|
||||
await ctx.runMutation(internal.email.markEmailFalha, {
|
||||
emailId: args.emailId,
|
||||
erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
|
||||
});
|
||||
await ctx.runMutation(internal.email.markEmailFalha, {
|
||||
emailId: args.emailId,
|
||||
erro: erroCompleto.substring(0, 2000) // Limitar tamanho do erro
|
||||
});
|
||||
|
||||
return { sucesso: false, erro: errorMessage };
|
||||
}
|
||||
},
|
||||
return { sucesso: false, erro: errorMessage };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
|
||||
// // Configurar SSH
|
||||
// let sshPasswordDecrypted: string | undefined = undefined;
|
||||
|
||||
|
||||
// // Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada
|
||||
// if (args.sshPassword) {
|
||||
// sshPasswordDecrypted = args.sshPassword;
|
||||
@@ -429,4 +429,3 @@
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
|
||||
|
||||
@@ -1,138 +1,155 @@
|
||||
"use node";
|
||||
'use node';
|
||||
|
||||
import { action } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "../_generated/api";
|
||||
import { v } from 'convex/values';
|
||||
import { internal } from '../_generated/api';
|
||||
import { action } from '../_generated/server';
|
||||
|
||||
/**
|
||||
* Extrair preview de link (metadados Open Graph) - função auxiliar
|
||||
*/
|
||||
async function extrairPreviewLinkHelper(url: string) {
|
||||
try {
|
||||
// Validar URL
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// Validar URL
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buscar HTML da página
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; SGSE-Bot/1.0)",
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // Timeout de 5 segundos
|
||||
});
|
||||
// Buscar HTML da página
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; SGSE-Bot/1.0)'
|
||||
},
|
||||
signal: AbortSignal.timeout(5000) // Timeout de 5 segundos
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const html = await response.text();
|
||||
|
||||
// Extrair metadados Open Graph e Twitter Cards
|
||||
const metadata: {
|
||||
titulo?: string;
|
||||
descricao?: string;
|
||||
imagem?: string;
|
||||
site?: string;
|
||||
} = {};
|
||||
// Extrair metadados Open Graph e Twitter Cards
|
||||
const metadata: {
|
||||
titulo?: string;
|
||||
descricao?: string;
|
||||
imagem?: string;
|
||||
site?: string;
|
||||
} = {};
|
||||
|
||||
// Título (og:title ou twitter:title ou <title>)
|
||||
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i);
|
||||
const twitterTitleMatch = html.match(/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i);
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||
|
||||
metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
|
||||
if (metadata.titulo) {
|
||||
metadata.titulo = metadata.titulo.trim().substring(0, 200);
|
||||
}
|
||||
// Título (og:title ou twitter:title ou <title>)
|
||||
const ogTitleMatch = html.match(
|
||||
/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
const twitterTitleMatch = html.match(
|
||||
/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||
|
||||
// Descrição (og:description ou twitter:description ou meta description)
|
||||
const ogDescMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i);
|
||||
const twitterDescMatch = html.match(/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i);
|
||||
const metaDescMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
|
||||
|
||||
metadata.descricao = ogDescMatch?.[1] || twitterDescMatch?.[1] || metaDescMatch?.[1] || undefined;
|
||||
if (metadata.descricao) {
|
||||
metadata.descricao = metadata.descricao.trim().substring(0, 300);
|
||||
}
|
||||
metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
|
||||
if (metadata.titulo) {
|
||||
metadata.titulo = metadata.titulo.trim().substring(0, 200);
|
||||
}
|
||||
|
||||
// Imagem (og:image ou twitter:image)
|
||||
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i);
|
||||
const twitterImageMatch = html.match(/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i);
|
||||
|
||||
const imageUrl = ogImageMatch?.[1] || twitterImageMatch?.[1];
|
||||
if (imageUrl) {
|
||||
// Resolver URL relativa
|
||||
try {
|
||||
metadata.imagem = new URL(imageUrl, url).href;
|
||||
} catch {
|
||||
metadata.imagem = imageUrl;
|
||||
}
|
||||
}
|
||||
// Descrição (og:description ou twitter:description ou meta description)
|
||||
const ogDescMatch = html.match(
|
||||
/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
const twitterDescMatch = html.match(
|
||||
/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
const metaDescMatch = html.match(
|
||||
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
|
||||
// Site (og:site_name ou domínio)
|
||||
const ogSiteMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i);
|
||||
metadata.site = ogSiteMatch?.[1] || urlObj.hostname.replace(/^www\./, "");
|
||||
metadata.descricao =
|
||||
ogDescMatch?.[1] || twitterDescMatch?.[1] || metaDescMatch?.[1] || undefined;
|
||||
if (metadata.descricao) {
|
||||
metadata.descricao = metadata.descricao.trim().substring(0, 300);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
titulo: metadata.titulo,
|
||||
descricao: metadata.descricao,
|
||||
imagem: metadata.imagem,
|
||||
site: metadata.site,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Erro ao extrair preview de link:", error);
|
||||
return null;
|
||||
}
|
||||
// Imagem (og:image ou twitter:image)
|
||||
const ogImageMatch = html.match(
|
||||
/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
const twitterImageMatch = html.match(
|
||||
/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
|
||||
const imageUrl = ogImageMatch?.[1] || twitterImageMatch?.[1];
|
||||
if (imageUrl) {
|
||||
// Resolver URL relativa
|
||||
try {
|
||||
metadata.imagem = new URL(imageUrl, url).href;
|
||||
} catch {
|
||||
metadata.imagem = imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Site (og:site_name ou domínio)
|
||||
const ogSiteMatch = html.match(
|
||||
/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i
|
||||
);
|
||||
metadata.site = ogSiteMatch?.[1] || urlObj.hostname.replace(/^www\./, '');
|
||||
|
||||
return {
|
||||
url,
|
||||
titulo: metadata.titulo,
|
||||
descricao: metadata.descricao,
|
||||
imagem: metadata.imagem,
|
||||
site: metadata.site
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao extrair preview de link:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processar preview de link e atualizar mensagem
|
||||
*/
|
||||
export const processarPreviewLink = action({
|
||||
args: {
|
||||
mensagemId: v.id("mensagens"),
|
||||
url: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
// Extrair preview
|
||||
const preview = await extrairPreviewLinkHelper(args.url);
|
||||
|
||||
if (preview) {
|
||||
// Atualizar mensagem com preview
|
||||
await ctx.runMutation(internal.chat.atualizarLinkPreview, {
|
||||
mensagemId: args.mensagemId,
|
||||
linkPreview: preview,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
args: {
|
||||
mensagemId: v.id('mensagens'),
|
||||
url: v.string()
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
// Extrair preview
|
||||
const preview = await extrairPreviewLinkHelper(args.url);
|
||||
|
||||
if (preview) {
|
||||
// Atualizar mensagem com preview
|
||||
await ctx.runMutation(internal.chat.atualizarLinkPreview, {
|
||||
mensagemId: args.mensagemId,
|
||||
linkPreview: preview
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extrair preview de link (metadados Open Graph) - versão pública
|
||||
*/
|
||||
export const extrairPreviewLink = action({
|
||||
args: {
|
||||
url: v.string(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
url: v.string(),
|
||||
titulo: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
imagem: v.optional(v.string()),
|
||||
site: v.optional(v.string()),
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
return await extrairPreviewLinkHelper(args.url);
|
||||
},
|
||||
args: {
|
||||
url: v.string()
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
url: v.string(),
|
||||
titulo: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
imagem: v.optional(v.string()),
|
||||
site: v.optional(v.string())
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
return await extrairPreviewLinkHelper(args.url);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,103 +1,108 @@
|
||||
"use node";
|
||||
'use node';
|
||||
|
||||
import { action } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "../_generated/api";
|
||||
import { v } from 'convex/values';
|
||||
import { internal } from '../_generated/api';
|
||||
import { action } from '../_generated/server';
|
||||
|
||||
/**
|
||||
* Enviar push notification usando Web Push API
|
||||
*/
|
||||
export const enviarPush = action({
|
||||
args: {
|
||||
subscriptionId: v.id("pushSubscriptions"),
|
||||
titulo: v.string(),
|
||||
corpo: v.string(),
|
||||
data: v.optional(
|
||||
v.object({
|
||||
conversaId: v.optional(v.string()),
|
||||
mensagemId: v.optional(v.string()),
|
||||
tipo: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
// Buscar subscription
|
||||
const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
|
||||
subscriptionId: args.subscriptionId,
|
||||
});
|
||||
args: {
|
||||
subscriptionId: v.id('pushSubscriptions'),
|
||||
titulo: v.string(),
|
||||
corpo: v.string(),
|
||||
data: v.optional(
|
||||
v.object({
|
||||
conversaId: v.optional(v.string()),
|
||||
mensagemId: v.optional(v.string()),
|
||||
tipo: v.optional(v.string())
|
||||
})
|
||||
)
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
// Buscar subscription
|
||||
const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
|
||||
subscriptionId: args.subscriptionId
|
||||
});
|
||||
|
||||
if (!subscription || !subscription.ativo) {
|
||||
return { sucesso: false, erro: "Subscription não encontrada ou inativa" };
|
||||
}
|
||||
if (!subscription || !subscription.ativo) {
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: 'Subscription não encontrada ou inativa'
|
||||
};
|
||||
}
|
||||
|
||||
// Web Push requer VAPID keys (deve estar em variáveis de ambiente)
|
||||
// Por enquanto, vamos usar uma implementação básica
|
||||
// Em produção, você precisará configurar VAPID keys
|
||||
// Web Push requer VAPID keys (deve estar em variáveis de ambiente)
|
||||
// Por enquanto, vamos usar uma implementação básica
|
||||
// Em produção, você precisará configurar VAPID keys
|
||||
|
||||
const webpushModule = await import("web-push");
|
||||
// web-push pode exportar como default ou named exports
|
||||
// Usar a declaração de tipo do módulo web-push
|
||||
interface WebPushType {
|
||||
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
|
||||
sendNotification: (
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||
payload: string | Buffer
|
||||
) => Promise<void>;
|
||||
}
|
||||
const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
|
||||
const webpushModule = await import('web-push');
|
||||
// web-push pode exportar como default ou named exports
|
||||
// Usar a declaração de tipo do módulo web-push
|
||||
interface WebPushType {
|
||||
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
|
||||
sendNotification: (
|
||||
subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
},
|
||||
payload: string | Buffer
|
||||
) => Promise<void>;
|
||||
}
|
||||
const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
|
||||
|
||||
// VAPID keys devem vir de variáveis de ambiente
|
||||
const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
|
||||
const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
|
||||
// VAPID keys devem vir de variáveis de ambiente
|
||||
const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
|
||||
const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
|
||||
|
||||
if (!publicKey || !privateKey) {
|
||||
console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
|
||||
// Em desenvolvimento, podemos retornar sucesso sem enviar
|
||||
return { sucesso: true };
|
||||
}
|
||||
if (!publicKey || !privateKey) {
|
||||
console.warn('⚠️ VAPID keys não configuradas. Push notifications não funcionarão.');
|
||||
// Em desenvolvimento, podemos retornar sucesso sem enviar
|
||||
return { sucesso: true };
|
||||
}
|
||||
|
||||
webpush.setVapidDetails("mailto:suporte@sgse.app", publicKey, privateKey);
|
||||
webpush.setVapidDetails('mailto:suporte@sgse.app', publicKey, privateKey);
|
||||
|
||||
// Preparar payload da notificação
|
||||
const payload = JSON.stringify({
|
||||
title: args.titulo,
|
||||
body: args.corpo,
|
||||
icon: "/favicon.png",
|
||||
badge: "/favicon.png",
|
||||
data: args.data || {},
|
||||
tag: args.data?.conversaId || "default",
|
||||
requireInteraction: args.data?.tipo === "mencao", // Menções requerem interação
|
||||
});
|
||||
// Preparar payload da notificação
|
||||
const payload = JSON.stringify({
|
||||
title: args.titulo,
|
||||
body: args.corpo,
|
||||
icon: '/favicon.png',
|
||||
badge: '/favicon.png',
|
||||
data: args.data || {},
|
||||
tag: args.data?.conversaId || 'default',
|
||||
requireInteraction: args.data?.tipo === 'mencao' // Menções requerem interação
|
||||
});
|
||||
|
||||
// Enviar push notification
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
},
|
||||
payload
|
||||
);
|
||||
// Enviar push notification
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth
|
||||
}
|
||||
},
|
||||
payload
|
||||
);
|
||||
|
||||
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
|
||||
return { sucesso: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("❌ Erro ao enviar push notification:", errorMessage);
|
||||
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
|
||||
return { sucesso: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('❌ Erro ao enviar push notification:', errorMessage);
|
||||
|
||||
// Se subscription inválida, marcar como inativa
|
||||
if (errorMessage.includes("410") || errorMessage.includes("expired")) {
|
||||
await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
|
||||
subscriptionId: args.subscriptionId,
|
||||
});
|
||||
}
|
||||
// Se subscription inválida, marcar como inativa
|
||||
if (errorMessage.includes('410') || errorMessage.includes('expired')) {
|
||||
await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
|
||||
subscriptionId: args.subscriptionId
|
||||
});
|
||||
}
|
||||
|
||||
return { sucesso: false, erro: errorMessage };
|
||||
}
|
||||
},
|
||||
return { sucesso: false, erro: errorMessage };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,64 +1,60 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
'use node';
|
||||
import { v } from 'convex/values';
|
||||
// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
|
||||
import nodemailer from "nodemailer";
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
import { action } from '../_generated/server';
|
||||
|
||||
export const testarConexao = action({
|
||||
args: {
|
||||
servidor: v.string(),
|
||||
porta: v.number(),
|
||||
usuario: v.string(),
|
||||
senha: v.string(),
|
||||
usarSSL: v.boolean(),
|
||||
usarTLS: v.boolean(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
args: {
|
||||
servidor: v.string(),
|
||||
porta: v.number(),
|
||||
usuario: v.string(),
|
||||
senha: v.string(),
|
||||
usarSSL: v.boolean(),
|
||||
usarTLS: v.boolean()
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
// Validações básicas
|
||||
if (!args.servidor || args.servidor.trim() === '') {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'Servidor SMTP não pode estar vazio'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Validações básicas
|
||||
if (!args.servidor || args.servidor.trim() === "") {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Servidor SMTP não pode estar vazio",
|
||||
};
|
||||
}
|
||||
if (args.porta < 1 || args.porta > 65535) {
|
||||
return { sucesso: false as const, erro: 'Porta inválida' };
|
||||
}
|
||||
|
||||
if (args.porta < 1 || args.porta > 65535) {
|
||||
return { sucesso: false as const, erro: "Porta inválida" };
|
||||
}
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: args.servidor,
|
||||
port: args.porta,
|
||||
secure: args.usarSSL,
|
||||
auth: {
|
||||
user: args.usuario,
|
||||
pass: args.senha
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
},
|
||||
connectionTimeout: 10000, // 10 segundos
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 10000
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: args.servidor,
|
||||
port: args.porta,
|
||||
secure: args.usarSSL,
|
||||
auth: {
|
||||
user: args.usuario,
|
||||
pass: args.senha,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
connectionTimeout: 10000, // 10 segundos
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 10000,
|
||||
});
|
||||
// Verificar conexão
|
||||
await transporter.verify();
|
||||
|
||||
// Verificar conexão
|
||||
await transporter.verify();
|
||||
|
||||
return { sucesso: true as const };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { sucesso: false as const, erro: errorMessage };
|
||||
}
|
||||
},
|
||||
return { sucesso: true as const };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { sucesso: false as const, erro: errorMessage };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use node";
|
||||
'use node';
|
||||
|
||||
/**
|
||||
* Utilitários de criptografia compatíveis com Node.js
|
||||
@@ -10,65 +10,64 @@
|
||||
* Esta versão funciona em ambiente Node.js (actions)
|
||||
*/
|
||||
export async function decryptSMTPPasswordNode(encryptedPassword: string): Promise<string> {
|
||||
try {
|
||||
// Em Node.js, crypto.subtle está disponível globalmente
|
||||
const crypto = globalThis.crypto;
|
||||
|
||||
if (!crypto || !crypto.subtle) {
|
||||
throw new Error("Web Crypto API não disponível");
|
||||
}
|
||||
try {
|
||||
// Em Node.js, crypto.subtle está disponível globalmente
|
||||
const crypto = globalThis.crypto;
|
||||
|
||||
// Chave base - mesma usada em auth/utils.ts
|
||||
const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024");
|
||||
|
||||
// Importar chave material
|
||||
const keyMaterialKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyMaterial,
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveBits", "deriveKey"]
|
||||
);
|
||||
if (!crypto || !crypto.subtle) {
|
||||
throw new Error('Web Crypto API não disponível');
|
||||
}
|
||||
|
||||
// Derivar chave de 256 bits usando PBKDF2
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: new TextEncoder().encode("SGSE-SALT"),
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterialKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
// Decodificar base64 manualmente (compatível com Node.js e browser)
|
||||
const binaryString = atob(encryptedPassword);
|
||||
const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||||
// Chave base - mesma usada em auth/utils.ts
|
||||
const keyMaterial = new TextEncoder().encode('SGSE-EMAIL-ENCRYPTION-KEY-2024');
|
||||
|
||||
// Extrair IV e dados criptografados
|
||||
const iv = combined.slice(0, 12);
|
||||
const encrypted = combined.slice(12);
|
||||
// Importar chave material
|
||||
const keyMaterialKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyMaterial,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
|
||||
// Descriptografar
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
encrypted
|
||||
);
|
||||
// Derivar chave de 256 bits usando PBKDF2
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: new TextEncoder().encode('SGSE-SALT'),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterialKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Converter para string
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Erro ao descriptografar senha SMTP (Node.js):", errorMessage);
|
||||
throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
|
||||
}
|
||||
// Decodificar base64 manualmente (compatível com Node.js e browser)
|
||||
const binaryString = atob(encryptedPassword);
|
||||
const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||||
|
||||
// Extrair IV e dados criptografados
|
||||
const iv = combined.slice(0, 12);
|
||||
const encrypted = combined.slice(12);
|
||||
|
||||
// Descriptografar
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
encrypted
|
||||
);
|
||||
|
||||
// Converter para string
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Erro ao descriptografar senha SMTP (Node.js):', errorMessage);
|
||||
throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user