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}
+
-
-
-
- Status: {statusConfig}
- {#if configAtual?.data?.testadoEm}
- - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')}
- {/if}
-
-
+ {#if !isLoading}
+
+
+
+ Status: {statusConfig}
+ {#if configAtual?.data?.testadoEm}
+ - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')}
+ {/if}
+
+
+ {/if}
+ {#if !isLoading}
+ {/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(),