Diffuser une information par e-mail, à la publication

La case « Envoyer aussi par e-mail » du bouton Publier met une ligne par
adhérent dans la file : jamais de copie partagée, aucune adresse visible
des autres. `informations.email_sent_at` fait office de garde
anti-double-diffusion, comme `publishPromoShares` pour les réseaux — une
information dépubliée puis republiée ne repart pas.

La diffusion est réservée à `manageEmails` : un modérateur publie, il
n'écrit pas à tous les adhérents. Sans adresse publique enregistrée, rien
ne part et le bandeau le dit, plutôt que d'expédier un message dont le
logo et tous les liens seraient cassés.

L'image de couverture passe par `/api/informations/<id>/image` et non en
data-URI : Gmail et Outlook suppriment les `<img src="data:">`, et
l'incorporer gonflerait chaque ligne de la file à plusieurs méga-octets.
La route ne sert que les informations publiées.

Deux défauts corrigés en chemin, trouvés en jouant le parcours complet :

- `db.execute` rend les colonnes brutes, sans les rebaptiser comme le fait
  le constructeur de requêtes : `to_address` n'arrivait pas dans
  `toAddress`, et nodemailer refusait tous les messages faute de
  destinataire.
- `PROMO_SCHEDULER=off` empêchait le chargement du module de fond, donc
  coupait aussi l'envoi des e-mails. Chaque boucle a maintenant son propre
  interrupteur.

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:15:11 +00:00
1 parent a7ac27e530
commit 877b0ece78
7 files changed
+265 -21

