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 = { 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 (
{children}
); } function StatusChip({ account, health }: { account: MailAccount | undefined; health: MailHealth | null }) { if (!account) return Non configurée; if (health && !health.ok) return Ne répond plus; if (account.provider !== "smtp" && !account.fromAddress) { return Non connectée; } if (account.provider === "smtp" && !account.smtpHost) { return Non configurée; } return Connectée; } 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 = { 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; const active = accounts.find((account) => account.isActive) ?? null; return (
{error && {error}} {ok && {ok}} {connected && Boîte {MAIL_LABELS[connected as MailProvider] ?? connected} connectée.} {!active && ( Aucune boîte n'expédie pour l'instant : les mots de passe temporaires restent affichés à l'écran, et aucune information ne part par e-mail. )} {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 (
{MAIL_LABELS[provider]}
{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é"}
{usable && (
)}
{verdict && !verdict.ok && (
{verdict.reason.slice(0, 220)}
)}
{provider === "smtp" ? (

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.

) : ( <>
{account?.fromAddress ? "Reconnecter la boîte" : "Connecter la boîte"}
Comment obtenir ces identifiants ?
    {HELP[provider].steps.map((step) => (
  1. {step}
  2. ))}

Adresse de retour à déclarer :{" "} {base ? `${base}/api/mail/${provider}/callback` : "définissez d'abord l'URL publique du site"}

Ouvrir la console {provider === "google" ? "Google Cloud" : "Microsoft Entra"} ↗

{HELP[provider].caution}
)} {usable && (
{connectable && account?.fromAddress && (
)} {verdict?.ok && ✓ {verdict.detail}}
)}
); })}
Envoi de contrôle
Part immédiatement vers votre propre adresse et affiche le verdict, sans passer par la file.
Journal d'envoi
{log.length === 0 ? (
Aucun message expédié pour l'instant.
) : (
{["Quand", "Destinataire", "Objet", "État", ""].map((header) => ( ))} {log.map((message) => { const status = MAIL_STATUS[message.status] ?? MAIL_STATUS.queued; return ( ); })}
{header}
{formatMoment(message.createdAt)} {message.toAddress} {message.subject} {message.error &&
{message.error.slice(0, 160)}
}
{status.label} {message.status === "failed" && (
)} {message.status === "queued" && (
)}
)}
); }