mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 07:08:05 +02:00
Compare commits
1
Commits
2da125eca2
...
main
| 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
|
**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.
|
||||||
|
|||||||
@@ -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'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>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<form action={saveMailApp}>
|
<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 { 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,14 +103,19 @@ 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);
|
||||||
await transport.sendMail({
|
try {
|
||||||
from: formatAddress(sender.fromAddress, sender.fromName),
|
await transport.sendMail({
|
||||||
to: formatAddress(mail.to, mail.toName),
|
from: formatAddress(sender.fromAddress, sender.fromName),
|
||||||
replyTo: mail.replyTo ?? undefined,
|
to: formatAddress(mail.to, mail.toName),
|
||||||
subject: mail.subject,
|
replyTo: mail.replyTo ?? undefined,
|
||||||
html: mail.html,
|
subject: mail.subject,
|
||||||
text: mail.text ?? undefined,
|
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" };
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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