No files matched your search

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getInformation } from "@/lib/informations";
export const dynamic = "force-dynamic";
/**
* Sert l'image de couverture d'une information **publiée**, en octets.
*
* Elle est stockée en data-URI, ce qui convient au site mais pas aux e-mails :
* Gmail et Outlook suppriment les `<img src="data:">`. Cette route donne au
* gabarit une URL ordinaire, et évite au passage de recopier plusieurs
* méga-octets dans chaque ligne de la file d'attente.
*
* Seules les informations publiées répondent : un brouillon ne doit pas fuiter
* par une URL devinable.
*/
const DATA_URI = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: raw } = await params;
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return new NextResponse("Introuvable", { status: 404 });
const information = await getInformation(id);
if (!information || information.status !== "published" || !information.imageUrl) {
return new NextResponse("Introuvable", { status: 404 });
}
const match = DATA_URI.exec(information.imageUrl);
if (!match) return new NextResponse("Introuvable", { status: 404 });
const bytes = Buffer.from(match[2].replace(/\s+/g, ""), "base64");
return new NextResponse(bytes, {
headers: {
"Content-Type": match[1],
"Content-Length": String(bytes.length),
// L'image d'une information ne change pas : les clients mail peuvent la
// garder, et un message relu six mois plus tard l'affiche encore.
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
},
});
}
+79 -5
View File
@@ -54,9 +54,10 @@ import {
} 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 { buildCredentialsEmail, buildGeneralEmail } from "@/lib/email-templates";
import { cancelMailMessage, logSentMail, queueMails, retryMailMessage } from "@/lib/mail-outbox";
import { activeMemberRecipients, selfRecipient } from "@/lib/mail-recipients";
import { buildCredentialsEmail, buildGeneralEmail, buildInformationEmail } from "@/lib/email-templates";
import { richTextToPlain } from "@/lib/rich-text";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { siteUrl } from "@/lib/social-accounts";
import { markInformationsRead as markRead } from "@/lib/informations";
@@ -1477,8 +1478,18 @@ export async function saveInformation(
return { id: created.id };
}
/**
* Les refus de diffusion passent par une redirection avec message plutôt que
* par un retour : le bouton « Publier » est un `<form action>` de composant
* serveur, qui ne peut rien renvoyer au navigateur. Même idiome que
* `saveSitePublicUrl`.
*/
function informationRedirect(message: string, tone: "error" | "ok"): never {
redirect(`/backend/informations?${tone}=${encodeURIComponent(message)}`);
}
export async function publishInformation(formData: FormData) {
await requireInformations();
const { role, userId } = await requireInformations();
const id = Number(formData.get("id") ?? 0);
if (!id) return;
@@ -1486,11 +1497,74 @@ export async function publishInformation(formData: FormData) {
.update(informations)
.set({ status: "published", publishedAt: new Date(), updatedAt: new Date() })
.where(and(eq(informations.id, id), eq(informations.status, "draft")))
.returning({ id: informations.id, title: informations.title });
.returning({
id: informations.id,
title: informations.title,
body: informations.body,
imageUrl: informations.imageUrl,
emailSentAt: informations.emailSentAt,
});
if (!published) return;
await logActivity(`Information publiée : <strong>${published.title}</strong>`, "#1F8A5B");
revalidateInformationPaths();
// La diffusion est un choix, pas une conséquence de la publication. Elle est
// en outre réservée à qui peut déjà écrire aux adhérents : un modérateur
// publie, il ne diffuse pas.
if (formData.get("sendEmail") !== "on" || !can(role, "manageEmails")) return;
// Garde anti-double-diffusion, sur le modèle de `publishPromoShares` : une
// information dépubliée puis republiée ne repart pas une seconde fois.
if (published.emailSentAt) return;
const base = await siteUrl();
if (!base) {
// Sans adresse publique, le logo et tous les liens du message seraient
// cassés : mieux vaut ne rien envoyer et le dire.
informationRedirect(
"Information publiée, mais non diffusée : l'adresse publique du site n'est pas renseignée (Backend › Réseaux sociaux).",
"error"
);
}
const settings = await getSiteSettings();
const { subject, html } = buildInformationEmail(
{ id: published.id, title: published.title, body: published.body, hasImage: Boolean(published.imageUrl) },
base,
emailBrand(settings)
);
const text = `${published.title}\n\n${richTextToPlain(published.body)}\n\n${base}/backend/espace/informations`;
const recipients = await activeMemberRecipients();
if (recipients.length === 0) {
informationRedirect("Information publiée, mais aucun adhérent actif n'a d'adresse e-mail utilisable.", "error");
}
// Une ligne par destinataire : aucune adresse n'est visible des autres.
await queueMails(
recipients.map((recipient) => ({
kind: "information" as const,
toAddress: recipient.email,
toName: recipient.name,
subject,
html,
text,
replyTo: settings.association_email || null,
informationId: published.id,
memberId: recipient.memberId,
createdById: userId,
}))
);
await db.update(informations).set({ emailSentAt: new Date() }).where(eq(informations.id, published.id));
await logActivity(
`Information <strong>${published.title}</strong> diffusée à ${recipients.length} adhérent(s)`,
"#2C6FB3"
);
revalidateInformationPaths();
informationRedirect(
`Information publiée et mise en file pour ${recipients.length} adhérent(s).`,
"ok"
);
}
export async function unpublishInformation(formData: FormData) {
+51 -5
View File
@@ -5,6 +5,8 @@ import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { countMemberAccounts, getAdminInformations, getInformation } from "@/lib/informations";
import { richTextExcerpt } from "@/lib/rich-text";
import { isMailConfigured } from "@/lib/mail-accounts";
import { mailCountsFor } from "@/lib/mail-outbox";
import { InformationForm } from "./InformationForm";
import {
deleteInformation,
@@ -34,21 +36,44 @@ function formatDay(value: Date) {
export default async function InformationsPage({
searchParams,
}: {
searchParams: Promise<{ modifier?: string }>;
searchParams: Promise<{ modifier?: string; error?: string; ok?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageInformations")) redirect("/backend");
// Un modérateur publie ; écrire à tous les adhérents reste réservé à qui
// tient déjà le studio d'e-mails.
const canBroadcast = can(session?.user.role, "manageEmails");
const mailReady = canBroadcast && (await isMailConfigured());
const { modifier } = await searchParams;
const { modifier, error, ok } = await searchParams;
const editId = Number(modifier ?? 0) || null;
const editing = editId ? await getInformation(editId) : null;
const [items, memberCount] = await Promise.all([getAdminInformations(), countMemberAccounts()]);
const published = items.filter((item) => item.status === "published");
const drafts = items.filter((item) => item.status === "draft");
const mailCounts = Object.fromEntries(
await Promise.all(
published
.filter((item) => item.emailSentAt)
.map(async (item) => [item.id, await mailCountsFor(item.id)] as const)
)
);
return (
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(0, 1fr)", gap: 18, alignItems: "start" }} className="informations-split">
<div>
{error && (
<div role="status" style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{error}
</div>
)}
{ok && (
<div role="status" style={{ background: "#e6f4ec", border: "1px solid #c4e2d1", color: "#1f8a5b", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{ok}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(0, 1fr)", gap: 18, alignItems: "start" }} className="informations-split">
<InformationForm
key={editing?.id ?? "nouvelle"}
draft={{
@@ -82,6 +107,13 @@ export default async function InformationsPage({
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
{item.emailSentAt && (
<div style={{ color: "#1f8a5b", fontSize: 12, marginTop: 5, fontWeight: 700 }}>
Diffusée par e-mail · {mailCounts[item.id]?.sent ?? 0} remis
{mailCounts[item.id]?.queued ? ` · ${mailCounts[item.id].queued} en file` : ""}
{mailCounts[item.id]?.failed ? ` · ${mailCounts[item.id].failed} en échec` : ""}
</div>
)}
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
@@ -116,11 +148,24 @@ export default async function InformationsPage({
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<form action={publishInformation}>
<form action={publishInformation} style={{ display: "grid", gap: 7 }}>
<input type="hidden" name="id" value={item.id} />
{canBroadcast && (
<label style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 12.5, color: "#6c6150", cursor: mailReady ? "pointer" : "not-allowed" }}>
<input type="checkbox" name="sendEmail" disabled={!mailReady} style={{ marginTop: 2 }} />
<span>
Envoyer aussi par e-mail <strong>aux {memberCount} adhérent(s)</strong>
{!mailReady && (
<span style={{ display: "block", color: "#a99c82", fontSize: 11.5 }}>
Aucune boîte mail configurée — voir Configuration › Boîte mail.
</span>
)}
</span>
</label>
)}
<button
type="submit"
style={{ ...rowButton, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 }}
style={{ ...rowButton, border: "none", background: "#13324F", color: "#fff", fontWeight: 800, justifySelf: "start" }}
>
Publier
</button>
@@ -139,6 +184,7 @@ export default async function InformationsPage({
))}
</div>
</section>
</div>
</div>
</div>
);
+4 -2
View File
@@ -35,8 +35,10 @@ async function mailTick() {
// Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure.
void promoTick();
setInterval(promoTick, PROMO_INTERVAL_MS).unref();
if (process.env.PROMO_SCHEDULER !== "off") {
void promoTick();
setInterval(promoTick, PROMO_INTERVAL_MS).unref();
}
if (process.env.MAIL_WORKER !== "off") {
void mailTick();
+5 -2
View File
@@ -1,5 +1,5 @@
/**
* Démarrage du serveur : libérateur des publications programmées.
* Démarrage du serveur : boucles de fond.
*
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
@@ -16,6 +16,9 @@ export async function register() {
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
// pas entrer dans ce bundle.
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.PROMO_SCHEDULER === "off") return;
// Chaque boucle a son propre interrupteur, appliqué à l'intérieur du module :
// couper le libérateur de promotions ici couperait aussi l'envoi des
// e-mails, qui n'a rien à voir avec lui.
if (process.env.PROMO_SCHEDULER === "off" && process.env.MAIL_WORKER === "off") return;
await import("./instrumentation-node");
}
+42 -1
View File
@@ -1,4 +1,4 @@
import { safeHttpUrl } from "@/lib/rich-text";
import { richTextToEmailHtml, safeHttpUrl } from "@/lib/rich-text";
export type EmailBrand = {
associationName: string;
@@ -205,3 +205,44 @@ export function buildCredentialsEmail(data: CredentialsEmailData, baseUrl: strin
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si le bouton ne fonctionne pas, copiez ce lien :<br><a href="${esc(loginUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(loginUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}
export type InformationEmailData = {
id: number;
title: string;
/** Texte balisé, analysé par `src/lib/rich-text.ts`. */
body: string;
hasImage: boolean;
};
/**
* Une information de l'association, mise en e-mail.
*
* Un constructeur à part plutôt qu'un élargissement de `GeneralEmailContent` :
* le contrat du studio de composition reste intact.
*
* L'image de couverture part par **URL** (`/api/informations/<id>/image`) et
* non en data-URI : Gmail et Outlook suppriment les `<img src="data:">`, et
* l'incorporer gonflerait chaque ligne de la file d'attente à plusieurs méga-octets.
*/
export function buildInformationEmail(data: InformationEmailData, baseUrl: string, brand: EmailBrand) {
const base = baseUrl.replace(/\/$/, "");
const spaceUrl = `${base}/backend/espace/informations`;
const subject = `${brand.associationName} — ${data.title}`;
const body = `
<tr><td style="padding:42px 40px 8px;">
<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">Information &middot; ${esc(brand.associationName)}</div>
<div style="padding-bottom:20px;font-family:Georgia,'Times New Roman',serif;font-size:29px;line-height:36px;color:#26201A;">${esc(data.title)}</div>
</td></tr>
${
data.hasImage
? `<tr><td style="padding:0 40px 18px;"><img src="${esc(`${base}/api/informations/${data.id}/image`)}" alt="" width="540" style="display:block;width:100%;max-width:540px;height:auto;border:1px solid #E6DCC6;"></td></tr>`
: ""
}
<tr><td style="padding:0 40px 10px;">${richTextToEmailHtml(data.body)}</td></tr>
<tr><td align="center" style="padding:18px 40px 10px;">${cta(spaceUrl, "Lire dans mon espace")}</td></tr>
<tr><td style="padding:14px 40px 34px;font-family:Arial,Helvetica,sans-serif;font-size:11.5px;line-height:18px;color:#8C8068;">
Vous recevez ce message en tant qu'adhérent de ${esc(brand.associationName)}.<br>
Si le bouton ne fonctionne pas, copiez ce lien : <a href="${esc(spaceUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(spaceUrl)}</a>
</td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}
+42 -6
View File
@@ -1,7 +1,7 @@
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 type { MailKind } from "@/db/schema";
import { sendNow } from "@/lib/mailer";
/**
@@ -93,6 +93,29 @@ export async function logSentMail(entry: {
});
}
type ClaimedMail = {
id: number;
toAddress: string;
toName: string | null;
subject: string;
html: string;
text: string | null;
replyTo: string | null;
attempts: number;
};
/** Ligne telle que la renvoie le pilote : noms de colonnes bruts. */
type ClaimedRow = {
id: number;
to_address: string;
to_name: string | null;
subject: string;
html: string;
text: string | null;
reply_to: string | null;
attempts: number;
};
/**
* Réclame un lot de messages à envoyer.
*
@@ -101,7 +124,7 @@ export async function logSentMail(entry: {
* 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[]> {
async function claimDueMails(limit: number): Promise<ClaimedMail[]> {
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.
@@ -110,7 +133,7 @@ async function claimDueMails(limit: number): Promise<MailMessage[]> {
.set({ status: "queued", lockedAt: null })
.where(and(eq(mailMessages.status, "sending"), sql`${mailMessages.lockedAt} < ${stale}`));
const { rows } = await db.execute<MailMessage>(sql`
const { rows } = await db.execute<ClaimedRow>(sql`
update ${mailMessages}
set status = 'sending', locked_at = now()
where id in (
@@ -120,9 +143,22 @@ async function claimDueMails(limit: number): Promise<MailMessage[]> {
limit ${limit}
for update skip locked
)
returning *
returning id, to_address, to_name, subject, html, text, reply_to, attempts
`);
return rows;
// `db.execute` rend les colonnes telles quelles : contrairement au
// constructeur de requêtes, il ne rebaptise pas `to_address` en `toAddress`.
// Sans cette conversion le destinataire arrivait vide chez nodemailer.
return rows.map((row) => ({
id: row.id,
toAddress: row.to_address,
toName: row.to_name,
subject: row.subject,
html: row.html,
text: row.text,
replyTo: row.reply_to,
attempts: Number(row.attempts),
}));
}
export type OutboxReport = { sent: number; failed: number };
@@ -130,7 +166,7 @@ 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[] = [];
let claimed: ClaimedMail[] = [];
try {
claimed = await claimDueMails(limit);
} catch (error) {