diff --git a/CLAUDE.md b/CLAUDE.md index 6f0ef31..6887550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -382,6 +382,21 @@ pas de cascade automatique, la bascule est un choix visible. **Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP est désactivée depuis 2024, y compris sur outlook.com et hotmail.com. **SMTP générique** via `nodemailer`, seule dépendance d'envoi. +- **Port et chiffrement SMTP sont un seul choix.** `src/lib/smtp-config.ts` est + **pur** (`tests/smtp-config.test.ts`) : `impliedSecure()` dit que seul le port + 465 chiffre dès l'ouverture, les autres (587, 25, 2525) passent par STARTTLS, + et `tlsMismatch()` prévient quand la case et le port se contredisent. Le + formulaire (`SmtpAccountForm`, client) réaligne la case sur le port choisi : + un TLS tenté sur le 587 échoue sur la bannière en clair (« wrong version + number »), un dialogue en clair sur le 465 attend une réponse qui ne vient + jamais. `describeSmtpError()` traduit ces échecs avant affichage — + l'explication d'abord, la trace ensuite, parce que l'écran tronque. +- Le transport pose `requireTLS` hors TLS implicite : sans lui, STARTTLS est + *opportuniste* et l'identifiant partirait en clair chez un serveur qui ne + l'annonce pas. Les délais (`connectionTimeout`, `greetingTimeout`) sont + ramenés à quelques secondes : ces connexions s'ouvrent depuis une action + serveur, et un port filtré en sortie — cas courant chez les hébergeurs — ne + doit pas bloquer l'écran deux minutes. - `mail_accounts.from_address` est **lu chez le fournisseur** au retour OAuth, jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM. diff --git a/src/app/backend/boite-mail/page.tsx b/src/app/backend/boite-mail/page.tsx index ee57739..ba3948a 100644 --- a/src/app/backend/boite-mail/page.tsx +++ b/src/app/backend/boite-mail/page.tsx @@ -11,6 +11,7 @@ import { type MailHealth, } from "@/lib/mail-accounts"; import { recentMails } from "@/lib/mail-outbox"; +import { SmtpAccountForm } from "@/components/SmtpAccountForm"; import { publicBaseUrl } from "@/lib/social-accounts"; import { getSiteSettings } from "@/lib/site-settings"; import type { MailAccount, MailProvider } from "@/db/schema"; @@ -194,61 +195,24 @@ export default async function BoiteMailPage({ {verdict && !verdict.ok && (
- {verdict.reason.slice(0, 220)} + {verdict.reason.slice(0, 400)}
)}
{provider === "smtp" ? ( -
-
- - - -
-
- - - -
- -

- L'adresse d'expédition doit appartenir au domaine qui authentifie la connexion, sinon - SPF et DKIM échouent et les messages partent en indésirable. -

-
- -
-
+ ) : ( <>
diff --git a/src/components/SmtpAccountForm.tsx b/src/components/SmtpAccountForm.tsx new file mode 100644 index 0000000..1453110 --- /dev/null +++ b/src/components/SmtpAccountForm.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState, type CSSProperties } from "react"; +import { IMPLICIT_TLS_PORT, impliedSecure, tlsMismatch } from "@/lib/smtp-config"; + +/** + * Réglages d'un serveur SMTP générique. + * + * Client parce que le port et le chiffrement sont **un seul choix** présenté + * en deux champs : changer le port réaligne la case, et un couple incohérent + * s'annonce à l'écran plutôt qu'en échec de connexion illisible. Voir + * `src/lib/smtp-config.ts` pour le pourquoi des deux ports. + * + * Aucun secret ne descend ici : le mot de passe enregistré n'est pas passé en + * prop, seul `hasPassword` dit s'il faut proposer de le conserver. + */ + +const PRESETS = [ + { port: IMPLICIT_TLS_PORT, label: "465 · chiffré dès l'ouverture" }, + { port: 587, label: "587 · STARTTLS" }, +]; + +export function SmtpAccountForm({ + saveAction, + host, + port, + secure, + user, + hasPassword, + fromAddress, + fromName, + submitStyle, +}: { + saveAction: (formData: FormData) => void | Promise; + host: string; + port: number; + secure: boolean; + user: string; + hasPassword: boolean; + fromAddress: string; + fromName: string; + submitStyle: CSSProperties; +}) { + const [currentHost, setHost] = useState(host); + const [currentPort, setPort] = useState(String(port)); + const [currentSecure, setSecure] = useState(secure); + + const numericPort = Number(currentPort); + const knownPort = Number.isFinite(numericPort) && numericPort > 0; + const warning = knownPort ? tlsMismatch(numericPort, currentSecure) : null; + + /** Le port choisi vaut choix du mode : la case suit, et reste reprenable à la main. */ + function applyPort(value: string) { + setPort(value); + const next = Number(value); + if (Number.isFinite(next) && next > 0) setSecure(impliedSecure(next)); + } + + return ( + +
+ + + +
+ +
+ Ports courants + {PRESETS.map((preset) => { + const on = numericPort === preset.port; + return ( + + ); + })} +
+ +
+ + + +
+ + + + {warning && ( +

+ {warning} +

+ )} + +

+ L'adresse d'expédition doit appartenir au domaine qui authentifie la connexion, sinon + SPF et DKIM échouent et les messages partent en indésirable. +

+
+ +
+ + ); +} diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts index a8ee9a9..80bcc46 100644 --- a/src/lib/mailer.ts +++ b/src/lib/mailer.ts @@ -1,5 +1,6 @@ import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime"; import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts"; +import { describeSmtpError } from "@/lib/smtp-config"; /** * Expédition d'un message. @@ -102,14 +103,19 @@ async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise { if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." }; const transport = await smtpTransport(sender.smtp); - await transport.sendMail({ - from: formatAddress(sender.fromAddress, sender.fromName), - to: formatAddress(mail.to, mail.toName), - replyTo: mail.replyTo ?? undefined, - subject: mail.subject, - html: mail.html, - text: mail.text ?? undefined, - }); + try { + await transport.sendMail({ + from: formatAddress(sender.fromAddress, sender.fromName), + to: formatAddress(mail.to, mail.toName), + replyTo: mail.replyTo ?? undefined, + subject: mail.subject, + html: mail.html, + text: mail.text ?? undefined, + }); + } catch (error) { + // Le verdict part dans le journal d'envoi : il doit se lire sans trace OpenSSL. + return { ok: false, reason: describeSmtpError(error instanceof Error ? error.message : String(error), sender.smtp) }; + } return { ok: true, provider: "smtp" }; } @@ -117,6 +123,17 @@ async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise { + const transport = await smtpTransport(config).catch(() => null); + if (!transport) return { ok: false, reason: "Transport SMTP indisponible." }; try { - const transport = await smtpTransport(config); await transport.verify(); - transport.close(); - return { ok: true, detail: `Connexion établie avec ${config.host}` }; + const how = config.secure ? "chiffrée dès l'ouverture" : "STARTTLS"; + return { ok: true, detail: `Connexion établie avec ${config.host} sur le port ${config.port} (${how})` }; } catch (error) { - return { ok: false, reason: error instanceof Error ? error.message : "Serveur SMTP injoignable" }; + return { ok: false, reason: describeSmtpError(error instanceof Error ? error.message : "", config) }; + } finally { + // Sans cela, un échec laisserait la socket au pool jusqu'à son expiration. + transport.close(); } } diff --git a/src/lib/smtp-config.ts b/src/lib/smtp-config.ts new file mode 100644 index 0000000..346f475 --- /dev/null +++ b/src/lib/smtp-config.ts @@ -0,0 +1,126 @@ +/** + * Port SMTP et chiffrement : deux réglages qui doivent s'accorder. + * + * Il existe deux façons de chiffrer une session SMTP, et elles ne se + * négocient pas sur le même port : + * + * - **TLS implicite** (port 465) : la connexion est chiffrée dès son + * ouverture, avant le moindre octet de protocole. + * - **STARTTLS** (ports 587, 25, 2525) : la connexion s'ouvre en clair, le + * client lit les capacités annoncées, puis demande `STARTTLS` pour passer + * la suite — authentification comprise — dans le tunnel. + * + * Se tromper ne produit pas un message d'erreur lisible : un client qui tente + * une poignée de main TLS sur le port 587 reçoit la bannière en clair du + * serveur et la lit comme un enregistrement TLS invalide (« wrong version + * number ») ; à l'inverse, un client qui parle en clair sur le port 465 + * attend une bannière qui ne viendra jamais et finit en délai dépassé. D'où + * ce module : l'écran accorde les deux champs tout seul, et les échecs sont + * traduits avant d'être affichés. + * + * Module **pur** (aucun accès réseau ni base), verrouillé par + * `tests/smtp-config.test.ts`. + */ + +/** Seul port où le chiffrement précède le protocole. */ +export const IMPLICIT_TLS_PORT = 465; + +/** Ports de soumission qui s'ouvrent en clair puis passent par STARTTLS. */ +export const STARTTLS_PORTS = [587, 25, 2525]; + +/** Le réglage « chiffré dès l'ouverture » qu'appelle un port donné. */ +export function impliedSecure(port: number): boolean { + return port === IMPLICIT_TLS_PORT; +} + +/** + * Message d'avertissement quand le port et la case ne s'accordent pas — + * `null` si le couple est cohérent. On explique, on ne bloque pas : + * un relais peut très bien écouter du TLS implicite sur un port exotique. + */ +export function tlsMismatch(port: number, secure: boolean): string | null { + if (secure && !impliedSecure(port)) { + return `Le port ${port} s'ouvre en clair puis passe par STARTTLS : en gardant la case cochée, la poignée de main TLS échouera dès la connexion.`; + } + if (!secure && impliedSecure(port)) { + return "Le port 465 chiffre dès l'ouverture : sans la case cochée, la connexion reste sans réponse jusqu'au délai dépassé."; + } + return null; +} + +export type SmtpErrorContext = { host?: string; port?: number; secure?: boolean }; + +const RAW_DETAIL = 110; + +function withDetail(explanation: string, raw: string): string { + const detail = raw.replace(/\s+/g, " ").trim(); + if (!detail) return explanation; + return `${explanation} (détail : ${detail.slice(0, RAW_DETAIL)})`; +} + +/** + * Traduit l'échec d'une connexion SMTP en phrase actionnable. + * + * L'explication passe **avant** le message d'origine : l'écran tronque le + * verdict, et c'est la consigne qu'il faut préserver, pas la trace OpenSSL. + */ +export function describeSmtpError(raw: string, context: SmtpErrorContext = {}): string { + const text = String(raw ?? ""); + const lower = text.toLowerCase(); + const where = context.host ? `${context.host}:${context.port ?? IMPLICIT_TLS_PORT}` : "le serveur"; + + // Bannière en clair lue comme du TLS : la case est cochée sur un port STARTTLS. + if (lower.includes("wrong version number") || lower.includes("ssl3_get_record") || lower.includes("packet length too long")) { + return withDetail( + `${where} a répondu en clair alors qu'une connexion chiffrée était attendue. Décochez « Connexion chiffrée dès l'ouverture » : sur le port ${context.port ?? 587}, le chiffrement se demande par STARTTLS.`, + text, + ); + } + + // Le serveur exige le tunnel avant l'authentification. + if (lower.includes("must issue a starttls") || lower.includes("starttls is required") || lower.includes("no starttls")) { + return withDetail( + `${where} exige STARTTLS avant l'authentification. Vérifiez que le port est bien un port de soumission (587) et non un port de relais filtré.`, + text, + ); + } + + // Poignée de main en clair sur un port qui chiffre d'emblée, ou port filtré. + if (lower.includes("greeting never received") || lower.includes("etimedout") || lower.includes("timed out") || lower.includes("timeout")) { + const hint = context.secure === false && impliedSecure(context.port ?? 0) + ? "Cochez « Connexion chiffrée dès l'ouverture » : le port 465 chiffre avant toute réponse." + : "Le port est probablement filtré en sortie : beaucoup d'hébergeurs bloquent les connexions SMTP sortantes, et il faut alors leur demander l'ouverture."; + return withDetail(`Aucune réponse de ${where}. ${hint}`, text); + } + + if (lower.includes("econnrefused")) { + return withDetail(`${where} a refusé la connexion : ce port n'écoute pas. Essayez 465 (chiffré dès l'ouverture) ou 587 (STARTTLS).`, text); + } + + if (lower.includes("enotfound") || lower.includes("eai_again") || lower.includes("getaddrinfo")) { + return withDetail(`Nom de serveur introuvable : vérifiez l'adresse « ${context.host ?? ""} ».`, text); + } + + if (lower.includes("eauth") || lower.includes("535") || lower.includes("invalid login") || lower.includes("authentication failed") || lower.includes("username and password not accepted")) { + return withDetail( + "Identifiants refusés. L'identifiant est l'adresse complète de la boîte, et certains fournisseurs exigent un mot de passe d'application plutôt que celui du compte.", + text, + ); + } + + if (lower.includes("altnames") || lower.includes("self signed") || lower.includes("self-signed") || lower.includes("unable to verify") || lower.includes("cert")) { + return withDetail( + `Le certificat présenté par ${where} ne correspond pas à ce nom de serveur. Utilisez le nom annoncé par le fournisseur plutôt qu'une adresse IP ou un alias.`, + text, + ); + } + + if (lower.includes("5.7.1") || lower.includes("relay") || lower.includes("not permitted") || lower.includes("sender address rejected")) { + return withDetail( + "Le serveur refuse cette adresse d'expédition : elle doit appartenir au domaine de la boîte qui authentifie la connexion.", + text, + ); + } + + return text || "Serveur SMTP injoignable"; +} diff --git a/tests/smtp-config.test.ts b/tests/smtp-config.test.ts new file mode 100644 index 0000000..e1cce05 --- /dev/null +++ b/tests/smtp-config.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + IMPLICIT_TLS_PORT, + STARTTLS_PORTS, + describeSmtpError, + impliedSecure, + tlsMismatch, +} from "../src/lib/smtp-config"; + +test("smtp — seul le port 465 chiffre dès l'ouverture", () => { + assert.equal(impliedSecure(IMPLICIT_TLS_PORT), true); + for (const port of STARTTLS_PORTS) assert.equal(impliedSecure(port), false); + assert.equal(impliedSecure(1025), false); +}); + +test("smtp — le couple port / chiffrement cohérent ne dit rien", () => { + assert.equal(tlsMismatch(465, true), null); + assert.equal(tlsMismatch(587, false), null); + assert.equal(tlsMismatch(25, false), null); +}); + +test("smtp — case cochée sur un port STARTTLS : on prévient avec le port", () => { + const warning = tlsMismatch(587, true); + assert.ok(warning); + assert.match(warning, /587/); + assert.match(warning, /STARTTLS/); +}); + +test("smtp — case décochée sur le port 465 : on prévient aussi", () => { + const warning = tlsMismatch(465, false); + assert.ok(warning); + assert.match(warning, /465/); +}); + +test("smtp — « wrong version number » désigne la case à décocher", () => { + const message = describeSmtpError( + "140342:error:0A00010B:SSL routines:ssl3_get_record:wrong version number", + { host: "mail.infomaniak.com", port: 587, secure: true }, + ); + assert.match(message, /Décochez/); + assert.match(message, /587/); +}); + +test("smtp — délai dépassé sur le 465 sans chiffrement : on désigne la case à cocher", () => { + const message = describeSmtpError("Greeting never received", { host: "mail.infomaniak.com", port: 465, secure: false }); + assert.match(message, /Cochez/); +}); + +test("smtp — délai dépassé sur un port STARTTLS : on évoque le filtrage sortant", () => { + const message = describeSmtpError("connect ETIMEDOUT 1.2.3.4:587", { host: "mail.infomaniak.com", port: 587, secure: false }); + assert.match(message, /filtré/); + assert.doesNotMatch(message, /Cochez/); +}); + +test("smtp — authentification refusée : on oriente vers le mot de passe d'application", () => { + const message = describeSmtpError("Invalid login: 535 5.7.8 Authentication failed", { host: "smtp.gmail.com", port: 587 }); + assert.match(message, /mot de passe d'application/); +}); + +test("smtp — nom de serveur introuvable : on cite le nom saisi", () => { + const message = describeSmtpError("getaddrinfo ENOTFOUND mail.exemple.invalid", { host: "mail.exemple.invalid" }); + assert.match(message, /mail\.exemple\.invalid/); +}); + +test("smtp — l'explication précède toujours la trace technique", () => { + const raw = "140342:error:0A00010B:SSL routines:ssl3_get_record:wrong version number"; + const message = describeSmtpError(raw, { host: "mail.infomaniak.com", port: 587, secure: true }); + assert.ok(message.indexOf("détail") > 0); + assert.ok(!message.startsWith(raw)); +}); + +test("smtp — un échec inconnu est rendu tel quel, jamais vide", () => { + assert.equal(describeSmtpError("Panne inattendue"), "Panne inattendue"); + assert.equal(describeSmtpError(""), "Serveur SMTP injoignable"); +});