fix(email): corrija identificação SMTP que filtra convites em Docker

This commit is contained in:
vitorlacerdadigital
2026-09-25 17:33:07 -03:00
parent d1081dc231
commit 999a4110a2
3 changed files with 159 additions and 1 deletions
@@ -0,0 +1,7 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Corrige convites filtrados por identificação SMTP local em instalações Docker
---
Corrige uma falha em que o servidor de e-mail aceitava os convites, mas podia filtrá-los depois porque o CRM se identificava como localhost. O envio SMTP passa a usar o domínio já configurado na instalação, sem exigir ajustes manuais no contêiner. A falha foi observada na hospedagem de e-mail HostGator; a correção se aplica ao transporte SMTP em geral. Crédito: @vitorlacerdadigital.
+16 -1
View File
@@ -1,4 +1,6 @@
import { isIP } from "node:net";
import nodemailer, { type Transporter } from "nodemailer";
import { env } from "@/lib/env";
import { getSmtpConfig, type SmtpConfig } from "@/lib/email/config";
interface SendArgs {
@@ -34,11 +36,24 @@ export function formatFromAddress(config: SmtpConfig, override?: string): string
const name = (override ?? config.fromName).replace(/[<>"\r\n]/g, "").trim();
return name ? `${name} <${config.fromEmail}>` : config.fromEmail;
}
/** A URL já é validada por lib/env; em desenvolvimento mantemos o padrão local. */
function smtpClientName(): string | undefined {
const hostname = new URL(env.NEXT_PUBLIC_APP_URL).hostname;
const address = hostname.replace(/^\[|\]$/g, "");
if (isIP(address) === 4) return `[${address}]`;
if (isIP(address) === 6) return `[IPv6:${address}]`;
return hostname.includes(".") ? hostname : undefined;
}
function getTransport(config: SmtpConfig) {
const key = `${config.host}\0${config.port}\0${config.security}\0${config.username}\0${config.password}`;
const name = smtpClientName();
const key = `${name ?? ""}\0${config.host}\0${config.port}\0${config.security}\0${config.username}\0${config.password}`;
if (transporter && transporterKey === key) return transporter;
transporterKey = key;
transporter = nodemailer.createTransport({
// Docker usa hostname curto; o padrão do Nodemailer vira [127.0.0.1],
// que alguns provedores aceitam e filtram depois. Identifique a instalação.
name,
host: config.host,
port: config.port,
secure: config.security === "tls",
@@ -0,0 +1,136 @@
// @vitest-environment node
import { createServer, type Server, type Socket } from "node:net";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SmtpConfig } from "@/lib/email/config";
const estado = vi.hoisted(() => ({
env: { NEXT_PUBLIC_APP_URL: "https://crm.example.com" },
config: {} as SmtpConfig,
}));
vi.mock("@/lib/env", () => ({ env: estado.env }));
vi.mock("@/lib/email/config", () => ({ getSmtpConfig: async () => estado.config }));
// Nodemailer REAL falando com um receptor SMTP local. O teste mede o EHLO no
// fio, não apenas a presença de uma opção em um mock de createTransport.
let servidor: Server;
let sockets: Set<Socket>;
let saudacoes: string[];
let mensagens: string[];
beforeEach(async () => {
vi.resetModules();
vi.spyOn(os, "hostname").mockReturnValue("21ce6ab200ec"); // hostname Docker curto
estado.env.NEXT_PUBLIC_APP_URL = "https://crm.example.com";
sockets = new Set();
saudacoes = [];
mensagens = [];
servidor = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.setEncoding("utf8");
socket.write("220 smtp.example.test ESMTP\r\n");
let buffer = "";
let emDados = false;
let mensagem = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
let fim: number;
while ((fim = buffer.indexOf("\r\n")) >= 0) {
const linha = buffer.slice(0, fim);
buffer = buffer.slice(fim + 2);
if (emDados) {
if (linha === ".") {
mensagens.push(mensagem);
mensagem = "";
emDados = false;
socket.write("250 2.0.0 accepted\r\n");
} else mensagem += `${linha}\r\n`;
} else if (/^(EHLO|HELO) /.test(linha)) {
saudacoes.push(linha);
socket.write("250 smtp.example.test\r\n");
} else if (linha === "DATA") {
emDados = true;
socket.write("354 End with a dot\r\n");
} else if (linha === "QUIT") {
socket.end("221 Bye\r\n");
} else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve, reject) => {
servidor.once("error", reject);
servidor.listen(0, "127.0.0.1", resolve);
});
const address = servidor.address();
if (!address || typeof address === "string") throw new Error("SMTP local sem porta");
estado.config = {
host: "127.0.0.1",
port: address.port,
security: "none",
username: "",
password: "",
fromEmail: "convites@example.com",
fromName: "CRM de teste",
source: "database",
};
});
afterEach(async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => servidor.close(() => resolve()));
vi.restoreAllMocks();
});
const convite = {
to: "pessoa@example.net",
subject: "Convite de teste local",
html: "<p>Você recebeu um convite.</p>",
};
describe("identidade SMTP da instalação", () => {
it("envia com o domínio público, mesmo com hostname Docker curto", async () => {
const { sendEmail } = await import("@/lib/email/smtp");
expect(await sendEmail(convite)).toMatchObject({ ok: true, id: expect.any(String) });
expect(saudacoes).toEqual(["EHLO crm.example.com"]);
expect(mensagens).toHaveLength(1);
expect(mensagens[0]).toContain("<convites@example.com>");
});
it("a verificação usa a mesma identidade e não envia mensagem", async () => {
const { checkSmtpConfiguration } = await import("@/lib/email/smtp");
expect(await checkSmtpConfiguration()).toEqual({ ok: true });
expect(saudacoes).toEqual(["EHLO crm.example.com"]);
expect(mensagens).toHaveLength(0);
});
it.each([
["https://crm.example.org:8443/base?origem=teste#inicio", "crm.example.org"],
["http://192.0.2.10:3000", "[192.0.2.10]"],
["http://[2001:db8::10]:3000", "[IPv6:2001:db8::10]"],
["http://localhost:3000", "[127.0.0.1]"],
["http://crm:3000", "[127.0.0.1]"],
])("usa apenas a identidade de %s: %s", async (url, nome) => {
estado.env.NEXT_PUBLIC_APP_URL = url;
const { sendEmail } = await import("@/lib/email/smtp");
expect(await sendEmail(convite)).toMatchObject({ ok: true });
expect(saudacoes).toEqual([`EHLO ${nome}`]);
});
it("o cache não conserva uma identidade de domínio anterior", async () => {
const { sendEmail } = await import("@/lib/email/smtp");
expect(await sendEmail(convite)).toMatchObject({ ok: true });
estado.env.NEXT_PUBLIC_APP_URL = "https://outro.example.org";
expect(await sendEmail(convite)).toMatchObject({ ok: true });
expect(saudacoes).toEqual(["EHLO crm.example.com", "EHLO outro.example.org"]);
});
it("sem SMTP configurado não tenta abrir conexão nem afirma envio", async () => {
estado.config.fromEmail = "";
const { sendEmail, checkSmtpConfiguration } = await import("@/lib/email/smtp");
expect(await sendEmail(convite)).toEqual({ ok: false, error: "not_configured" });
expect(await checkSmtpConfiguration()).toEqual({ ok: false, reason: "not_configured" });
expect(saudacoes).toEqual([]);
expect(mensagens).toEqual([]);
});
});