mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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
63 lines
2.2 KiB
TypeScript
63 lines
2.2 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
||
import { NextResponse } from "next/server";
|
||
import { getSession } from "@/lib/session";
|
||
import { can } from "@/lib/rbac";
|
||
import {
|
||
getDecryptedAppSecret,
|
||
getMailAccount,
|
||
mailAuthorizeUrl,
|
||
MAIL_PROVIDERS,
|
||
} from "@/lib/mail-accounts";
|
||
import { publicBaseUrl } from "@/lib/social-accounts";
|
||
import type { MailProvider } from "@/db/schema";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const SETTINGS = "/backend/boite-mail";
|
||
|
||
function back(request: Request, error: string) {
|
||
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
|
||
}
|
||
|
||
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
|
||
const session = await getSession();
|
||
if (!can(session?.user.role, "manageSettings")) {
|
||
return NextResponse.redirect(new URL("/backend", request.url));
|
||
}
|
||
|
||
const { provider: raw } = await params;
|
||
const provider = raw as MailProvider;
|
||
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
|
||
return back(request, "Fournisseur inconnu.");
|
||
}
|
||
|
||
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
|
||
// arrivé : c'est celle où le fournisseur nous renverra.
|
||
const base = await publicBaseUrl();
|
||
if (!base) {
|
||
return back(
|
||
request,
|
||
"Impossible de déterminer l'adresse publique du site : renseignez-la dans Backend › Réseaux sociaux."
|
||
);
|
||
}
|
||
|
||
const account = await getMailAccount(provider);
|
||
const appSecret = await getDecryptedAppSecret(provider);
|
||
if (!account?.appId || !appSecret) {
|
||
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
|
||
}
|
||
|
||
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
|
||
// de rappel forgée. Aucun jeton ne transite jamais par une URL.
|
||
const state = randomBytes(24).toString("hex");
|
||
const response = NextResponse.redirect(await mailAuthorizeUrl(provider, account.appId, state, base));
|
||
response.cookies.set(`plr_mail_oauth_${provider}`, state, {
|
||
httpOnly: true,
|
||
sameSite: "lax",
|
||
secure: new URL(base).protocol === "https:",
|
||
path: "/",
|
||
maxAge: 600,
|
||
});
|
||
return response;
|
||
}
|