mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 22:58:04 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e340dde58 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
<div style={{ marginTop: 13 }}>
|
||||
<Banner tone="error">{verdict.reason.slice(0, 220)}</Banner>
|
||||
<Banner tone="error">{verdict.reason.slice(0, 400)}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
|
||||
|
||||
{provider === "smtp" ? (
|
||||
<form action={saveMailSmtp}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
|
||||
<label className="field-label">
|
||||
Serveur
|
||||
<input className="field" name="smtpHost" defaultValue={account?.smtpHost ?? ""} placeholder="smtp.ionos.fr" style={{ marginTop: 6 }} />
|
||||
</label>
|
||||
<label className="field-label">
|
||||
Port
|
||||
<input className="field" name="smtpPort" type="number" min={1} max={65535} defaultValue={account?.smtpPort ?? 465} style={{ marginTop: 6 }} />
|
||||
</label>
|
||||
<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'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'ouverture (port 465). Décochez pour STARTTLS (port 587).
|
||||
</label>
|
||||
<p className="field-hint" style={{ marginTop: 8 }}>
|
||||
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.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
|
||||
<button type="submit" style={primary}>Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
<SmtpAccountForm
|
||||
saveAction={saveMailSmtp}
|
||||
host={account?.smtpHost ?? ""}
|
||||
port={account?.smtpPort ?? 465}
|
||||
secure={account?.smtpSecure ?? true}
|
||||
user={account?.smtpUser ?? ""}
|
||||
hasPassword={Boolean(account?.smtpPassword)}
|
||||
fromAddress={account?.fromAddress ?? ""}
|
||||
fromName={account?.fromName ?? settings.association_name}
|
||||
submitStyle={primary}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<form action={saveMailApp}>
|
||||
|
||||
@@ -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'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'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'adresse d'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>
|
||||
);
|
||||
}
|
||||
+39
-12
@@ -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<Sen
|
||||
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
|
||||
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<Send
|
||||
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
|
||||
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
|
||||
* 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) {
|
||||
const nodemailer = (await import("nodemailer")).default;
|
||||
@@ -124,18 +141,28 @@ async function smtpTransport(config: SmtpConfig) {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
requireTLS: !config.secure,
|
||||
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. */
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user