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

+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 { 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,
};
}