diff --git a/.changes/corrige-identidade-smtp-em-docker.md b/.changes/corrige-identidade-smtp-em-docker.md new file mode 100644 index 000000000..cd8db2ede --- /dev/null +++ b/.changes/corrige-identidade-smtp-em-docker.md @@ -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. diff --git a/lib/email/smtp.ts b/lib/email/smtp.ts index 86158e5b3..018c74065 100644 --- a/lib/email/smtp.ts +++ b/lib/email/smtp.ts @@ -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", diff --git a/tests/unit/smtp-identifica-a-instalacao.test.ts b/tests/unit/smtp-identifica-a-instalacao.test.ts new file mode 100644 index 000000000..60e7dfa45 --- /dev/null +++ b/tests/unit/smtp-identifica-a-instalacao.test.ts @@ -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; +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((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((resolve) => servidor.close(() => resolve())); + vi.restoreAllMocks(); +}); + +const convite = { + to: "pessoa@example.net", + subject: "Convite de teste local", + html: "

Você recebeu um convite.

", +}; + +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(""); + }); + + 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([]); + }); +});