diff --git a/src/lib/email-client.ts b/src/lib/email-client.ts index 7a3cc4f..e4699c0 100644 --- a/src/lib/email-client.ts +++ b/src/lib/email-client.ts @@ -1,23 +1,20 @@ -function bytesBase64(bytes: Uint8Array) { - let binary = ""; - for (let offset = 0; offset < bytes.length; offset += 0x8000) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); - } - return globalThis.btoa(binary); -} +/** + * Sorties navigateur du studio de composition : copie enrichie, brouillon + * Outlook, export HTML. Rien ici ne part sur le réseau — l'envoi réel vit + * côté serveur dans `src/lib/mailer.ts`. + * + * L'assemblage du message est délégué à `src/lib/mime.ts`, partagé avec le + * transport Gmail : un seul constructeur RFC 822, verrouillé par ses tests. + */ +import { buildMimeMessage, bytesToBase64, type MimeAttachment } from "@/lib/mime"; -function utf8Base64(value: string) { - return bytesBase64(new TextEncoder().encode(value)); -} - -function wrapBase64(value: string) { - return value.match(/.{1,76}/g)?.join("\r\n") ?? ""; -} +const LOGO_CONTENT_ID = "plein-r-logo"; +const LOGO_SRC = /(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi; function slug(value: string) { return value .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") + .replace(/[̀-ͯ]/g, "") .replace(/[^a-zA-Z0-9]+/g, "-") .replace(/^-|-$/g, "") .toLowerCase() || "email-plein-r"; @@ -59,20 +56,25 @@ export async function copyRichEmail(html: string, plainText: string) { export async function downloadOutlookDraft(subject: string, html: string, prefix = "email") { const logoUrl = html.match(/\bsrc=["']([^"']*\/assets\/logo\.png(?:\?[^"']*)?)["']/i)?.[1]; let renderedHtml = html; - let logoPart = ""; - const boundary = "----=_PleinR_Outlook_Draft"; + const inline: MimeAttachment[] = []; + // Le logo est embarqué plutôt que laissé en lien : un brouillon ouvert hors + // ligne, ou avant la mise en ligne du site, afficherait sinon un cadre vide. if (logoUrl) { const response = await fetch(logoUrl, { cache: "force-cache" }); if (!response.ok) throw new Error("Logo Plein R introuvable."); - const base64 = bytesBase64(new Uint8Array(await response.arrayBuffer())); - renderedHtml = html.replace(/(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi, "$1$2cid:plein-r-logo$2"); - logoPart = [`--${boundary}`, "Content-Type: image/png; name=\"logo.png\"", "Content-Transfer-Encoding: base64", "Content-ID: ", "Content-Disposition: inline; filename=\"logo.png\"", "", wrapBase64(base64)].join("\r\n"); + renderedHtml = html.replace(LOGO_SRC, `$1$2cid:${LOGO_CONTENT_ID}$2`); + inline.push({ + filename: "logo.png", + contentType: "image/png", + contentId: LOGO_CONTENT_ID, + base64: bytesToBase64(new Uint8Array(await response.arrayBuffer())), + }); } - const mime = logoPart - ? ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", `Content-Type: multipart/related; type=\"text/html\"; boundary=\"${boundary}\"`, "", `--${boundary}`, "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(renderedHtml)), logoPart, `--${boundary}--`, ""].join("\r\n") - : ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(html)), ""].join("\r\n"); + // `X-Unsent: 1` : Outlook ouvre le fichier comme un brouillon modifiable et + // non comme un message reçu. + const mime = buildMimeMessage({ subject, html: renderedHtml, inline, headers: { "X-Unsent": "1" } }); downloadBlob(new Blob([mime], { type: "message/rfc822" }), `${prefix}-${slug(subject)}-outlook.eml`); } diff --git a/src/lib/mime.ts b/src/lib/mime.ts new file mode 100644 index 0000000..646382e --- /dev/null +++ b/src/lib/mime.ts @@ -0,0 +1,196 @@ +/** + * Construction d'un message RFC 822. + * + * Module **pur** (verrouillé par `tests/mime.test.ts`) : il n'utilise que + * `TextEncoder` et `btoa`, disponibles aussi bien dans Node que dans le + * navigateur. Il sert donc deux appelants qui n'ont rien à voir : + * + * - `src/lib/email-client.ts`, qui fabrique le brouillon `.eml` téléchargé + * depuis le studio de composition ; + * - le transport Gmail (`src/lib/mailer.ts`), dont le champ `raw` attend + * exactement ce format, encodé en base64url. + * + * Tout corps est encodé en base64 : une frontière ne peut donc jamais + * apparaître dans la charge utile, et les sauts de ligne restent en CRLF — + * un LF isolé casse le « dot-stuffing » SMTP et se fait rejeter par Gmail. + */ + +const CRLF = "\r\n"; + +/** Longueur maximale d'une ligne base64 (RFC 2045). */ +const BASE64_LINE = 76; + +/** + * Un mot encodé RFC 2047 ne doit pas dépasser 75 caractères. L'enveloppe + * `=?UTF-8?B?` + `?=` en consomme 12, il reste 63 caractères de base64, soit + * 45 octets de source (4 caractères base64 pour 3 octets). + */ +const HEADER_CHUNK_BYTES = 45; + +const PRINTABLE_ASCII = /^[\x20-\x7e]*$/; + +export type MimeAttachment = { + filename: string; + contentType: string; + /** Contenu déjà encodé en base64, sans repli de ligne. */ + base64: string; + /** Présent = pièce jointe en ligne, référencée par `cid:`. */ + contentId?: string; +}; + +export type MimeMessage = { + from?: string; + to?: string; + replyTo?: string; + subject: string; + html: string; + text?: string; + /** En-têtes libres ajoutés en tête (`X-Unsent`, `List-Unsubscribe`…). */ + headers?: Record; + inline?: MimeAttachment[]; + /** + * Frontières imposées. Injectable pour que les tests décrivent une sortie + * stable ; en production elles sont tirées au hasard. + */ + boundaries?: string[]; +}; + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + // Par tranches : `String.fromCharCode(...bytes)` dépasse la pile d'appels + // au-delà de quelques dizaines de milliers d'octets. + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return globalThis.btoa(binary); +} + +export function utf8ToBase64(value: string): string { + return bytesToBase64(new TextEncoder().encode(value)); +} + +export function wrapBase64(value: string): string { + return value.match(new RegExp(`.{1,${BASE64_LINE}}`, "g"))?.join(CRLF) ?? ""; +} + +/** Encodage attendu par le champ `raw` de l'API Gmail. */ +export function toBase64Url(value: string): string { + return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Valeur d'en-tête sûre. Les caractères hors ASCII imprimable passent en mots + * encodés RFC 2047, repliés si le texte est long ; un accent dans un objet ne + * doit jamais partir tel quel. + */ +export function encodeHeaderValue(value: string): string { + // Un CR ou un LF dans une valeur d'en-tête permettrait d'en injecter + // d'autres (un `Bcc:` par exemple) : on les réduit à une espace. + const clean = String(value ?? "").replace(/[\r\n]+/g, " ").trim(); + if (PRINTABLE_ASCII.test(clean)) return clean; + + const encoder = new TextEncoder(); + const words: string[] = []; + let chunk: number[] = []; + // Itération par point de code : découper le tableau d'octets au ras des 45 + // couperait une séquence UTF-8 en deux. + for (const char of clean) { + const bytes = encoder.encode(char); + if (chunk.length > 0 && chunk.length + bytes.length > HEADER_CHUNK_BYTES) { + words.push(`=?UTF-8?B?${bytesToBase64(Uint8Array.from(chunk))}?=`); + chunk = []; + } + chunk.push(...bytes); + } + if (chunk.length > 0) words.push(`=?UTF-8?B?${bytesToBase64(Uint8Array.from(chunk))}?=`); + return words.join(`${CRLF} `); +} + +/** « Nom affiché » , avec le nom encodé ou échappé selon les cas. */ +export function formatAddress(email: string, name?: string | null): string { + const address = String(email ?? "").replace(/[\r\n<>,;]/g, "").trim(); + const display = String(name ?? "").replace(/[\r\n]+/g, " ").trim(); + if (!address) return ""; + if (!display) return address; + if (PRINTABLE_ASCII.test(display)) { + return `"${display.replace(/(["\\])/g, "\\$1")}" <${address}>`; + } + return `${encodeHeaderValue(display)} <${address}>`; +} + +function randomBoundary(index: number): string { + const noise = Math.random().toString(36).slice(2, 12); + return `----=_PleinR_${index}_${noise}`; +} + +type Part = { headers: string[]; body: string }; + +function textPart(contentType: string, value: string): Part { + return { + headers: [`Content-Type: ${contentType}; charset=UTF-8`, "Content-Transfer-Encoding: base64"], + body: wrapBase64(utf8ToBase64(value)), + }; +} + +function attachmentPart(attachment: MimeAttachment): Part { + const disposition = attachment.contentId ? "inline" : "attachment"; + const headers = [ + `Content-Type: ${attachment.contentType}; name="${attachment.filename}"`, + "Content-Transfer-Encoding: base64", + `Content-Disposition: ${disposition}; filename="${attachment.filename}"`, + ]; + if (attachment.contentId) headers.splice(2, 0, `Content-ID: <${attachment.contentId}>`); + return { headers, body: wrapBase64(attachment.base64) }; +} + +function multipart(subtype: string, boundary: string, parts: Part[], extra = ""): Part { + const body = [ + ...parts.flatMap((part) => [`--${boundary}`, ...part.headers, "", part.body]), + `--${boundary}--`, + ].join(CRLF); + return { + headers: [`Content-Type: multipart/${subtype}; ${extra}boundary="${boundary}"`], + body, + }; +} + +/** + * Assemble le message complet. Structure choisie selon ce qui est fourni : + * + * html seul → text/html + * html + texte → multipart/alternative + * … + images en ligne → multipart/related enveloppant le précédent + */ +export function buildMimeMessage(message: MimeMessage): string { + const boundaryAt = (index: number) => message.boundaries?.[index] ?? randomBoundary(index); + const inline = message.inline ?? []; + + let root: Part = textPart("text/html", message.html); + if (message.text) { + // L'ordre compte : le repli texte d'abord, la version préférée en dernier. + root = multipart("alternative", boundaryAt(0), [ + textPart("text/plain", message.text), + root, + ]); + } + if (inline.length > 0) { + root = multipart( + "related", + boundaryAt(1), + [root, ...inline.map(attachmentPart)], + 'type="text/html"; ', + ); + } + + const headers: string[] = []; + for (const [key, value] of Object.entries(message.headers ?? {})) { + headers.push(`${key}: ${encodeHeaderValue(value)}`); + } + if (message.from) headers.push(`From: ${message.from}`); + if (message.to) headers.push(`To: ${message.to}`); + if (message.replyTo) headers.push(`Reply-To: ${message.replyTo}`); + headers.push(`Subject: ${encodeHeaderValue(message.subject)}`); + headers.push("MIME-Version: 1.0"); + + return [...headers, ...root.headers, "", root.body, ""].join(CRLF); +} diff --git a/tests/mime.test.ts b/tests/mime.test.ts new file mode 100644 index 0000000..20608ed --- /dev/null +++ b/tests/mime.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + buildMimeMessage, + bytesToBase64, + encodeHeaderValue, + formatAddress, + toBase64Url, + utf8ToBase64, + wrapBase64, +} from "../src/lib/mime"; + +const BOUNDARIES = ["ALT", "REL"]; + +test("un objet purement ASCII part tel quel", () => { + assert.equal(encodeHeaderValue("Invitation Plein R"), "Invitation Plein R"); +}); + +test("un objet accentué est encodé en mot RFC 2047", () => { + const encoded = encodeHeaderValue("Rencontre à Pompey"); + assert.match(encoded, /^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/); + assert.equal(Buffer.from(encoded.slice(10, -2), "base64").toString("utf8"), "Rencontre à Pompey"); +}); + +test("un objet accentué long est replié en plusieurs mots d'au plus 75 caractères", () => { + const encoded = encodeHeaderValue( + "Assemblée générale de l'association Plein R du Bassin de Pompey — mardi 14 octobre à 19 h, salle Jean-Jaurès", + ); + const words = encoded.split("\r\n "); + assert.ok(words.length > 1, "le texte long doit être replié"); + for (const word of words) { + assert.ok(word.length <= 75, `mot trop long : ${word.length}`); + assert.match(word, /^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/); + } + const decoded = words.map((w) => Buffer.from(w.slice(10, -2), "base64").toString("utf8")).join(""); + assert.equal(decoded, "Assemblée générale de l'association Plein R du Bassin de Pompey — mardi 14 octobre à 19 h, salle Jean-Jaurès"); +}); + +test("le repli ne coupe jamais une séquence UTF-8 en deux", () => { + // Que des caractères à 3 octets : 45 / 3 = 15 par mot, sans reste. + const encoded = encodeHeaderValue("€".repeat(40)); + const decoded = encoded + .split("\r\n ") + .map((w) => Buffer.from(w.slice(10, -2), "base64").toString("utf8")) + .join(""); + assert.equal(decoded, "€".repeat(40)); +}); + +test("un retour à la ligne dans un en-tête ne permet pas d'en injecter un autre", () => { + const encoded = encodeHeaderValue("Bonjour\r\nBcc: espion@example.com"); + assert.ok(!encoded.includes("\r")); + assert.ok(!encoded.includes("\n")); + assert.equal(encoded, "Bonjour Bcc: espion@example.com"); +}); + +test("une adresse avec nom affiché est mise en forme, une adresse nue reste nue", () => { + assert.equal(formatAddress("contact@pleinr.fr"), "contact@pleinr.fr"); + assert.equal(formatAddress("contact@pleinr.fr", "Plein R"), '"Plein R" '); + assert.equal( + formatAddress("contact@pleinr.fr", 'Plein "R"'), + '"Plein \\"R\\"" ', + ); +}); + +test("un nom affiché accentué est encodé, pas mis entre guillemets", () => { + const address = formatAddress("contact@pleinr.fr", "Association Plein R — Élan"); + assert.match(address, /^=\?UTF-8\?B\?.+\?= $/); +}); + +test("une adresse malformée ne peut pas glisser d'en-tête supplémentaire", () => { + assert.equal( + formatAddress("victime@pleinr.fr>\r\nBcc: espion@example.com", "Plein R"), + '"Plein R" ', + ); +}); + +test("le base64 est replié à 76 colonnes", () => { + const lines = wrapBase64(utf8ToBase64("x".repeat(400))).split("\r\n"); + for (const line of lines) assert.ok(line.length <= 76, `ligne de ${line.length} caractères`); + assert.ok(lines.length > 1); +}); + +test("toBase64Url retire le remplissage et remplace + et /", () => { + const raw = bytesToBase64(Uint8Array.from([251, 255, 190, 0])); + assert.ok(raw.includes("+") || raw.includes("/")); + const url = toBase64Url(raw); + assert.ok(!url.includes("+") && !url.includes("/") && !url.includes("=")); + assert.equal(url, raw.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")); +}); + +test("un message HTML seul tient en une seule partie", () => { + const mime = buildMimeMessage({ subject: "Bonjour", html: "

Salut

" }); + assert.match(mime, /^Subject: Bonjour\r\nMIME-Version: 1\.0\r\nContent-Type: text\/html; charset=UTF-8\r\n/); + assert.ok(!mime.includes("multipart")); + assert.ok(mime.includes(utf8ToBase64("

Salut

"))); +}); + +test("html + texte donnent un multipart/alternative, le texte d'abord", () => { + const mime = buildMimeMessage({ + subject: "Bonjour", + html: "

Salut

", + text: "Salut", + boundaries: BOUNDARIES, + }); + assert.ok(mime.includes('Content-Type: multipart/alternative; boundary="ALT"')); + assert.ok(mime.includes("--ALT--")); + assert.ok( + mime.indexOf("text/plain") < mime.indexOf("text/html"), + "le repli texte doit précéder la version préférée", + ); +}); + +test("une image en ligne enveloppe le tout dans un multipart/related", () => { + const mime = buildMimeMessage({ + subject: "Bonjour", + html: '', + text: "Bonjour", + boundaries: BOUNDARIES, + inline: [{ filename: "logo.png", contentType: "image/png", contentId: "logo", base64: "AAAA" }], + }); + assert.ok(mime.includes('Content-Type: multipart/related; type="text/html"; boundary="REL"')); + assert.ok(mime.includes("Content-ID: ")); + assert.ok(mime.includes('Content-Disposition: inline; filename="logo.png"')); + // L'alternative reste imbriquée dans le related, pas l'inverse. + assert.ok(mime.indexOf("--REL") < mime.indexOf("multipart/alternative")); + assert.ok(mime.includes("--ALT--")); + assert.ok(mime.includes("--REL--")); +}); + +test("une pièce jointe sans identifiant de contenu est attachée, pas incorporée", () => { + const mime = buildMimeMessage({ + subject: "Compte rendu", + html: "

Ci-joint

", + boundaries: BOUNDARIES, + inline: [{ filename: "cr.pdf", contentType: "application/pdf", base64: "AAAA" }], + }); + assert.ok(mime.includes('Content-Disposition: attachment; filename="cr.pdf"')); + assert.ok(!mime.includes("Content-ID")); +}); + +test("le message n'utilise que des CRLF, jamais un LF isolé", () => { + const mime = buildMimeMessage({ + subject: "Rencontre à Pompey — programme complet de la soirée du 14 octobre", + html: "

Ligne une

\n

Ligne deux

", + text: "Ligne une\nLigne deux", + boundaries: BOUNDARIES, + inline: [{ filename: "logo.png", contentType: "image/png", contentId: "logo", base64: "AAAA" }], + }); + // Les corps sont encodés en base64 : aucun LF de la source ne survit brut. + assert.equal(mime.replace(/\r\n/g, ""), mime.replace(/\r\n/g, "").replace(/[\r\n]/g, "")); + assert.ok(!/(? { + const mime = buildMimeMessage({ + subject: "Essai", + html: "

--ALT et --REL dans le texte

", + text: "--ALT--", + boundaries: BOUNDARIES, + }); + // Les corps étant en base64, les seules occurrences sont les délimiteurs. + assert.equal(mime.match(/--ALT/g)?.length, 3); // ouverture ×2 + fermeture +}); + +test("les en-têtes libres, l'expéditeur et le répondre-à sont posés", () => { + const mime = buildMimeMessage({ + subject: "Essai", + html: "

a

", + from: '"Plein R" ', + to: "adherent@example.com", + replyTo: "bureau@pleinr.fr", + headers: { "X-Unsent": "1" }, + }); + assert.ok(mime.startsWith("X-Unsent: 1\r\n")); + assert.ok(mime.includes('From: "Plein R" \r\n')); + assert.ok(mime.includes("To: adherent@example.com\r\n")); + assert.ok(mime.includes("Reply-To: bureau@pleinr.fr\r\n")); +});