mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-19 21:28:09 +02:00
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:
7 files changed
+265
-21
No files matched your search
@@ -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 · ${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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user