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

+58
View File
@@ -0,0 +1,58 @@
CREATE TYPE "public"."mail_kind" AS ENUM('credentials', 'information', 'meeting', 'studio', 'test');--> statement-breakpoint
CREATE TYPE "public"."mail_provider" AS ENUM('google', 'microsoft', 'smtp');--> statement-breakpoint
CREATE TYPE "public"."mail_status" AS ENUM('queued', 'sending', 'sent', 'failed', 'cancelled');--> statement-breakpoint
CREATE TABLE "mail_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"provider" "mail_provider" NOT NULL,
"from_address" varchar(200),
"from_name" varchar(200),
"is_active" boolean DEFAULT false NOT NULL,
"app_id" varchar(200),
"app_secret" text,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"smtp_host" varchar(200),
"smtp_port" integer,
"smtp_secure" boolean DEFAULT true NOT NULL,
"smtp_user" varchar(200),
"smtp_password" text,
"connected_by_id" integer,
"connected_at" timestamp with time zone,
"last_check_at" timestamp with time zone,
"last_check_ok" boolean,
"last_check_error" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mail_accounts_provider_unique" UNIQUE("provider")
);
--> statement-breakpoint
CREATE TABLE "mail_messages" (
"id" serial PRIMARY KEY NOT NULL,
"kind" "mail_kind" NOT NULL,
"status" "mail_status" DEFAULT 'queued' NOT NULL,
"to_address" varchar(200) NOT NULL,
"to_name" varchar(200),
"subject" varchar(300) NOT NULL,
"html" text NOT NULL,
"text" text,
"reply_to" varchar(200),
"information_id" integer,
"meeting_id" integer,
"member_id" integer,
"created_by_id" integer,
"provider" "mail_provider",
"attempts" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"locked_at" timestamp with time zone,
"sent_at" timestamp with time zone,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mail_accounts" ADD CONSTRAINT "mail_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mail_messages_due_idx" ON "mail_messages" USING btree ("status","next_attempt_at");--> statement-breakpoint
CREATE INDEX "mail_messages_information_idx" ON "mail_messages" USING btree ("information_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -134,6 +134,13 @@
"when": 1789399747132, "when": 1789399747132,
"tag": "0018_lazy_big_bertha", "tag": "0018_lazy_big_bertha",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789401051538,
"tag": "0019_nostalgic_warstar",
"breakpoints": true
} }
] ]
} }
+3
View File
@@ -4,6 +4,9 @@ const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile. // Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
poweredByHeader: false, poweredByHeader: false,
// `nodemailer` charge net/tls/dns par des requires dynamiques : laissé au
// bundler, il se retrouve à moitié inliné et casse à l'exécution.
serverExternalPackages: ["nodemailer"],
experimental: { experimental: {
// Les images d'entête/logo sont envoyées en data-URL via server action. // Les images d'entête/logo sont envoyées en data-URL via server action.
serverActions: { bodySizeLimit: "4mb" }, serverActions: { bodySizeLimit: "4mb" },
+21
View File
@@ -13,6 +13,7 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"next": "^15.5.25", "next": "^15.5.25",
"next-auth": "^5.0.0-beta.32", "next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0", "pg": "^8.23.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
@@ -21,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/react": "^19.0.7", "@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
@@ -1220,6 +1222,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1711,6 +1723,15 @@
} }
} }
}, },
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/oauth4webapi": { "node_modules/oauth4webapi": {
"version": "3.8.6", "version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
+2
View File
@@ -20,6 +20,7 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"next": "^15.5.25", "next": "^15.5.25",
"next-auth": "^5.0.0-beta.32", "next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0", "pg": "^8.23.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
@@ -28,6 +29,7 @@
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/react": "^19.0.7", "@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
exchangeMailCode,
getActiveMailAccount,
getDecryptedAppSecret,
getMailAccount,
saveMailConnection,
setActiveMailProvider,
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, params: Record<string, string>) {
const url = new URL(SETTINGS, request.url);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = NextResponse.redirect(url);
// Le state a fait son office, quel que soit le résultat.
for (const provider of MAIL_PROVIDERS) response.cookies.delete(`plr_mail_oauth_${provider}`);
return response;
}
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, { error: "Fournisseur inconnu." });
}
const url = new URL(request.url);
const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (failure) return back(request, { error: `Autorisation refusée : ${failure}` });
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = request.headers
.get("cookie")
?.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`plr_mail_oauth_${provider}=`))
?.split("=")[1];
if (!code || !state || !expected || state !== expected) {
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, { error: "Identifiants d'application introuvables." });
}
const base = await publicBaseUrl();
if (!base) return back(request, { error: "Adresse publique du site introuvable." });
try {
const result = await exchangeMailCode(provider, account.appId, appSecret, code, base);
const userId = Number(session?.user.id);
await saveMailConnection({
provider,
...result,
connectedById: Number.isFinite(userId) ? userId : null,
});
// Première boîte connectée : elle devient l'expéditeur, sinon l'écran
// afficherait une connexion réussie sans qu'aucun message ne parte. Si un
// autre fournisseur expédie déjà, on ne le détrône pas dans son dos.
if (!(await getActiveMailAccount())) await setActiveMailProvider(provider);
return back(request, { connected: provider });
} catch (caught) {
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
return back(request, { error: message.slice(0, 400) });
}
}
@@ -0,0 +1,62 @@
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;
}
+2
View File
@@ -22,6 +22,7 @@ const TITLES: Record<string, [string, string]> = {
"/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"], "/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"],
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"], "/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
"/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"], "/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"],
"/backend/boite-mail": ["Boîte mail", "Brancher la boîte de l'association pour les envois du site"],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"], "/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"], "/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"], "/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
@@ -83,6 +84,7 @@ const SECTIONS: NavSection[] = [
label: "Configuration", label: "Configuration",
items: [ items: [
{ href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" }, { href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" },
{ href: "/backend/boite-mail", label: "Boîte mail", icon: "mailbox", capability: "manageSettings" },
{ href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" }, { href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" },
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" }, { href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
], ],
+157
View File
@@ -43,6 +43,22 @@ import { isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule"; import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish"; import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
import { logActivity } from "@/lib/activity-log"; import { logActivity } from "@/lib/activity-log";
import {
MAIL_LABELS,
MAIL_PROVIDERS,
checkMailHealth,
disconnectMailAccount,
saveOAuthApp,
saveSmtpAccount,
setActiveMailProvider,
} from "@/lib/mail-accounts";
import type { MailProvider } from "@/db/schema";
import { sendNow } from "@/lib/mailer";
import { cancelMailMessage, logSentMail, retryMailMessage } from "@/lib/mail-outbox";
import { selfRecipient } from "@/lib/mail-recipients";
import { buildGeneralEmail } from "@/lib/email-templates";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { siteUrl } from "@/lib/social-accounts";
import { markInformationsRead as markRead } from "@/lib/informations"; import { markInformationsRead as markRead } from "@/lib/informations";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth"; import type { AppRole } from "@/types/next-auth";
@@ -1471,3 +1487,144 @@ export async function markInformationsRead(ids: number[]) {
revalidatePath("/backend/espace/promotions"); revalidatePath("/backend/espace/promotions");
revalidatePath("/backend"); revalidatePath("/backend");
} }
// ---- Boîte mail de l'association ----
async function requireMailSettings() {
const access = await requireRole();
if (!can(access.role, "manageSettings")) throw new Error("Accès refusé");
return access;
}
function mailRedirect(message: string, tone: "error" | "ok") {
redirect(`/backend/boite-mail?${tone}=${encodeURIComponent(message)}`);
}
export async function saveMailApp(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") return;
const appId = asString(formData, "appId");
if (!appId) mailRedirect("L'identifiant de l'application est requis.", "error");
// Champ secret laissé vide = on garde celui déjà enregistré.
const appSecret = asString(formData, "appSecret") || null;
try {
await saveOAuthApp(provider, appId, appSecret);
} catch (error) {
mailRedirect(error instanceof Error ? error.message : "Enregistrement impossible.", "error");
}
revalidatePath("/backend/boite-mail");
}
export async function saveMailSmtp(formData: FormData) {
await requireMailSettings();
const host = asString(formData, "smtpHost");
const user = asString(formData, "smtpUser");
const port = Number(formData.get("smtpPort") ?? 465) || 465;
if (!host || !user) mailRedirect("Le serveur et l'identifiant sont requis.", "error");
try {
await saveSmtpAccount({
host,
port: Math.min(65535, Math.max(1, port)),
secure: formData.get("smtpSecure") === "on",
user,
password: asString(formData, "smtpPassword") || null,
fromAddress: asString(formData, "fromAddress") || user,
fromName: asString(formData, "fromName"),
});
} catch (error) {
mailRedirect(error instanceof Error ? error.message : "Enregistrement impossible.", "error");
}
revalidatePath("/backend/boite-mail");
mailRedirect("Réglages SMTP enregistrés.", "ok");
}
export async function setMailProvider(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider)) return;
await setActiveMailProvider(provider);
await logActivity(`Boîte mail : expédition via <strong>${MAIL_LABELS[provider]}</strong>`, "#2C6FB3");
revalidatePath("/backend/boite-mail");
}
export async function disconnectMail(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider)) return;
await disconnectMailAccount(provider);
await logActivity(`Boîte mail déconnectée : <strong>${MAIL_LABELS[provider]}</strong>`, "#d8472b");
revalidatePath("/backend/boite-mail");
}
/**
* Envoi de contrôle vers sa propre adresse. Direct et non mis en file : c'est
* précisément le verdict immédiat qu'on cherche.
*/
export async function sendMailTest() {
const { userId, name } = await requireMailSettings();
const recipient = userId ? await selfRecipient(userId) : null;
if (!recipient) mailRedirect("Votre compte n'a pas d'adresse e-mail utilisable.", "error");
const settings = await getSiteSettings();
const base = await siteUrl();
const { subject, html } = buildGeneralEmail(
{
subject: `Test d'envoi — ${settings.association_name}`,
kicker: settings.association_name,
title: "La boîte mail répond",
body: `Bonjour ${name},\n\nCe message confirme que le site sait expédier depuis la boîte de l'association.\n\nSi vous le recevez, les mots de passe temporaires, les invitations aux rencontres et les informations diffusées partiront de la même façon.`,
buttonLabel: "",
buttonUrl: "",
signature: `L'équipe de ${settings.association_name}`,
},
base,
emailBrand(settings)
);
const result = await sendNow({
to: recipient!.email,
toName: recipient!.name,
subject,
html,
replyTo: settings.association_email || null,
});
await logSentMail({
kind: "test",
toAddress: recipient!.email,
subject,
createdById: userId,
result: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
revalidatePath("/backend/boite-mail");
mailRedirect(
result.ok ? `Message de test envoyé à ${recipient!.email}.` : `Échec de l'envoi : ${result.reason}`,
result.ok ? "ok" : "error"
);
}
/** Contrôle la santé des trois fournisseurs à la demande. */
export async function checkMailProvider(formData: FormData) {
await requireMailSettings();
const provider = String(formData.get("provider")) as MailProvider;
if (!MAIL_PROVIDERS.includes(provider)) return;
await checkMailHealth(provider);
revalidatePath("/backend/boite-mail");
}
export async function retryMail(formData: FormData) {
await requireMailSettings();
const id = Number(formData.get("id") ?? 0);
if (id) await retryMailMessage(id);
revalidatePath("/backend/boite-mail");
}
export async function cancelMail(formData: FormData) {
await requireMailSettings();
const id = Number(formData.get("id") ?? 0);
if (id) await cancelMailMessage(id);
revalidatePath("/backend/boite-mail");
}
+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>
);
}
+2 -9
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getSession } from "@/lib/session"; import { getSession } from "@/lib/session";
import { EmailCreator } from "@/components/EmailCreator"; import { EmailCreator } from "@/components/EmailCreator";
import { can } from "@/lib/rbac"; import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings"; import { emailBrand, getSiteSettings } from "@/lib/site-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -10,13 +10,6 @@ export default async function EmailsPage() {
const session = await getSession(); const session = await getSession();
if (!can(session?.user.role, "manageEmails")) redirect("/backend"); if (!can(session?.user.role, "manageEmails")) redirect("/backend");
const settings = await getSiteSettings(); const settings = await getSiteSettings();
const brand = {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
return <EmailCreator brand={brand} />; return <EmailCreator brand={emailBrand(settings)} />;
} }
+82
View File
@@ -43,6 +43,21 @@ export const contactStatusEnum = pgEnum("contact_status", [
"archived", "archived",
]); ]);
export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]); export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]);
export const mailProviderEnum = pgEnum("mail_provider", ["google", "microsoft", "smtp"]);
export const mailStatusEnum = pgEnum("mail_status", [
"queued",
"sending",
"sent",
"failed",
"cancelled",
]);
export const mailKindEnum = pgEnum("mail_kind", [
"credentials",
"information",
"meeting",
"studio",
"test",
]);
// ---- Categories (métiers) ---- // ---- Categories (métiers) ----
export const categories = pgTable("categories", { export const categories = pgTable("categories", {
@@ -338,6 +353,69 @@ export const informationReads = pgTable(
}) })
); );
// ---- Boîte mail de l'association ----
// Une ligne par fournisseur, `is_active` désigne celui qui expédie. Les secrets
// sont chiffrés (`src/lib/crypto.ts`) et ne ressortent jamais vers le
// navigateur — même règle que `social_accounts`.
export const mailAccounts = pgTable("mail_accounts", {
id: serial("id").primaryKey(),
provider: mailProviderEnum("provider").notNull().unique(),
// Lue chez le fournisseur pour Google et Microsoft : on expédie comme la
// boîte authentifiée, sinon SPF et DKIM tombent.
fromAddress: varchar("from_address", { length: 200 }),
fromName: varchar("from_name", { length: 200 }),
isActive: boolean("is_active").notNull().default(false),
appId: varchar("app_id", { length: 200 }),
appSecret: text("app_secret"),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
smtpHost: varchar("smtp_host", { length: 200 }),
smtpPort: integer("smtp_port"),
smtpSecure: boolean("smtp_secure").notNull().default(true),
smtpUser: varchar("smtp_user", { length: 200 }),
smtpPassword: text("smtp_password"),
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
connectedAt: timestamp("connected_at", { withTimezone: true }),
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
lastCheckOk: boolean("last_check_ok"),
lastCheckError: text("last_check_error"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// File d'attente d'envoi. `to_address` est un destinataire **unique** : une
// diffusion produit autant de lignes que d'adhérents, et aucune adresse ne peut
// donc apparaître aux yeux des autres.
export const mailMessages = pgTable(
"mail_messages",
{
id: serial("id").primaryKey(),
kind: mailKindEnum("kind").notNull(),
status: mailStatusEnum("status").notNull().default("queued"),
toAddress: varchar("to_address", { length: 200 }).notNull(),
toName: varchar("to_name", { length: 200 }),
subject: varchar("subject", { length: 300 }).notNull(),
html: text("html").notNull(),
text: text("text"),
replyTo: varchar("reply_to", { length: 200 }),
informationId: integer("information_id").references(() => informations.id, { onDelete: "set null" }),
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
createdById: integer("created_by_id").references(() => users.id, { onDelete: "set null" }),
provider: mailProviderEnum("provider"),
attempts: integer("attempts").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
lockedAt: timestamp("locked_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
dueIdx: index("mail_messages_due_idx").on(table.status, table.nextAttemptAt),
informationIdx: index("mail_messages_information_idx").on(table.informationId),
})
);
// ---- Relations ---- // ---- Relations ----
export const membersRelations = relations(members, ({ one, many }) => ({ export const membersRelations = relations(members, ({ one, many }) => ({
category: one(categories, { category: one(categories, {
@@ -440,4 +518,8 @@ export type PastMeeting = typeof pastMeetings.$inferSelect;
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect; export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
export type ImageConsent = typeof imageConsents.$inferSelect; export type ImageConsent = typeof imageConsents.$inferSelect;
export type Information = typeof informations.$inferSelect; export type Information = typeof informations.$inferSelect;
export type MailAccount = typeof mailAccounts.$inferSelect;
export type MailProvider = (typeof mailProviderEnum.enumValues)[number];
export type MailMessage = typeof mailMessages.$inferSelect;
export type MailKind = (typeof mailKindEnum.enumValues)[number];
export type InformationRead = typeof informationReads.$inferSelect; export type InformationRead = typeof informationReads.$inferSelect;
+27 -7
View File
@@ -1,13 +1,19 @@
/** /**
* Boucle de libération des publications programmées. Fichier séparé de * Boucles de fond : libération des publications programmées et vidage de la
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans * file d'envoi. Fichier séparé de `instrumentation.ts` : il touche la base et
* le bundle edge du middleware. * `nodemailer`, et ne doit donc jamais entrer dans le bundle edge du
* middleware — c'est cet import-là que l'`IgnorePlugin` de `next.config.mjs`
* coupe.
*/ */
import { releaseDuePromotions } from "@/lib/promo-publish"; import { releaseDuePromotions } from "@/lib/promo-publish";
import { processOutbox } from "@/lib/mail-outbox";
const INTERVAL_MS = 60_000; const PROMO_INTERVAL_MS = 60_000;
// Plus court que la minute des promotions : un mot de passe ou une invitation
// mis en file ne doit pas attendre une minute pleine.
const MAIL_INTERVAL_MS = 20_000;
async function tick() { async function promoTick() {
try { try {
const n = await releaseDuePromotions(); const n = await releaseDuePromotions();
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`); if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
@@ -18,7 +24,21 @@ async function tick() {
} }
} }
async function mailTick() {
try {
const { sent, failed } = await processOutbox();
if (sent || failed) console.log(`[mail] ${sent} envoyé(s), ${failed} en échec`);
} catch (error) {
console.error("[mail] file d'attente :", error);
}
}
// Un premier passage au démarrage rattrape les échéances tombées pendant un // Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure. // redéploiement ou une coupure.
void tick(); void promoTick();
setInterval(tick, INTERVAL_MS).unref(); setInterval(promoTick, PROMO_INTERVAL_MS).unref();
if (process.env.MAIL_WORKER !== "off") {
void mailTick();
setInterval(mailTick, MAIL_INTERVAL_MS).unref();
}
+532
View File
@@ -0,0 +1,532 @@
import { eq, ne } from "drizzle-orm";
import { db } from "@/db";
import { mailAccounts } from "@/db/schema";
import type { MailAccount, MailProvider } from "@/db/schema";
import { decryptSecret, encryptSecret } from "@/lib/crypto";
/**
* Boîte mail de l'association : configuration, connexion, santé.
*
* Décalque volontaire de `src/lib/social-accounts.ts` — mêmes règles, mêmes
* garanties : les secrets sont chiffrés en base, ne repartent jamais vers le
* navigateur, un champ laissé vide conserve celui déjà enregistré, et le
* contrôle de santé ne lève jamais.
*
* Trois transports, un seul actif à la fois. Pas de cascade automatique : si
* Google se bloque, c'est l'administrateur qui bascule, et il le voit.
*/
export const MAIL_PROVIDERS: MailProvider[] = ["google", "microsoft", "smtp"];
export const MAIL_LABELS: Record<MailProvider, string> = {
google: "Google — Gmail / Workspace",
microsoft: "Microsoft — Outlook / Hotmail / 365",
smtp: "Autre serveur (SMTP)",
};
/** Le jeton d'accès est renouvelé un peu avant l'échéance annoncée. */
const REFRESH_MARGIN_MS = 5 * 60_000;
function env(key: string): string {
return (process.env[key] ?? "").trim();
}
// ---- Lecture ----
export async function getMailAccount(provider: MailProvider): Promise<MailAccount | null> {
const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.provider, provider));
return row ?? null;
}
export async function getMailAccounts(): Promise<MailAccount[]> {
return db.select().from(mailAccounts);
}
export async function getActiveMailAccount(): Promise<MailAccount | null> {
const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.isActive, true));
return row ?? null;
}
export type MailSender = {
provider: MailProvider;
fromAddress: string;
fromName: string;
/** Google / Microsoft : jeton valide. SMTP : chaîne vide. */
accessToken: string;
smtp?: { host: string; port: number; secure: boolean; user: string; password: string };
};
/**
* Le transport prêt à expédier, ou `null` si rien n'est configuré.
*
* Aucun repli sur l'environnement pour les jetons OAuth — ils ne peuvent
* venir que du parcours de connexion — mais les réglages SMTP restent lisibles
* depuis l'environnement, ce qui permet un premier déploiement sans passer par
* l'écran.
*/
export async function resolveMailSender(): Promise<MailSender | null> {
const account = await getActiveMailAccount();
if (account?.provider === "smtp") {
const password = safeDecrypt(account.smtpPassword);
if (account.smtpHost && account.smtpUser && password) {
return {
provider: "smtp",
fromAddress: account.fromAddress || account.smtpUser,
fromName: account.fromName ?? "",
accessToken: "",
smtp: {
host: account.smtpHost,
port: account.smtpPort ?? 465,
secure: account.smtpSecure,
user: account.smtpUser,
password,
},
};
}
}
if (account && (account.provider === "google" || account.provider === "microsoft")) {
const accessToken = await ensureAccessToken(account.provider);
if (accessToken && account.fromAddress) {
return {
provider: account.provider,
fromAddress: account.fromAddress,
fromName: account.fromName ?? "",
accessToken,
};
}
}
// Repli d'environnement : utile avant toute configuration depuis l'écran.
const host = env("SMTP_HOST");
const user = env("SMTP_USER");
const password = env("SMTP_PASSWORD");
if (host && user && password) {
return {
provider: "smtp",
fromAddress: env("SMTP_FROM") || user,
fromName: env("SMTP_FROM_NAME"),
accessToken: "",
smtp: { host, port: Number(env("SMTP_PORT") || 465), secure: env("SMTP_SECURE") !== "false", user, password },
};
}
return null;
}
export async function isMailConfigured(): Promise<boolean> {
return (await resolveMailSender()) !== null;
}
function safeDecrypt(value: string | null): string | null {
if (!value) return null;
try {
return decryptSecret(value);
} catch {
// Clé de chiffrement changée : on préfère « non configuré » à une erreur.
return null;
}
}
// ---- Écriture ----
export async function saveOAuthApp(provider: MailProvider, appId: string, appSecret: string | null) {
const existing = await getMailAccount(provider);
if (!existing) {
if (!appSecret) throw new Error("Le secret de l'application est requis à la première saisie.");
await db.insert(mailAccounts).values({ provider, appId, appSecret: encryptSecret(appSecret), updatedAt: new Date() });
return;
}
await db
.update(mailAccounts)
.set({
appId,
// Champ laissé vide = on conserve le secret déjà enregistré.
...(appSecret ? { appSecret: encryptSecret(appSecret) } : {}),
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
}
export type SmtpSettings = {
host: string;
port: number;
secure: boolean;
user: string;
password: string | null;
fromAddress: string;
fromName: string;
};
export async function saveSmtpAccount(settings: SmtpSettings) {
const existing = await getMailAccount("smtp");
const shared = {
smtpHost: settings.host,
smtpPort: settings.port,
smtpSecure: settings.secure,
smtpUser: settings.user,
fromAddress: settings.fromAddress || settings.user,
fromName: settings.fromName,
updatedAt: new Date(),
// Les réglages changent : le dernier verdict ne vaut plus rien.
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
};
if (!existing) {
if (!settings.password) throw new Error("Le mot de passe est requis à la première saisie.");
await db.insert(mailAccounts).values({ provider: "smtp", ...shared, smtpPassword: encryptSecret(settings.password) });
return;
}
await db
.update(mailAccounts)
.set({ ...shared, ...(settings.password ? { smtpPassword: encryptSecret(settings.password) } : {}) })
.where(eq(mailAccounts.provider, "smtp"));
}
export async function saveMailConnection(input: {
provider: MailProvider;
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
fromAddress: string;
fromName?: string | null;
connectedById: number | null;
}) {
const existing = await getMailAccount(input.provider);
const values = {
accessToken: encryptSecret(input.accessToken),
// Microsoft fait tourner le jeton de rafraîchissement à chaque
// renouvellement ; un `null` ici ne doit pas effacer celui en place.
...(input.refreshToken ? { refreshToken: encryptSecret(input.refreshToken) } : {}),
expiresAt: input.expiresAt,
fromAddress: input.fromAddress,
...(input.fromName ? { fromName: input.fromName } : {}),
connectedById: input.connectedById,
connectedAt: new Date(),
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
updatedAt: new Date(),
};
if (!existing) {
await db.insert(mailAccounts).values({ provider: input.provider, ...values });
return;
}
await db.update(mailAccounts).set(values).where(eq(mailAccounts.provider, input.provider));
}
/** Désigne le fournisseur expéditeur ; les autres restent configurés. */
export async function setActiveMailProvider(provider: MailProvider) {
await db.update(mailAccounts).set({ isActive: false }).where(ne(mailAccounts.provider, provider));
await db.update(mailAccounts).set({ isActive: true, updatedAt: new Date() }).where(eq(mailAccounts.provider, provider));
}
/**
* Coupe la connexion sans effacer les identifiants d'application : une
* reconnexion ne redemande pas de recopier l'identifiant et le secret.
*/
export async function disconnectMailAccount(provider: MailProvider) {
await db
.update(mailAccounts)
.set({
accessToken: null,
refreshToken: null,
expiresAt: null,
fromAddress: null,
isActive: false,
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
}
export async function getDecryptedAppSecret(provider: MailProvider): Promise<string | null> {
const account = await getMailAccount(provider);
return safeDecrypt(account?.appSecret ?? null);
}
// ---- Jetons OAuth ----
const TOKEN_ENDPOINT: Record<"google" | "microsoft", string> = {
google: "https://oauth2.googleapis.com/token",
microsoft: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
};
/**
* Jeton d'accès valide, renouvelé si besoin. Appelée par l'expéditeur, jamais
* par le rendu d'une page : un renouvellement est une écriture.
*/
export async function ensureAccessToken(provider: "google" | "microsoft"): Promise<string | null> {
const account = await getMailAccount(provider);
if (!account) return null;
const current = safeDecrypt(account.accessToken);
const stillValid =
current && account.expiresAt && new Date(account.expiresAt).getTime() - Date.now() > REFRESH_MARGIN_MS;
if (stillValid) return current;
const refreshToken = safeDecrypt(account.refreshToken);
const appSecret = safeDecrypt(account.appSecret);
if (!refreshToken || !account.appId || !appSecret) return current;
try {
const res = await fetch(TOKEN_ENDPOINT[provider], {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: account.appId,
client_secret: appSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) return current;
const payload = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!payload.access_token) return current;
await db
.update(mailAccounts)
.set({
accessToken: encryptSecret(payload.access_token),
// Microsoft renvoie un nouveau jeton de rafraîchissement à chaque
// appel et invalide l'ancien : ne pas l'enregistrer condamnerait
// l'envoi au bout d'une heure.
...(payload.refresh_token ? { refreshToken: encryptSecret(payload.refresh_token) } : {}),
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null,
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
return payload.access_token;
} catch {
return current;
}
}
// ---- Santé ----
export type MailHealth = { ok: true; detail: string } | { ok: false; reason: string };
async function rememberCheck(provider: MailProvider, health: MailHealth) {
await db
.update(mailAccounts)
.set({
lastCheckAt: new Date(),
lastCheckOk: health.ok,
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
})
.where(eq(mailAccounts.provider, provider));
}
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
/**
* Interroge le fournisseur. Ne lève jamais : un échec est un verdict, pas une
* exception — l'écran doit pouvoir l'afficher.
*/
export async function checkMailHealth(provider: MailProvider): Promise<MailHealth | null> {
const account = await getMailAccount(provider);
if (!account) return null;
try {
if (provider === "smtp") {
const password = safeDecrypt(account.smtpPassword);
if (!account.smtpHost || !account.smtpUser || !password) return null;
const { verifySmtp } = await import("@/lib/mailer");
const health = await verifySmtp({
host: account.smtpHost,
port: account.smtpPort ?? 465,
secure: account.smtpSecure,
user: account.smtpUser,
password,
});
await rememberCheck(provider, health);
return health;
}
const token = await ensureAccessToken(provider);
if (!token) return null;
const url =
provider === "google"
? "https://gmail.googleapis.com/gmail/v1/users/me/profile"
: "https://graph.microsoft.com/v1.0/me";
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const health: MailHealth = res.ok
? { ok: true, detail: `Boîte accessible (${account.fromAddress ?? "compte connecté"})` }
: { ok: false, reason: await readError(res) };
await rememberCheck(provider, health);
return health;
} catch (error) {
const health: MailHealth = {
ok: false,
reason: error instanceof Error ? error.message : "Fournisseur injoignable",
};
await rememberCheck(provider, health);
return health;
}
}
/** Verdict mis en cache : évite un appel réseau à chaque affichage. */
export async function mailHealthCached(
provider: MailProvider,
maxAgeMs = 6 * 3_600_000
): Promise<MailHealth | null> {
const account = await getMailAccount(provider);
if (!account) return null;
const fresh = account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
if (fresh && account.lastCheckOk !== null) {
return account.lastCheckOk
? { ok: true, detail: "Vérifiée récemment" }
: { ok: false, reason: account.lastCheckError ?? "Boîte refusée" };
}
return checkMailHealth(provider);
}
// ---- Parcours OAuth ----
export class MailAuthError extends Error {}
/**
* Portées demandées.
*
* Google : `gmail.send` est classée *sensible*. `https://mail.google.com/`,
* qu'exigerait SMTP avec XOAUTH2, est *restreinte* et déclenche un audit de
* sécurité — d'où le choix de l'API Gmail.
*
* Microsoft : délégué, sur l'autorité `/common`, pour accepter aussi bien une
* boîte professionnelle qu'un compte outlook.com ou hotmail.com personnel.
*/
const SCOPES: Record<"google" | "microsoft", string> = {
google: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.email",
microsoft: "offline_access Mail.Send User.Read",
};
export async function mailRedirectUri(provider: MailProvider, base: string): Promise<string> {
return `${base.replace(/\/+$/, "")}/api/mail/${provider}/callback`;
}
export async function mailAuthorizeUrl(
provider: "google" | "microsoft",
appId: string,
state: string,
base: string
): Promise<string> {
const redirectUri = await mailRedirectUri(provider, base);
if (provider === "google") {
return `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri,
response_type: "code",
scope: SCOPES.google,
// Sans `offline` et `consent`, Google ne délivre pas de jeton de
// rafraîchissement au deuxième passage : l'envoi mourrait au bout d'une
// heure sans raison visible.
access_type: "offline",
prompt: "consent",
include_granted_scopes: "true",
state,
})}`;
}
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri,
response_type: "code",
response_mode: "query",
scope: SCOPES.microsoft,
state,
})}`;
}
export type MailExchange = {
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
fromAddress: string;
fromName: string | null;
};
/**
* Échange le code contre des jetons, puis **lit l'adresse chez le
* fournisseur** : Gmail expédie comme l'utilisateur authentifié et Graph comme
* la boîte. Laisser l'administrateur saisir une autre adresse ferait tomber
* SPF et DKIM, et tous les messages partiraient en indésirable.
*/
export async function exchangeMailCode(
provider: "google" | "microsoft",
appId: string,
appSecret: string,
code: string,
base: string
): Promise<MailExchange> {
const res = await fetch(TOKEN_ENDPOINT[provider], {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: appId,
client_secret: appSecret,
code,
redirect_uri: await mailRedirectUri(provider, base),
grant_type: "authorization_code",
}),
});
if (!res.ok) throw new MailAuthError(await readError(res));
const payload = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!payload.access_token) throw new MailAuthError("Aucun jeton d'accès renvoyé.");
const identity = await readIdentity(provider, payload.access_token);
if (!identity.address) {
throw new MailAuthError("Impossible de lire l'adresse de la boîte connectée.");
}
return {
accessToken: payload.access_token,
refreshToken: payload.refresh_token ?? null,
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null,
fromAddress: identity.address,
fromName: identity.name,
};
}
async function readIdentity(
provider: "google" | "microsoft",
accessToken: string
): Promise<{ address: string; name: string | null }> {
if (provider === "google") {
const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new MailAuthError(await readError(res));
const profile = (await res.json()) as { emailAddress?: string };
return { address: profile.emailAddress ?? "", name: null };
}
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new MailAuthError(await readError(res));
const profile = (await res.json()) as { mail?: string; userPrincipalName?: string; displayName?: string };
return { address: profile.mail ?? profile.userPrincipalName ?? "", name: profile.displayName ?? null };
}
+207
View File
@@ -0,0 +1,207 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { mailMessages } from "@/db/schema";
import type { MailKind, MailMessage } from "@/db/schema";
import { sendNow } from "@/lib/mailer";
/**
* File d'attente d'envoi.
*
* Une diffusion à quatre-vingts adhérents ne peut pas partir dans la requête
* qui l'a déclenchée : la page attendrait quatre-vingts allers-retours. Les
* messages sont donc écrits en base et vidés par la boucle de fond, sur le
* modèle de `releaseDuePromotions`.
*
* Une ligne par destinataire, jamais de copie partagée : `to_address` est un
* `varchar` unique, la confidentialité est structurelle.
*
* **Les mots de passe temporaires ne passent pas par ici.** Le clair n'existe
* que dans la portée de l'action qui les émet, et `mail_messages.html` est
* stocké en base : ils partent en ligne directe (`sendNow`), et seule une
* trace sans contenu est journalisée.
*/
/** Débit par passage. Gmail bride autour de quelques centaines par jour. */
const DEFAULT_BATCH = Number(process.env.MAIL_RATE_PER_MINUTE ?? 20);
/** Au-delà, le message est abandonné : cinq échecs ne sont pas un incident réseau. */
export const MAX_ATTEMPTS = 5;
/** Un verrou plus vieux que cela vient d'un conteneur mort en plein envoi. */
const STALE_LOCK_MS = 10 * 60_000;
export type QueuedMail = {
kind: MailKind;
toAddress: string;
toName?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
informationId?: number | null;
meetingId?: number | null;
memberId?: number | null;
createdById?: number | null;
};
/** Attente avant la prochaine tentative : 2, 4, 8, 16 minutes. */
export function backoffDelayMs(attempts: number): number {
const capped = Math.min(Math.max(attempts, 1), MAX_ATTEMPTS);
return 2 ** capped * 60_000;
}
export function shouldGiveUp(attempts: number): boolean {
return attempts >= MAX_ATTEMPTS;
}
export async function queueMails(mails: QueuedMail[]): Promise<number> {
if (mails.length === 0) return 0;
const rows = await db.insert(mailMessages).values(mails).returning({ id: mailMessages.id });
return rows.length;
}
export async function queueMail(mail: QueuedMail): Promise<number> {
return queueMails([mail]);
}
/**
* Journalise un envoi déjà parti, sans son contenu.
*
* Sert aux mots de passe temporaires : le journal doit montrer qu'un message
* est parti, à qui et quand, sans que le corps — donc le mot de passe — ne
* touche la base.
*/
export async function logSentMail(entry: {
kind: MailKind;
toAddress: string;
subject: string;
memberId?: number | null;
createdById?: number | null;
result: { ok: true } | { ok: false; reason: string };
}) {
await db.insert(mailMessages).values({
kind: entry.kind,
toAddress: entry.toAddress,
subject: entry.subject,
html: "",
memberId: entry.memberId ?? null,
createdById: entry.createdById ?? null,
status: entry.result.ok ? "sent" : "failed",
attempts: 1,
sentAt: entry.result.ok ? new Date() : null,
error: entry.result.ok ? null : entry.result.reason.slice(0, 2000),
});
}
/**
* Réclame un lot de messages à envoyer.
*
* `FOR UPDATE SKIP LOCKED` : deux instances peuvent vider la file en parallèle
* sans jamais expédier deux fois le même message. `releaseDuePromotions` s'en
* passe parce que la transition de statut y fait office de verrou ; ici le
* passage en `sending` doit être exclusif avant l'appel réseau.
*/
async function claimDueMails(limit: number): Promise<MailMessage[]> {
const stale = new Date(Date.now() - STALE_LOCK_MS);
// Un conteneur arrêté en plein envoi laisse des lignes en `sending` :
// on les remet en file avant de servir le lot suivant.
await db
.update(mailMessages)
.set({ status: "queued", lockedAt: null })
.where(and(eq(mailMessages.status, "sending"), sql`${mailMessages.lockedAt} < ${stale}`));
const { rows } = await db.execute<MailMessage>(sql`
update ${mailMessages}
set status = 'sending', locked_at = now()
where id in (
select id from ${mailMessages}
where status = 'queued' and next_attempt_at <= now()
order by id
limit ${limit}
for update skip locked
)
returning *
`);
return rows;
}
export type OutboxReport = { sent: number; failed: number };
/** Vide un lot de la file. Ne lève jamais : c'est une boucle de fond. */
export async function processOutbox(limit = DEFAULT_BATCH): Promise<OutboxReport> {
const report: OutboxReport = { sent: 0, failed: 0 };
let claimed: MailMessage[] = [];
try {
claimed = await claimDueMails(limit);
} catch (error) {
console.error("[mail] réclamation :", error);
return report;
}
for (const message of claimed) {
const result = await sendNow({
to: message.toAddress,
toName: message.toName,
subject: message.subject,
html: message.html,
text: message.text,
replyTo: message.replyTo,
});
if (result.ok) {
report.sent += 1;
await db
.update(mailMessages)
.set({ status: "sent", sentAt: new Date(), provider: result.provider, lockedAt: null, error: null })
.where(eq(mailMessages.id, message.id));
continue;
}
report.failed += 1;
const attempts = message.attempts + 1;
const exhausted = shouldGiveUp(attempts);
await db
.update(mailMessages)
.set({
status: exhausted ? "failed" : "queued",
attempts,
lockedAt: null,
nextAttemptAt: new Date(Date.now() + backoffDelayMs(attempts)),
error: result.reason.slice(0, 2000),
})
.where(eq(mailMessages.id, message.id));
}
return report;
}
// ---- Journal ----
export async function recentMails(limit = 40) {
return db.select().from(mailMessages).orderBy(desc(mailMessages.id)).limit(limit);
}
export async function mailCountsFor(informationId: number) {
const rows = await db
.select({ status: mailMessages.status, total: sql<number>`count(*)::int` })
.from(mailMessages)
.where(eq(mailMessages.informationId, informationId))
.groupBy(mailMessages.status);
return rows.reduce<Record<string, number>>((acc, row) => ({ ...acc, [row.status]: row.total }), {});
}
/** Remet un message échoué en file, immédiatement. */
export async function retryMailMessage(id: number) {
await db
.update(mailMessages)
.set({ status: "queued", attempts: 0, nextAttemptAt: new Date(), lockedAt: null, error: null })
.where(and(eq(mailMessages.id, id), eq(mailMessages.status, "failed")));
}
/** Annule un message encore en attente ; un message parti ne s'annule pas. */
export async function cancelMailMessage(id: number) {
await db
.update(mailMessages)
.set({ status: "cancelled", lockedAt: null })
.where(and(eq(mailMessages.id, id), eq(mailMessages.status, "queued")));
}
+122
View File
@@ -0,0 +1,122 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { categories, meetingRegistrations, members, users } from "@/db/schema";
/**
* Qui reçoit quoi.
*
* L'adresse retenue est **`members.email`**, l'adresse administrative qui sert
* aussi d'identifiant de connexion — jamais `members.contact_email`, qui est
* l'adresse publique de la fiche et peut pointer vers un standard.
*
* La partie pure (`dedupeRecipients`) est exportée pour être testée sans base :
* c'est elle qui garantit qu'un adhérent ne reçoit pas deux fois le même
* message, et que N destinataires donnent bien N envois.
*/
export type Recipient = { email: string; name: string; memberId: number | null };
export type AudienceKind = "all" | "category" | "members" | "meeting";
export type Audience =
| { kind: "all" }
| { kind: "category"; categoryId: number }
| { kind: "members"; memberIds: number[] }
| { kind: "meeting"; meetingId: number };
// Volontairement permissif : le rôle de ce contrôle est d'écarter les champs
// vides ou manifestement cassés, pas de réinventer la RFC 5322.
const PLAUSIBLE_EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]{2,}$/;
export function isPlausibleEmail(value: string): boolean {
return PLAUSIBLE_EMAIL.test(String(value ?? "").trim());
}
/**
* Écarte les adresses vides ou invalides et les doublons, insensiblement à la
* casse. L'ordre d'origine est conservé : la première occurrence gagne, avec
* son nom.
*/
export function dedupeRecipients(rows: { email: string | null; name?: string | null; memberId?: number | null }[]): Recipient[] {
const seen = new Set<string>();
const out: Recipient[] = [];
for (const row of rows) {
const email = String(row.email ?? "").trim();
if (!isPlausibleEmail(email)) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push({ email, name: String(row.name ?? "").trim(), memberId: row.memberId ?? null });
}
return out;
}
export async function resolveAudience(audience: Audience): Promise<Recipient[]> {
if (audience.kind === "meeting") {
const rows = await db
.select({ email: meetingRegistrations.attendeeEmail, name: meetingRegistrations.attendeeName, memberId: meetingRegistrations.memberId })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.meetingId, audience.meetingId))
.orderBy(asc(meetingRegistrations.id));
return dedupeRecipients(rows);
}
const base = db
.select({ email: members.email, name: members.name, memberId: members.id })
.from(members)
.$dynamic();
if (audience.kind === "category") {
return dedupeRecipients(
await base
.where(and(eq(members.status, "active"), eq(members.categoryId, audience.categoryId)))
.orderBy(asc(members.name)),
);
}
if (audience.kind === "members") {
if (audience.memberIds.length === 0) return [];
return dedupeRecipients(await base.where(inArray(members.id, audience.memberIds)).orderBy(asc(members.name)));
}
return dedupeRecipients(await base.where(eq(members.status, "active")).orderBy(asc(members.name)));
}
/** Les adhérents actifs, pour la diffusion d'une information. */
export async function activeMemberRecipients(): Promise<Recipient[]> {
return resolveAudience({ kind: "all" });
}
export type AudienceOption = { value: string; label: string; count: number };
/** Options proposées par le sélecteur de destinataires. */
export async function audienceOptions(): Promise<{ all: number; categories: AudienceOption[] }> {
const all = (await activeMemberRecipients()).length;
const rows = await db
.select({ id: categories.id, label: categories.label, email: members.email, memberId: members.id })
.from(categories)
.innerJoin(members, and(eq(members.categoryId, categories.id), eq(members.status, "active")))
.orderBy(asc(categories.sort), asc(categories.label));
const grouped = new Map<number, { label: string; rows: { email: string | null; memberId: number }[] }>();
for (const row of rows) {
const entry = grouped.get(row.id) ?? { label: row.label, rows: [] };
entry.rows.push({ email: row.email, memberId: row.memberId });
grouped.set(row.id, entry);
}
return {
all,
categories: [...grouped.entries()].map(([id, entry]) => ({
value: String(id),
label: entry.label,
count: dedupeRecipients(entry.rows).length,
})),
};
}
/** L'adresse de l'utilisateur courant, pour « m'envoyer un test ». */
export async function selfRecipient(userId: number): Promise<Recipient | null> {
const [row] = await db.select({ email: users.email, name: users.name }).from(users).where(eq(users.id, userId));
return dedupeRecipients(row ? [{ ...row, memberId: null }] : [])[0] ?? null;
}
+141
View File
@@ -0,0 +1,141 @@
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
/**
* Expédition d'un message.
*
* Trois transports, deux familles :
*
* - **Google** passe par l'API Gmail (`users.messages.send`) et non par SMTP
* avec XOAUTH2 : celui-ci exigerait la portée `https://mail.google.com/`,
* classée « restreinte », donc un audit de sécurité. `gmail.send` est une
* portée simplement « sensible ».
* - **Microsoft** passe par Graph (`/me/sendMail`) : l'authentification
* basique SMTP est désactivée par défaut depuis 2024, y compris sur les
* boîtes outlook.com et hotmail.com.
* - **SMTP** couvre tout le reste (mot de passe d'application Gmail, Outlook
* professionnel, OVH, Ionos…) via `nodemailer`.
*
* `sendNow` ne lève jamais : elle renvoie un verdict, que la file d'attente ou
* l'action appelante affiche. Un envoi raté ne doit pas faire tomber la page
* qui l'a déclenché.
*/
export type OutgoingMail = {
to: string;
toName?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
};
export type SendResult =
| { ok: true; provider: MailSender["provider"] }
| { ok: false; reason: string };
export type SmtpConfig = { host: string; port: number; secure: boolean; user: string; password: string };
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
export async function sendNow(mail: OutgoingMail): Promise<SendResult> {
const sender = await resolveMailSender();
if (!sender) {
return { ok: false, reason: "Aucune boîte mail n'est configurée (Backend › Boîte mail)." };
}
try {
if (sender.provider === "google") return await sendViaGmail(sender, mail);
if (sender.provider === "microsoft") return await sendViaGraph(sender, mail);
return await sendViaSmtp(sender, mail);
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "Envoi impossible" };
}
}
async function sendViaGmail(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
// Le champ `raw` de l'API Gmail attend le message RFC 822 en base64url.
const raw = toBase64Url(
utf8ToBase64(
buildMimeMessage({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
}),
),
);
const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", {
method: "POST",
headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ raw }),
});
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, provider: "google" };
}
async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
// Graph prend du JSON : aucun MIME à construire de ce côté.
const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", {
method: "POST",
headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
message: {
subject: mail.subject,
body: { contentType: "HTML", content: mail.html },
toRecipients: [{ emailAddress: { address: mail.to, name: mail.toName ?? undefined } }],
...(mail.replyTo ? { replyTo: [{ emailAddress: { address: mail.replyTo } }] } : {}),
},
saveToSentItems: true,
}),
});
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, provider: "microsoft" };
}
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
const transport = await smtpTransport(sender.smtp);
await transport.sendMail({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
});
return { ok: true, provider: "smtp" };
}
/**
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
* jamais entrer dans un bundle qui ne les a pas.
*/
async function smtpTransport(config: SmtpConfig) {
const nodemailer = (await import("nodemailer")).default;
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: { user: config.user, pass: config.password },
});
}
/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */
export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> {
try {
const transport = await smtpTransport(config);
await transport.verify();
transport.close();
return { ok: true, detail: `Connexion établie avec ${config.host}` };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "Serveur SMTP injoignable" };
}
}
+16
View File
@@ -1,3 +1,4 @@
import type { EmailBrand } from "@/lib/email-templates";
import { asc } from "drizzle-orm"; import { asc } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { siteSettings } from "@/db/schema"; import { siteSettings } from "@/db/schema";
@@ -122,3 +123,18 @@ export function parseBoardMembers(value: string) {
return { name, role: role || "Membre du directoire" }; return { name, role: role || "Membre du directoire" };
}); });
} }
/**
* Identité de l'association telle qu'elle apparaît en pied de tous les
* e-mails. Elle sert au studio de composition comme aux envois automatiques :
* un seul endroit à changer.
*/
export function emailBrand(settings: SiteSettings): EmailBrand {
return {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
}