mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-17 20:27:35 +02:00
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:
19 files changed
+3997
-16
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EmailBrand } from "@/lib/email-templates";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { siteSettings } from "@/db/schema";
|
||||
@@ -122,3 +123,18 @@ export function parseBoardMembers(value: string) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user