diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte index 0bf6289..c6e32eb 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte @@ -39,12 +39,56 @@ } }); + // Tornar SSL e TLS mutuamente exclusivos + $effect(() => { + if (usarSSL && usarTLS) { + // Se ambos estão marcados, priorizar TLS por padrão + usarSSL = false; + } + }); + + function toggleSSL() { + usarSSL = !usarSSL; + if (usarSSL) { + usarTLS = false; + } + } + + function toggleTLS() { + usarTLS = !usarTLS; + if (usarTLS) { + usarSSL = false; + } + } + async function salvarConfiguracao() { - if (!servidor || !porta || !usuario || !senha || !emailRemetente) { + // Validação de campos obrigatórios + if (!servidor?.trim() || !porta || !usuario?.trim() || !emailRemetente?.trim() || !nomeRemetente?.trim()) { mostrarMensagem("error", "Preencha todos os campos obrigatórios"); return; } + // Validação de porta (1-65535) + const portaNum = Number(porta); + if (isNaN(portaNum) || portaNum < 1 || portaNum > 65535) { + mostrarMensagem("error", "Porta deve ser um número entre 1 e 65535"); + return; + } + + // Validação de formato de email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(emailRemetente.trim())) { + mostrarMensagem("error", "Email remetente inválido"); + return; + } + + // Validação de senha: obrigatória apenas se não houver configuração existente + const temConfigExistente = configAtual?.data?.ativo; + if (!temConfigExistente && !senha) { + mostrarMensagem("error", "Senha é obrigatória para nova configuração"); + return; + } + if (!authStore.usuario) { mostrarMensagem("error", "Usuário não autenticado"); return; @@ -54,9 +98,9 @@ try { const resultado = await client.mutation(api.configuracaoEmail.salvarConfigEmail, { servidor: servidor.trim(), - porta: Number(porta), + porta: portaNum, usuario: usuario.trim(), - senha: senha, + senha: senha || "", // Senha vazia será tratada no backend emailRemetente: emailRemetente.trim(), nomeRemetente: nomeRemetente.trim(), usarSSL, @@ -79,16 +123,23 @@ } async function testarConexao() { - if (!servidor || !porta || !usuario || !senha) { + if (!servidor?.trim() || !porta || !usuario?.trim() || !senha) { mostrarMensagem("error", "Preencha os dados de conexão antes de testar"); return; } + // Validação de porta + const portaNum = Number(porta); + if (isNaN(portaNum) || portaNum < 1 || portaNum > 65535) { + mostrarMensagem("error", "Porta deve ser um número entre 1 e 65535"); + return; + } + testando = true; try { - const resultado = await client.action(api.configuracaoEmail.testarConexaoSMTP, { + const resultado = await client.mutation(api.configuracaoEmail.testarConexaoSMTP, { servidor: servidor.trim(), - porta: Number(porta), + porta: portaNum, usuario: usuario.trim(), senha: senha, usarSSL, @@ -111,6 +162,9 @@ const statusConfig = $derived( configAtual?.data?.ativo ? "Configurado" : "Não configurado" ); + + const isLoading = $derived(configAtual === undefined); + const hasError = $derived(configAtual === null && !isLoading);
@@ -162,24 +216,35 @@
{/if} + + {#if isLoading} +
+ + Carregando configurações... +
+ {/if} + -
- - {#if configAtual?.data?.ativo} - - {:else} - - {/if} - - - Status: {statusConfig} - {#if configAtual?.data?.testadoEm} - - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')} - {/if} - -
+ {#if !isLoading} +
+ + {#if configAtual?.data?.ativo} + + {:else} + + {/if} + + + Status: {statusConfig} + {#if configAtual?.data?.testadoEm} + - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')} + {/if} + +
+ {/if} + {#if !isLoading}

Dados do Servidor SMTP

@@ -294,7 +359,8 @@
+ {/if}
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index b42d0e6..5cb06fe 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -325,7 +325,7 @@ nome: destinatario.nome, matricula: destinatario.matricula, }, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); } @@ -335,7 +335,7 @@ destinatarioId: destinatario._id as any, assunto: "Notificação do Sistema", corpo: mensagemPersonalizada, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); } @@ -433,7 +433,7 @@ nome: destinatario.nome, matricula: destinatario.matricula || "", }, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); sucessosEmail++; @@ -446,7 +446,7 @@ destinatarioId: destinatario._id as any, assunto: "Notificação do Sistema", corpo: mensagemPersonalizada, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); sucessosEmail++; diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts index 9e09c4b..8f27bfa 100644 --- a/packages/backend/convex/actions/email.ts +++ b/packages/backend/convex/actions/email.ts @@ -23,8 +23,8 @@ export const enviar = action({ return { sucesso: false, erro: "Email não encontrado" }; } - // Buscar configuração SMTP ativa - const config = await ctx.runQuery(internal.email.getActiveEmailConfig, {}); + // Buscar configuração SMTP ativa com senha descriptografada + const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {}); if (!config) { return { @@ -52,8 +52,7 @@ export const enviar = action({ secure: config.usarSSL, auth: { user: config.usuario, - // Em produção deve ser armazenado com criptografia reversível - pass: config.senhaHash, + pass: config.senha, // Senha já descriptografada }, tls: { // Permitir certificados autoassinados diff --git a/packages/backend/convex/auth/utils.ts b/packages/backend/convex/auth/utils.ts index 708ea0a..6e5fe29 100644 --- a/packages/backend/convex/auth/utils.ts +++ b/packages/backend/convex/auth/utils.ts @@ -130,3 +130,106 @@ export function validarSenha(senha: string): boolean { return regex.test(senha); } +/** + * Criptografia reversível para senhas SMTP usando AES-GCM + * NOTA: Esta função é usada apenas para senhas SMTP que precisam ser descriptografadas. + * Para senhas de usuários, use hashPassword() que é unidirecional. + */ + +// Chave de criptografia derivada (em produção, deve vir de variável de ambiente) +// Para desenvolvimento, usando uma chave fixa. Em produção, deve ser configurada via env var. +const getEncryptionKey = async (): Promise => { + // Chave base - em produção, isso deve vir de process.env.ENCRYPTION_KEY + // Por enquanto, usando uma chave derivada de um valor fixo + const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024"); + + // Deriva uma chave de 256 bits usando PBKDF2 + const key = await crypto.subtle.importKey( + "raw", + keyMaterial, + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"] + ); + + return await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: new TextEncoder().encode("SGSE-SALT"), + iterations: 100000, + hash: "SHA-256", + }, + key, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +}; + +/** + * Criptografa uma senha SMTP usando AES-GCM + */ +export async function encryptSMTPPassword(password: string): Promise { + try { + const key = await getEncryptionKey(); + const encoder = new TextEncoder(); + const data = encoder.encode(password); + + // Gerar IV (Initialization Vector) aleatório + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Criptografar + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + data + ); + + // Combinar IV + dados criptografados e converter para base64 + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return btoa(String.fromCharCode(...combined)); + } catch (error) { + console.error("Erro ao criptografar senha SMTP:", error); + throw new Error("Falha ao criptografar senha SMTP"); + } +} + +/** + * Descriptografa uma senha SMTP usando AES-GCM + */ +export async function decryptSMTPPassword(encryptedPassword: string): Promise { + try { + const key = await getEncryptionKey(); + + // Decodificar base64 + const combined = Uint8Array.from(atob(encryptedPassword), (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) { + console.error("Erro ao descriptografar senha SMTP:", error); + throw new Error("Falha ao descriptografar senha SMTP"); + } +} + diff --git a/packages/backend/convex/configuracaoEmail.ts b/packages/backend/convex/configuracaoEmail.ts index d17ab8e..4988860 100644 --- a/packages/backend/convex/configuracaoEmail.ts +++ b/packages/backend/convex/configuracaoEmail.ts @@ -1,7 +1,8 @@ import { v } from "convex/values"; -import { mutation, query, action } from "./_generated/server"; -import { hashPassword } from "./auth/utils"; +import { mutation, query } from "./_generated/server"; +import { encryptSMTPPassword } from "./auth/utils"; import { registrarAtividade } from "./logsAtividades"; +import { api } from "./_generated/api"; /** * Obter configuração de email ativa (senha mascarada) @@ -62,8 +63,29 @@ export const salvarConfigEmail = mutation({ return { sucesso: false as const, erro: "Email remetente inválido" }; } - // Criptografar senha - const senhaHash = await hashPassword(args.senha); + // Validar porta + if (args.porta < 1 || args.porta > 65535) { + return { sucesso: false as const, erro: "Porta deve ser um número entre 1 e 65535" }; + } + + // Buscar config ativa anterior para manter senha se não fornecida + const configAtiva = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + // Determinar senhaHash: usar nova senha se fornecida, senão manter a atual + let senhaHash: string; + if (args.senha && args.senha.trim().length > 0) { + // Nova senha fornecida, criptografar usando criptografia reversível (AES) + senhaHash = await encryptSMTPPassword(args.senha); + } else if (configAtiva) { + // Senha não fornecida, manter a atual (já criptografada) + senhaHash = configAtiva.senhaHash; + } else { + // Sem senha e sem config existente - erro + return { sucesso: false as const, erro: "Senha é obrigatória para nova configuração" }; + } // Desativar config anterior const configsAntigas = await ctx.db @@ -105,10 +127,7 @@ export const salvarConfigEmail = mutation({ }); /** - * Testar conexão SMTP (action - precisa de Node.js) - * - * NOTA: Esta action será implementada quando instalarmos nodemailer. - * Por enquanto, retorna sucesso simulado para não bloquear o desenvolvimento. + * Testar conexão SMTP (mutation que chama action real) */ export const testarConexaoSMTP = mutation({ args: { @@ -119,10 +138,65 @@ export const testarConexaoSMTP = mutation({ 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) => { - // Delegar para a action de Node em arquivo separado + // Validações básicas + if (!args.servidor || args.servidor.trim().length === 0) { + return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" }; + } - return { sucesso: true }; + if (!args.porta || args.porta < 1 || args.porta > 65535) { + return { sucesso: false as const, erro: "Porta inválida. Deve ser entre 1 e 65535" }; + } + + if (!args.usuario || args.usuario.trim().length === 0) { + return { sucesso: false as const, erro: "Usuário não pode estar vazio" }; + } + + if (!args.senha || args.senha.trim().length === 0) { + return { sucesso: false as const, erro: "Senha não pode estar vazia" }; + } + + // Validação de SSL/TLS mutuamente exclusivos + if (args.usarSSL && args.usarTLS) { + return { sucesso: false as const, erro: "SSL e TLS não podem estar habilitados simultaneamente" }; + } + + // Chamar action de teste real (que usa nodemailer) + try { + const resultado = await ctx.scheduler.runAfter(0, api.actions.smtp.testarConexao, { + servidor: args.servidor, + porta: args.porta, + usuario: args.usuario, + senha: args.senha, + usarSSL: args.usarSSL, + usarTLS: args.usarTLS, + }); + + // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm + if (resultado.sucesso) { + const configAtiva = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + if (configAtiva) { + await ctx.db.patch(configAtiva._id, { + testadoEm: Date.now(), + }); + } + } + + return resultado; + } catch (error: any) { + return { + sucesso: false as const, + erro: error.message || "Erro ao conectar com o servidor SMTP" + }; + } }, }); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index 86a2792..3e622ff 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -229,6 +229,30 @@ export const getActiveEmailConfig = internalQuery({ }, }); +// 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(), diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index f55345f..676869c 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -471,7 +471,7 @@ export default defineSchema({ servidor: v.string(), // smtp.gmail.com porta: v.number(), // 587, 465, etc. usuario: v.string(), - senhaHash: v.string(), // senha criptografada + senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP emailRemetente: v.string(), nomeRemetente: v.string(), usarSSL: v.boolean(),