Boîte mail de l'association : trois transports, une file d'attente

Le site savait composer de beaux messages et ne savait pas les envoyer :
`email-client.ts` ne proposait que la copie ou le téléchargement d'un
brouillon Outlook, complété à la main. `/backend/boite-mail` branche
désormais la boîte de l'association, sur le modèle exact de l'écran
Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ
laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne
lève jamais.

Trois transports, un seul actif à la fois, désigné par l'administrateur —
pas de cascade automatique : si Google se bloque, la bascule se voit.

- **Google** passe par l'API Gmail plutôt que par SMTP avec XOAUTH2 :
  celui-ci exigerait `https://mail.google.com/`, portée *restreinte* et
  donc audit de sécurité, là où `gmail.send` est simplement *sensible*.
- **Microsoft** passe par Graph : l'authentification basique SMTP est
  désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
- **SMTP** couvre le reste via `nodemailer`, seule dépendance ajoutée.

`from_address` est **lu chez le fournisseur** et non saisi : Gmail expédie
comme l'utilisateur authentifié, Graph comme la boîte. Une adresse d'un
autre domaine ferait tomber SPF et DKIM.

La file `mail_messages` porte un destinataire unique par ligne — la
confidentialité d'une diffusion est structurelle, aucune copie partagée
n'est possible. Elle est vidée par la boucle de fond, avec réclamation en
`FOR UPDATE SKIP LOCKED`, réessais espacés et reprise des verrous laissés
par un conteneur arrêté en plein envoi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-14 16:00:11 +00:00
1 parent d57e544a18
commit 8d4443a1a1
19 files changed
+3997 -16

No files matched your search

