mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-14 02:39:22 +02:00
Extrait le constructeur MIME dans un module pur
`downloadOutlookDraft` assemblait à la main un message RFC 822 complet (sujet RFC 2047, base64 replié, partie `cid:` en ligne). Ce code a un second appelant à venir : le champ `raw` de l'API Gmail attend exactement ce format. Il part donc dans `src/lib/mime.ts`, pur et verrouillé par `tests/mime.test.ts`, plutôt que d'importer plus tard l'entrée interne `nodemailer/lib/mail-composer`. Le constructeur gagne au passage ce que le brouillon Outlook n'avait pas besoin de gérer : repli des mots encodés longs sans couper une séquence UTF-8, `multipart/alternative` pour le repli texte, mise en forme des adresses, et neutralisation des CR/LF dans les valeurs d'en-tête — un retour à la ligne dans un objet permettrait sinon d'injecter un `Bcc:`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
3 files changed
+400
-23
No files matched your search
+25
-23
@@ -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: <plein-r-logo>", "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`);
|
||||
}
|
||||
|
||||
|
||||
+196
@@ -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>`. */
|
||||
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<string, string>;
|
||||
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é » <adresse>, 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);
|
||||
}
|
||||
@@ -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" <contact@pleinr.fr>');
|
||||
assert.equal(
|
||||
formatAddress("contact@pleinr.fr", 'Plein "R"'),
|
||||
'"Plein \\"R\\"" <contact@pleinr.fr>',
|
||||
);
|
||||
});
|
||||
|
||||
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\?.+\?= <contact@pleinr\.fr>$/);
|
||||
});
|
||||
|
||||
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" <victime@pleinr.frBcc: espion@example.com>',
|
||||
);
|
||||
});
|
||||
|
||||
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: "<p>Salut</p>" });
|
||||
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("<p>Salut</p>")));
|
||||
});
|
||||
|
||||
test("html + texte donnent un multipart/alternative, le texte d'abord", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Bonjour",
|
||||
html: "<p>Salut</p>",
|
||||
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: '<img src="cid:logo">',
|
||||
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: <logo>"));
|
||||
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: "<p>Ci-joint</p>",
|
||||
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: "<p>Ligne une</p>\n<p>Ligne deux</p>",
|
||||
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(!/(?<!\r)\n/.test(mime), "un LF sans CR précède");
|
||||
});
|
||||
|
||||
test("une frontière ne peut pas apparaître dans la charge utile", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Essai",
|
||||
html: "<p>--ALT et --REL dans le texte</p>",
|
||||
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: "<p>a</p>",
|
||||
from: '"Plein R" <contact@pleinr.fr>',
|
||||
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" <contact@pleinr.fr>\r\n'));
|
||||
assert.ok(mime.includes("To: adherent@example.com\r\n"));
|
||||
assert.ok(mime.includes("Reply-To: bureau@pleinr.fr\r\n"));
|
||||
});
|
||||
Reference in new issue
Block a user