mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
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
127 lines
6.0 KiB
TypeScript
127 lines
6.0 KiB
TypeScript
/**
|
|
* 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";
|
|
}
|