+399
View File
@@ -0,0 +1,399 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
MAIL_LABELS,
MAIL_PROVIDERS,
getMailAccounts,
mailHealthCached,
type MailHealth,
} from "@/lib/mail-accounts";
import { recentMails } from "@/lib/mail-outbox";
import { publicBaseUrl } from "@/lib/social-accounts";
import { getSiteSettings } from "@/lib/site-settings";
import type { MailAccount, MailProvider } from "@/db/schema";
import {
cancelMail,
checkMailProvider,
disconnectMail,
retryMail,
saveMailApp,
saveMailSmtp,
sendMailTest,
setMailProvider,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "18px 20px", marginBottom: 16 };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const ghost: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12.5, padding: "8px 13px", borderRadius: 9, cursor: "pointer", textDecoration: "none", display: "inline-block" };
const primary: CSSProperties = { ...ghost, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 };
const danger: CSSProperties = { ...ghost, color: "#d8472b", borderColor: "#e0c3bb" };
const pill: CSSProperties = { borderRadius: 999, padding: "5px 11px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
const BRAND: Record<MailProvider, { color: string; mark: string }> = {
google: { color: "#1a73e8", mark: "G" },
microsoft: { color: "#0f6cbd", mark: "M" },
smtp: { color: "#6c6150", mark: "@" },
};
const HELP: Record<"google" | "microsoft", { portal: string; steps: string[]; caution: string }> = {
google: {
portal: "https://console.cloud.google.com/apis/credentials",
steps: [
"Créez un projet, puis un identifiant OAuth de type « Application Web ».",
"Activez l'API Gmail dans la bibliothèque d'API du projet.",
"Sur l'écran de consentement, ajoutez la portée .../auth/gmail.send.",
"Déclarez l'adresse de retour ci-dessous comme URI de redirection autorisé.",
"Collez l'ID client et le code secret du client ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Tant que l'application reste en mode « Test », Google fait expirer l'autorisation au bout de 7 jours et l'envoi s'arrête sans prévenir. Passez-la « En production » (un écran d'avertissement subsiste, sans conséquence), ou déclarez-la « Interne » si l'association a un compte Google Workspace.",
},
microsoft: {
portal: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
steps: [
"Inscrivez une application, en autorisant « comptes dans un annuaire quelconque et comptes Microsoft personnels ».",
"Ajoutez l'adresse de retour ci-dessous comme URI de redirection de type « Web ».",
"Dans les autorisations d'API, ajoutez les permissions déléguées Mail.Send, User.Read et offline_access.",
"Créez un secret client et notez sa valeur : elle n'est affichée qu'une fois.",
"Collez l'ID d'application et le secret ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Microsoft a désactivé l'authentification par simple mot de passe (SMTP AUTH) sur les boîtes outlook.com et hotmail.com : cette connexion par bouton est le seul chemin fiable pour ces adresses.",
},
};
function Banner({ tone, children }: { tone: "error" | "ok" | "warn"; children: ReactNode }) {
const palette = {
error: { background: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
ok: { background: "#e6f4ec", border: "#c4e2d1", color: "#1f8a5b" },
warn: { background: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
}[tone];
return (
<div role="status" style={{ ...palette, border: `1px solid ${palette.border}`, borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{children}
</div>
);
}
function StatusChip({ account, health }: { account: MailAccount | undefined; health: MailHealth | null }) {
if (!account) return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
if (health && !health.ok) return <span style={{ ...pill, background: "#fbe9e6", color: "#d8472b" }}>Ne répond plus</span>;
if (account.provider !== "smtp" && !account.fromAddress) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non connectée</span>;
}
if (account.provider === "smtp" && !account.smtpHost) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
}
return <span style={{ ...pill, background: "#e6f4ec", color: "#1f8a5b" }}>Connectée</span>;
}
function formatMoment(value: Date) {
return new Date(value).toLocaleString("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
const MAIL_STATUS: Record<string, { label: string; background: string; color: string }> = {
queued: { label: "En file", background: "#eaf0f6", color: "#2C6FB3" },
sending: { label: "En cours", background: "#eaf0f6", color: "#2C6FB3" },
sent: { label: "Remis", background: "#e6f4ec", color: "#1f8a5b" },
failed: { label: "Échec", background: "#fbe9e6", color: "#d8472b" },
cancelled: { label: "Annulé", background: "#f1efe7", color: "#a99c82" },
};
/**
* Boîte mail de l'association.
*
* Même structure que Backend › Réseaux sociaux, et mêmes règles : aucun secret
* ne descend vers le navigateur — le champ mot de passe part vide et un champ
* vide conserve la valeur enregistrée.
*/
export default async function BoiteMailPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; ok?: string; connected?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const { error, ok, connected } = await searchParams;
const [accounts, settings, base, log] = await Promise.all([
getMailAccounts(),
getSiteSettings(),
publicBaseUrl(),
recentMails(20),
]);
const byProvider = new Map(accounts.map((account) => [account.provider, account]));
const health = Object.fromEntries(
await Promise.all(MAIL_PROVIDERS.map(async (provider) => [provider, await mailHealthCached(provider)] as const)),
) as Record<MailProvider, MailHealth | null>;
const active = accounts.find((account) => account.isActive) ?? null;
return (
<div>
{error && <Banner tone="error">{error}</Banner>}
{ok && <Banner tone="ok">{ok}</Banner>}
{connected && <Banner tone="ok">Boîte {MAIL_LABELS[connected as MailProvider] ?? connected} connectée.</Banner>}
{!active && (
<Banner tone="warn">
Aucune boîte n&apos;expédie pour l&apos;instant : les mots de passe temporaires restent affichés à
l&apos;écran, et aucune information ne part par e-mail.
</Banner>
)}
{MAIL_PROVIDERS.map((provider) => {
const account = byProvider.get(provider);
const verdict = health[provider];
const brand = BRAND[provider];
const isActive = account?.isActive ?? false;
const connectable = provider !== "smtp";
const usable = provider === "smtp" ? Boolean(account?.smtpHost) : Boolean(account?.fromAddress);
return (
<section
key={provider}
style={{ ...panel, ...(isActive ? { borderColor: "#b9d3c6", boxShadow: "inset 4px 0 0 #1f8a5b" } : {}) }}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: 13, alignItems: "center" }}>
<span
aria-hidden="true"
className="font-display"
style={{ width: 40, height: 40, borderRadius: 10, background: brand.color, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}
>
{brand.mark}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 16, color: "#26201a" }}>{MAIL_LABELS[provider]}</div>
<div style={{ color: "#9a8d72", fontSize: 12.5 }}>
{provider === "smtp"
? account?.smtpHost
? `${account.smtpHost} : ${account.smtpPort ?? 465} · ${account.smtpUser}`
: "Aucun serveur enregistré"
: account?.fromAddress
? `${account.fromAddress}${account.connectedAt ? ` · connectée le ${formatMoment(account.connectedAt)}` : ""}`
: "Aucun compte relié"}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 9, alignItems: "center" }}>
<StatusChip account={account} health={verdict} />
{usable && (
<form action={setMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" disabled={isActive} style={{ ...(isActive ? { ...ghost, borderColor: "#b9d3c6", color: "#1f8a5b", cursor: "default" } : ghost) }}>
{isActive ? "✓ Expédie les messages" : "Expédier par ici"}
</button>
</form>
)}
</div>
</div>
{verdict && !verdict.ok && (
<div style={{ marginTop: 13 }}>
<Banner tone="error">{verdict.reason.slice(0, 220)}</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&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}>
<input type="hidden" name="provider" value={provider} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 12 }}>
<label className="field-label">
Identifiant de l&apos;application
<input className="field" name="appId" required defaultValue={account?.appId ?? ""} style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Secret de l&apos;application
<input
className="field"
name="appSecret"
type="password"
autoComplete="new-password"
placeholder={account?.appSecret ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
style={{ marginTop: 6 }}
/>
</label>
</div>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
<button type="submit" style={primary}>Enregistrer</button>
<Link
href={account?.appId ? `/api/mail/${provider}/connect` : "#"}
aria-disabled={!account?.appId}
style={{ ...ghost, ...(account?.appId ? {} : { opacity: 0.5, pointerEvents: "none" }) }}
>
{account?.fromAddress ? "Reconnecter la boîte" : "Connecter la boîte"}
</Link>
</div>
</form>
<details style={{ marginTop: 14 }}>
<summary style={{ cursor: "pointer", fontSize: 13, fontWeight: 700, color: "#9a6638" }}>
Comment obtenir ces identifiants ?
</summary>
<ol style={{ margin: "11px 0 0", paddingLeft: 20, color: "#6c6150", fontSize: 13, lineHeight: 1.7 }}>
{HELP[provider].steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p style={{ margin: "11px 0 0", color: "#6c6150", fontSize: 13 }}>
Adresse de retour à déclarer :{" "}
<code style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 5, padding: "1px 6px", fontSize: 12.5 }}>
{base ? `${base}/api/mail/${provider}/callback` : "définissez d'abord l'URL publique du site"}
</code>
</p>
<p style={{ margin: "11px 0 0" }}>
<Link href={HELP[provider].portal} target="_blank" style={{ color: "#2C6FB3", fontSize: 13, fontWeight: 700 }}>
Ouvrir la console {provider === "google" ? "Google Cloud" : "Microsoft Entra"} ↗
</Link>
</p>
<div style={{ marginTop: 12 }}>
<Banner tone="warn">{HELP[provider].caution}</Banner>
</div>
</details>
</>
)}
{usable && (
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<form action={checkMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={ghost}>Vérifier la connexion</button>
</form>
{connectable && account?.fromAddress && (
<form action={disconnectMail}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={danger}>Déconnecter</button>
</form>
)}
{verdict?.ok && <span style={{ alignSelf: "center", color: "#1f8a5b", fontSize: 12.5 }}>✓ {verdict.detail}</span>}
</div>
)}
</section>
);
})}
<section style={panel}>
<div style={eyebrow}>Envoi de contrôle</div>
<form action={sendMailTest} style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<button type="submit" disabled={!active} style={{ ...primary, ...(active ? {} : { opacity: 0.5 }) }}>
M&apos;envoyer un message de test
</button>
<span className="field-hint">
Part immédiatement vers votre propre adresse et affiche le verdict, sans passer par la file.
</span>
</form>
</section>
<section style={panel}>
<div style={eyebrow}>Journal d&apos;envoi</div>
{log.length === 0 ? (
<div style={{ color: "#a99c82", fontSize: 13 }}>Aucun message expédié pour l&apos;instant.</div>
) : (
<div className="table-scroll">
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12.8 }}>
<thead>
<tr>
{["Quand", "Destinataire", "Objet", "État", ""].map((header) => (
<th key={header} style={{ textAlign: "left", fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, padding: "0 10px 8px 0", whiteSpace: "nowrap" }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{log.map((message) => {
const status = MAIL_STATUS[message.status] ?? MAIL_STATUS.queued;
return (
<tr key={message.id}>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068", whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>
{formatMoment(message.createdAt)}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#26201a", fontWeight: 600 }}>{message.toAddress}</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068" }}>
{message.subject}
{message.error && <div style={{ color: "#d8472b", fontSize: 11.5, marginTop: 3 }}>{message.error.slice(0, 160)}</div>}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6" }}>
<span style={{ ...pill, background: status.background, color: status.color }}>{status.label}</span>
</td>
<td style={{ padding: "9px 0", borderTop: "1px solid #f0e8d6", whiteSpace: "nowrap" }}>
{message.status === "failed" && (
<form action={retryMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Réessayer</button>
</form>
)}
{message.status === "queued" && (
<form action={cancelMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Annuler</button>
</form>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}