diff --git a/src/app/api/informations/[id]/image/route.ts b/src/app/api/informations/[id]/image/route.ts new file mode 100644 index 0000000..05b2538 --- /dev/null +++ b/src/app/api/informations/[id]/image/route.ts @@ -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 ``. 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", + }, + }); +} diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index b2a0dac..acf393c 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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 `
` 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 : ${published.title}`, "#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 ${published.title} 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) { diff --git a/src/app/backend/informations/page.tsx b/src/app/backend/informations/page.tsx index f253d5c..5e0cb93 100644 --- a/src/app/backend/informations/page.tsx +++ b/src/app/backend/informations/page.tsx @@ -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 ( -
+
+ {error && ( +
+ {error} +
+ )} + {ok && ( +
+ {ok} +
+ )} + +
{richTextExcerpt(item.body, 110)}
+ {item.emailSentAt && ( +
+ 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` : ""} +
+ )}
Modifier @@ -116,11 +148,24 @@ export default async function InformationsPage({
{richTextExcerpt(item.body, 110)}
- + + {canBroadcast && ( + + )} @@ -139,6 +184,7 @@ export default async function InformationsPage({ ))}
+
); diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 129ec8a..57865b0 100644 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -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(); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index d07a3b3..d50e302 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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"); } diff --git a/src/lib/email-templates.ts b/src/lib/email-templates.ts index f74c787..16646d1 100644 --- a/src/lib/email-templates.ts +++ b/src/lib/email-templates.ts @@ -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 Si le bouton ne fonctionne pas, copiez ce lien :
${esc(loginUrl)}`; 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//image`) et + * non en data-URI : Gmail et Outlook suppriment les ``, 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 = ` + +
Information · ${esc(brand.associationName)}
+
${esc(data.title)}
+ + ${ + data.hasImage + ? `` + : "" + } + ${richTextToEmailHtml(data.body)} + ${cta(spaceUrl, "Lire dans mon espace")} + + Vous recevez ce message en tant qu'adhérent de ${esc(brand.associationName)}.
+ Si le bouton ne fonctionne pas, copiez ce lien : ${esc(spaceUrl)} + `; + return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) }; +} diff --git a/src/lib/mail-outbox.ts b/src/lib/mail-outbox.ts index 668d973..e189ba9 100644 --- a/src/lib/mail-outbox.ts +++ b/src/lib/mail-outbox.ts @@ -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 { +async function claimDueMails(limit: number): Promise { 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 { .set({ status: "queued", lockedAt: null }) .where(and(eq(mailMessages.status, "sending"), sql`${mailMessages.lockedAt} < ${stale}`)); - const { rows } = await db.execute(sql` + const { rows } = await db.execute(sql` update ${mailMessages} set status = 'sending', locked_at = now() where id in ( @@ -120,9 +143,22 @@ async function claimDueMails(limit: number): Promise { 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 { const report: OutboxReport = { sent: 0, failed: 0 }; - let claimed: MailMessage[] = []; + let claimed: ClaimedMail[] = []; try { claimed = await claimDueMails(limit); } catch (error) {