Boîte mail : accorder le port SMTP et le chiffrement

Le port et la case « connexion chiffrée » étaient deux champs indépendants
qu'il fallait accorder de tête. Passer en 587 sans décocher fait tenter une
poignée de main TLS sur un port qui répond en clair : la connexion meurt sur
un « wrong version number » que rien ne traduisait.

- `src/lib/smtp-config.ts` (pur, testé) : `impliedSecure()`, `tlsMismatch()`
  et `describeSmtpError()`, qui traduit les échecs courants — mauvaise case,
  port filtré, identifiants refusés, certificat, expéditeur rejeté — en
  consigne actionnable, l'explication avant la trace puisque l'écran tronque.
- `SmtpAccountForm` (client) réaligne la case sur le port saisi, propose les
  deux ports courants en pastilles et affiche le désaccord s'il subsiste. Le
  mot de passe enregistré ne descend pas en prop, seul un booléen le fait.
- Transport : `requireTLS` hors TLS implicite — sans lui STARTTLS reste
  opportuniste et l'identifiant partirait en clair chez un serveur qui ne
  l'annonce pas — SNI explicite, TLS 1.2 minimum, et délais ramenés à
  quelques secondes pour qu'un port bloqué en sortie rende son verdict au
  lieu de figer l'action serveur.
- `verifySmtp` referme la socket même en échec et dit sur quel port et dans
  quel mode la connexion a abouti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RCUSintYfXnBtwFKQezFSt
This commit is contained in:
Claude committed 2026-09-15 13:51:51 +00:00
1 parent 2da125eca2
commit 0e340dde58
6 files changed
+425 -52

No files matched your search

