Files
PleinR/src/app/api/mail/[provider]/connect/route.ts
T
Claude 8d4443a1a1 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
2026-09-14 16:00:11 +00:00

63 lines
2.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}