+15
View File
@@ -382,6 +382,21 @@ pas de cascade automatique, la bascule est un choix visible.
**Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP **Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP
est désactivée depuis 2024, y compris sur outlook.com et hotmail.com. 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. **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, - `mail_accounts.from_address` est **lu chez le fournisseur** au retour OAuth,
jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la 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. boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM.
+12 -48
View File
@@ -11,6 +11,7 @@ import {
type MailHealth, type MailHealth,
} from "@/lib/mail-accounts"; } from "@/lib/mail-accounts";
import { recentMails } from "@/lib/mail-outbox"; import { recentMails } from "@/lib/mail-outbox";
import { SmtpAccountForm } from "@/components/SmtpAccountForm";
import { publicBaseUrl } from "@/lib/social-accounts"; import { publicBaseUrl } from "@/lib/social-accounts";
import { getSiteSettings } from "@/lib/site-settings"; import { getSiteSettings } from "@/lib/site-settings";
import type { MailAccount, MailProvider } from "@/db/schema"; import type { MailAccount, MailProvider } from "@/db/schema";
@@ -194,61 +195,24 @@ export default async function BoiteMailPage({
{verdict && !verdict.ok && ( {verdict && !verdict.ok && (
<div style={{ marginTop: 13 }}> <div style={{ marginTop: 13 }}>
<Banner tone="error">{verdict.reason.slice(0, 220)}</Banner> <Banner tone="error">{verdict.reason.slice(0, 400)}</Banner>
</div> </div>
)} )}
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} /> <div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
{provider === "smtp" ? ( {provider === "smtp" ? (
<form action={saveMailSmtp}> <SmtpAccountForm
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}> saveAction={saveMailSmtp}
<label className="field-label"> host={account?.smtpHost ?? ""}
Serveur port={account?.smtpPort ?? 465}
<input className="field" name="smtpHost" defaultValue={account?.smtpHost ?? ""} placeholder="smtp.ionos.fr" style={{ marginTop: 6 }} /> secure={account?.smtpSecure ?? true}
</label> user={account?.smtpUser ?? ""}
<label className="field-label"> hasPassword={Boolean(account?.smtpPassword)}
Port fromAddress={account?.fromAddress ?? ""}
<input className="field" name="smtpPort" type="number" min={1} max={65535} defaultValue={account?.smtpPort ?? 465} style={{ marginTop: 6 }} /> fromName={account?.fromName ?? settings.association_name}
</label> submitStyle={primary}
<label className="field-label">
Identifiant
<input className="field" name="smtpUser" defaultValue={account?.smtpUser ?? ""} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
<label className="field-label">
Mot de passe
<input
className="field"
name="smtpPassword"
type="password"
autoComplete="new-password"
placeholder={account?.smtpPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
style={{ marginTop: 6 }}
/> />
</label>
<label className="field-label">
Adresse d&apos;expédition
<input className="field" name="fromAddress" defaultValue={account?.fromAddress ?? ""} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Nom affiché
<input className="field" name="fromName" defaultValue={account?.fromName ?? settings.association_name} style={{ marginTop: 6 }} />
</label>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
<input type="checkbox" name="smtpSecure" defaultChecked={account?.smtpSecure ?? true} />
Connexion chiffrée dès l&apos;ouverture (port 465). Décochez pour STARTTLS (port 587).
</label>
<p className="field-hint" style={{ marginTop: 8 }}>
L&apos;adresse d&apos;expédition doit appartenir au domaine qui authentifie la connexion, sinon
SPF et DKIM échouent et les messages partent en indésirable.
</p>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
<button type="submit" style={primary}>Enregistrer</button>
</div>
</form>
) : ( ) : (
<> <>
<form action={saveMailApp}> <form action={saveMailApp}>
+165
View File
@@ -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<void>;
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 (
<form action={saveAction}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
<label className="field-label">
Serveur
<input
className="field"
name="smtpHost"
value={currentHost}
onChange={(event) => setHost(event.target.value)}
placeholder="smtp.ionos.fr"
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Port
<input
className="field"
name="smtpPort"
type="number"
min={1}
max={65535}
value={currentPort}
onChange={(event) => applyPort(event.target.value)}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Identifiant
<input className="field" name="smtpUser" defaultValue={user} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
</label>
</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#9a8d72", fontWeight: 700 }}>Ports courants</span>
{PRESETS.map((preset) => {
const on = numericPort === preset.port;
return (
<button
key={preset.port}
type="button"
onClick={() => applyPort(String(preset.port))}
style={{
border: `1px solid ${on ? "#13324F" : "#d8cdb4"}`,
background: on ? "#13324F" : "#fff",
color: on ? "#fff" : "#6c6150",
fontWeight: 700,
fontSize: 12,
padding: "6px 11px",
borderRadius: 999,
cursor: "pointer",
}}
>
{preset.label}
</button>
);
})}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
<label className="field-label">
Mot de passe
<input
className="field"
name="smtpPassword"
type="password"
autoComplete="new-password"
placeholder={hasPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Adresse d&apos;expédition
<input className="field" name="fromAddress" defaultValue={fromAddress} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Nom affiché
<input className="field" name="fromName" defaultValue={fromName} style={{ marginTop: 6 }} />
</label>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
<input
type="checkbox"
name="smtpSecure"
checked={currentSecure}
onChange={(event) => setSecure(event.target.checked)}
/>
Connexion chiffrée dès l&apos;ouverture (port 465). Décochez pour STARTTLS (port 587).
</label>
{warning && (
<p style={{ marginTop: 8, fontSize: 12.5, color: "#a4571f", background: "#fdf3e3", border: "1px solid #ecd9b4", borderRadius: 9, padding: "9px 11px" }}>
{warning}
</p>
)}
<p className="field-hint" style={{ marginTop: 8 }}>
L&apos;adresse d&apos;expédition doit appartenir au domaine qui authentifie la connexion, sinon
SPF et DKIM échouent et les messages partent en indésirable.
</p>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
<button type="submit" style={submitStyle}>Enregistrer</button>
</div>
</form>
);
}
+31 -4
View File
@@ -1,5 +1,6 @@
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime"; import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts"; import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
import { describeSmtpError } from "@/lib/smtp-config";
/** /**
* Expédition d'un message. * Expédition d'un message.
@@ -102,6 +103,7 @@ async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise<Sen
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> { async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." }; if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
const transport = await smtpTransport(sender.smtp); const transport = await smtpTransport(sender.smtp);
try {
await transport.sendMail({ await transport.sendMail({
from: formatAddress(sender.fromAddress, sender.fromName), from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName), to: formatAddress(mail.to, mail.toName),
@@ -110,6 +112,10 @@ async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<Send
html: mail.html, html: mail.html,
text: mail.text ?? undefined, 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" }; return { ok: true, provider: "smtp" };
} }
@@ -117,6 +123,17 @@ async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<Send
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il * `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne * est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
* jamais entrer dans un bundle qui ne les a pas. * jamais entrer dans un bundle qui ne les a pas.
*
* Deux réglages qui ne sont pas des détails :
*
* - `requireTLS` hors TLS implicite : sans lui, STARTTLS est *opportuniste*.
* Un serveur qui ne l'annonce pas — ou un intermédiaire qui retire la
* capacité de la réponse EHLO — ferait partir l'identifiant et le mot de
* passe en clair sans que rien ne le signale. On préfère l'échec.
* - les délais : ceux de `nodemailer` se comptent en minutes, or ces
* connexions sont ouvertes depuis une action serveur. Un port filtré en
* sortie — cas courant chez les hébergeurs — doit rendre son verdict en
* quelques secondes, pas bloquer l'écran.
*/ */
async function smtpTransport(config: SmtpConfig) { async function smtpTransport(config: SmtpConfig) {
const nodemailer = (await import("nodemailer")).default; const nodemailer = (await import("nodemailer")).default;
@@ -124,18 +141,28 @@ async function smtpTransport(config: SmtpConfig) {
host: config.host, host: config.host,
port: config.port, port: config.port,
secure: config.secure, secure: config.secure,
requireTLS: !config.secure,
auth: { user: config.user, pass: config.password }, auth: { user: config.user, pass: config.password },
connectionTimeout: 12_000,
greetingTimeout: 12_000,
socketTimeout: 30_000,
// SNI explicite : sans lui, un hôte joint par IP casse la validation.
tls: { servername: config.host, minVersion: "TLSv1.2" },
}); });
} }
/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */ /** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */
export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> { export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> {
const transport = await smtpTransport(config).catch(() => null);
if (!transport) return { ok: false, reason: "Transport SMTP indisponible." };
try { try {
const transport = await smtpTransport(config);
await transport.verify(); await transport.verify();
transport.close(); const how = config.secure ? "chiffrée dès l'ouverture" : "STARTTLS";
return { ok: true, detail: `Connexion établie avec ${config.host}` }; return { ok: true, detail: `Connexion établie avec ${config.host} sur le port ${config.port} (${how})` };
} catch (error) { } 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();
} }
} }
+126
View File
@@ -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";
}
+76
View File
@@ -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");
});