@bobfrankston/rmfmail 1.2.241 → 1.2.243
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/android-bootstrap.bundle.js +24 -2
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +59 -5
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +6 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +6 -1
- package/client/components/message-list.js +13 -2
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +13 -2
- package/client/components/message-viewer.js +110 -3
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +123 -3
- package/client/compose/compose.bundle.js +14 -0
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.css +11 -0
- package/client/lib/rmf-tiny.js +14 -0
- package/package.json +7 -7
- package/packages/mailx-imap/index.d.ts +9 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +86 -0
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +73 -0
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +37 -4
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +34 -4
- package/packages/mailx-types/mime-inline-images.d.ts +0 -4
- package/packages/mailx-types/mime-inline-images.d.ts.map +1 -1
- package/packages/mailx-types/mime-inline-images.js +48 -4
- package/packages/mailx-types/mime-inline-images.js.map +1 -1
- package/packages/mailx-types/mime-inline-images.ts +45 -5
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/test/inline-images.test.mjs +79 -0
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-26520 → node_modules.npmglobalize-stash-17784}/.package-lock.json +0 -0
|
@@ -2038,6 +2038,11 @@ export class MailxService implements MailxApi {
|
|
|
2038
2038
|
let foldersSearched = 0;
|
|
2039
2039
|
let foldersFailed = 0;
|
|
2040
2040
|
const failedFolders: string[] = [];
|
|
2041
|
+
// Folders the server says are gone: counted apart from transient
|
|
2042
|
+
// failures so the UI never tells the user to retry something that
|
|
2043
|
+
// cannot succeed.
|
|
2044
|
+
let foldersGone = 0;
|
|
2045
|
+
const goneFolders: string[] = [];
|
|
2041
2046
|
let droppedHits = 0;
|
|
2042
2047
|
|
|
2043
2048
|
// Bounded concurrency. The per-account ops queue already
|
|
@@ -2071,9 +2076,28 @@ export class MailxService implements MailxApi {
|
|
|
2071
2076
|
for (let j = 0; j < results.length; j++) {
|
|
2072
2077
|
const r = results[j];
|
|
2073
2078
|
if (r.status !== "fulfilled") {
|
|
2079
|
+
const why = String(r.reason?.message || r.reason || "");
|
|
2080
|
+
// A mailbox the server says does not exist is not a
|
|
2081
|
+
// timeout and will never succeed — every search pays
|
|
2082
|
+
// for it again and the user is told to "retry to
|
|
2083
|
+
// search them again", which cannot work. Gmail
|
|
2084
|
+
// answers `[NONEXISTENT] Unknown Mailbox` for the
|
|
2085
|
+
// `[Imap]/…` rows mailx keeps locally; Dovecot says
|
|
2086
|
+
// "doesn't exist". Drop the local folder row (the
|
|
2087
|
+
// IMAP sync path already does this on its own
|
|
2088
|
+
// wording) and count it separately.
|
|
2089
|
+
if (/\[NONEXISTENT\]|Unknown Mailbox|doesn't exist|does not exist/i.test(why)) {
|
|
2090
|
+
foldersGone++;
|
|
2091
|
+
goneFolders.push(`${acct.id}/${batch[j].path}`);
|
|
2092
|
+
console.log(` [server-search] ${acct.id}/${batch[j].path}: mailbox not on server — removing the stale local folder row`);
|
|
2093
|
+
try { this.db.deleteFolder(batch[j].id); } catch (e: any) {
|
|
2094
|
+
console.error(` [server-search] could not remove stale folder ${batch[j].path}: ${e.message}`);
|
|
2095
|
+
}
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2074
2098
|
foldersFailed++;
|
|
2075
2099
|
failedFolders.push(`${acct.id}/${batch[j].path}`);
|
|
2076
|
-
console.error(` [server-search] ${acct.id}/${batch[j].path}: ${
|
|
2100
|
+
console.error(` [server-search] ${acct.id}/${batch[j].path}: ${why}`);
|
|
2077
2101
|
continue;
|
|
2078
2102
|
}
|
|
2079
2103
|
foldersSearched++;
|
|
@@ -2101,14 +2125,20 @@ export class MailxService implements MailxApi {
|
|
|
2101
2125
|
// Newest first, then paginate.
|
|
2102
2126
|
items.sort((a: any, b: any) => (b.date?.getTime?.() || 0) - (a.date?.getTime?.() || 0));
|
|
2103
2127
|
const sliced = items.slice((page - 1) * pageSize, page * pageSize);
|
|
2104
|
-
if (foldersFailed > 0 || droppedHits > 0) {
|
|
2105
|
-
console.log(` [server-search] q="${q}" — ${total} hits across ${foldersSearched} folders; ${foldersFailed}
|
|
2128
|
+
if (foldersFailed > 0 || droppedHits > 0 || foldersGone > 0) {
|
|
2129
|
+
console.log(` [server-search] q="${q}" — ${total} hits across ${foldersSearched} folders; ${foldersFailed} failed, ${foldersGone} stale folder(s) removed, ${droppedHits} hits dropped${aborted ? " (superseded)" : ""}`);
|
|
2106
2130
|
}
|
|
2107
2131
|
return {
|
|
2108
2132
|
items: sliced, total, page, pageSize,
|
|
2109
2133
|
aborted,
|
|
2110
|
-
|
|
2134
|
+
// A superseded search is NOT a partial result — the user typed
|
|
2135
|
+
// another character and this generation was abandoned on
|
|
2136
|
+
// purpose. Reporting its untouched folders as failures is what
|
|
2137
|
+
// produced "99 could not be searched (timeout / connection)"
|
|
2138
|
+
// for a search nothing had gone wrong with (Bob 2026-08-09).
|
|
2139
|
+
partial: !aborted && (foldersFailed > 0 || droppedHits > 0),
|
|
2111
2140
|
foldersSearched, foldersFailed, failedFolders, droppedHits,
|
|
2141
|
+
foldersGone, goneFolders,
|
|
2112
2142
|
};
|
|
2113
2143
|
} else if (scope === "current" && accountId && folderId) {
|
|
2114
2144
|
// Per-folder search — folder scope is the user's explicit
|
|
@@ -27,10 +27,6 @@ export interface InlineImagePart {
|
|
|
27
27
|
mime: string;
|
|
28
28
|
base64: string;
|
|
29
29
|
}
|
|
30
|
-
/** Pull base64 `data:` images out of the HTML, replacing each src with a
|
|
31
|
-
* `cid:` reference. Identical data: URIs (the same image pasted twice)
|
|
32
|
-
* collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
|
|
33
|
-
* msg-id-like uniqueness). */
|
|
34
30
|
export declare function extractInlineImages(html: string, idDomain: string): {
|
|
35
31
|
html: string;
|
|
36
32
|
images: InlineImagePart[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mime-inline-images.d.ts","sourceRoot":"","sources":["mime-inline-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,WAAW,eAAe;IAC5B,oFAAoF;IACpF,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAClB;
|
|
1
|
+
{"version":3,"file":"mime-inline-images.d.ts","sourceRoot":"","sources":["mime-inline-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,WAAW,eAAe;IAC5B,oFAAoF;IACpF,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAClB;AAsBD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,eAAe,EAAE,CAAA;CAAE,CA2C/G"}
|
|
@@ -25,18 +25,62 @@
|
|
|
25
25
|
* `cid:` reference. Identical data: URIs (the same image pasted twice)
|
|
26
26
|
* collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
|
|
27
27
|
* msg-id-like uniqueness). */
|
|
28
|
+
/** Base64 of a UTF-8 string, in both runtimes this package targets: the
|
|
29
|
+
* daemon (Node, no `btoa` on older majors) and the browser/WebView (no
|
|
30
|
+
* `Buffer`). SVG markup is text, so it must go through UTF-8 bytes rather
|
|
31
|
+
* than `btoa` directly, which throws on any character above U+00FF. */
|
|
32
|
+
function utf8ToBase64(text) {
|
|
33
|
+
const bytes = new TextEncoder().encode(text);
|
|
34
|
+
// Reached through globalThis, not by name: this module is type-checked
|
|
35
|
+
// against the BROWSER lib for the client build, where `Buffer` has no
|
|
36
|
+
// declaration at all.
|
|
37
|
+
const B = globalThis.Buffer;
|
|
38
|
+
if (B)
|
|
39
|
+
return B.from(bytes).toString("base64");
|
|
40
|
+
let bin = "";
|
|
41
|
+
for (const b of bytes)
|
|
42
|
+
bin += String.fromCharCode(b);
|
|
43
|
+
return btoa(bin);
|
|
44
|
+
}
|
|
28
45
|
export function extractInlineImages(html, idDomain) {
|
|
29
46
|
const images = [];
|
|
30
47
|
const byDataUri = new Map(); // data-uri → contentId
|
|
31
48
|
const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
32
|
-
|
|
49
|
+
// Two encodings, one job. `;base64,` is what an editor produces when it
|
|
50
|
+
// ingests a pasted bitmap — but SVG normally arrives URL-ENCODED
|
|
51
|
+
// (`data:image/svg+xml,%3Csvg…`), which is how most tools export it and
|
|
52
|
+
// what you get pasting markup. Matching only the base64 form left those
|
|
53
|
+
// as raw `data:` URIs on the wire: invisible in Outlook, stripped by
|
|
54
|
+
// Gmail — exactly the breakage v1.2.179 fixed for bitmaps, still live for
|
|
55
|
+
// SVG until now (Bob 2026-08-09: "can I put an SVG image into a
|
|
56
|
+
// message?"). Percent-encoded payloads are decoded and re-encoded to
|
|
57
|
+
// base64 so every inline image leaves as a normal MIME part.
|
|
58
|
+
const out = html.replace(/(<img\b[^>]*?\ssrc=)(["'])(data:(image\/[\w.+-]+)(;base64)?,([^"']+))\2/gi, (_m, prefix, quote, dataUri, mime, isB64, payload) => {
|
|
33
59
|
let cid = byDataUri.get(dataUri);
|
|
34
60
|
if (!cid) {
|
|
61
|
+
let base64;
|
|
62
|
+
if (isB64) {
|
|
63
|
+
// Strip whitespace the editor may have wrapped into the URI;
|
|
64
|
+
// the send path re-wraps at 76 cols per RFC 2045.
|
|
65
|
+
base64 = payload.replace(/\s+/g, "");
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
try {
|
|
69
|
+
base64 = utf8ToBase64(decodeURIComponent(payload));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Malformed percent-encoding — leave the src untouched
|
|
73
|
+
// rather than emit a corrupt part. Better a broken
|
|
74
|
+
// image the sender can see in their own Sent copy than
|
|
75
|
+
// a silently mangled attachment.
|
|
76
|
+
return _m;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!base64)
|
|
80
|
+
return _m;
|
|
35
81
|
cid = `img${images.length + 1}.${stamp}@${idDomain}`;
|
|
36
82
|
byDataUri.set(dataUri, cid);
|
|
37
|
-
|
|
38
|
-
// the send path re-wraps at 76 cols per RFC 2045.
|
|
39
|
-
images.push({ contentId: cid, mime, base64: b64.replace(/\s+/g, "") });
|
|
83
|
+
images.push({ contentId: cid, mime, base64 });
|
|
40
84
|
}
|
|
41
85
|
return `${prefix}${quote}cid:${cid}${quote}`;
|
|
42
86
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mime-inline-images.js","sourceRoot":"","sources":["mime-inline-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AASH;;;+BAG+B;AAC/B,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,QAAgB;IAC9D,MAAM,MAAM,GAAsB,EAAE,CAAC;IACrC,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC,CAAG,uBAAuB;IACtE,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACpF,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CACpB,
|
|
1
|
+
{"version":3,"file":"mime-inline-images.js","sourceRoot":"","sources":["mime-inline-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AASH;;;+BAG+B;AAC/B;;;wEAGwE;AACxE,SAAS,YAAY,CAAC,IAAY;IAC9B,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC7C,uEAAuE;IACvE,sEAAsE;IACtE,sBAAsB;IACtB,MAAM,CAAC,GAAI,UAAkB,CAAC,MAAM,CAAC;IACrC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC/C,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,GAAG,IAAI,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACrD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;AACrB,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,QAAgB;IAC9D,MAAM,MAAM,GAAsB,EAAE,CAAC;IACrC,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC,CAAG,uBAAuB;IACtE,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACpF,wEAAwE;IACxE,iEAAiE;IACjE,wEAAwE;IACxE,wEAAwE;IACxE,qEAAqE;IACrE,0EAA0E;IAC1E,gEAAgE;IAChE,qEAAqE;IACrE,6DAA6D;IAC7D,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CACpB,2EAA2E,EAC3E,CAAC,EAAE,EAAE,MAAc,EAAE,KAAa,EAAE,OAAe,EAAE,IAAY,EAAE,KAAyB,EAAE,OAAe,EAAE,EAAE;QAC7G,IAAI,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACjC,IAAI,CAAC,GAAG,EAAE,CAAC;YACP,IAAI,MAAc,CAAC;YACnB,IAAI,KAAK,EAAE,CAAC;gBACR,6DAA6D;gBAC7D,kDAAkD;gBAClD,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YACzC,CAAC;iBAAM,CAAC;gBACJ,IAAI,CAAC;oBACD,MAAM,GAAG,YAAY,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAAC;gBACvD,CAAC;gBAAC,MAAM,CAAC;oBACL,uDAAuD;oBACvD,mDAAmD;oBACnD,uDAAuD;oBACvD,iCAAiC;oBACjC,OAAO,EAAE,CAAC;gBACd,CAAC;YACL,CAAC;YACD,IAAI,CAAC,MAAM;gBAAE,OAAO,EAAE,CAAC;YACvB,GAAG,GAAG,MAAM,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,IAAI,QAAQ,EAAE,CAAC;YACrD,SAAS,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;YAC5B,MAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAClD,CAAC;QACD,OAAO,GAAG,MAAM,GAAG,KAAK,OAAO,GAAG,GAAG,KAAK,EAAE,CAAC;IACjD,CAAC,CACJ,CAAC;IACF,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC;AACjC,CAAC"}
|
|
@@ -33,20 +33,60 @@ export interface InlineImagePart {
|
|
|
33
33
|
* `cid:` reference. Identical data: URIs (the same image pasted twice)
|
|
34
34
|
* collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
|
|
35
35
|
* msg-id-like uniqueness). */
|
|
36
|
+
/** Base64 of a UTF-8 string, in both runtimes this package targets: the
|
|
37
|
+
* daemon (Node, no `btoa` on older majors) and the browser/WebView (no
|
|
38
|
+
* `Buffer`). SVG markup is text, so it must go through UTF-8 bytes rather
|
|
39
|
+
* than `btoa` directly, which throws on any character above U+00FF. */
|
|
40
|
+
function utf8ToBase64(text: string): string {
|
|
41
|
+
const bytes = new TextEncoder().encode(text);
|
|
42
|
+
// Reached through globalThis, not by name: this module is type-checked
|
|
43
|
+
// against the BROWSER lib for the client build, where `Buffer` has no
|
|
44
|
+
// declaration at all.
|
|
45
|
+
const B = (globalThis as any).Buffer;
|
|
46
|
+
if (B) return B.from(bytes).toString("base64");
|
|
47
|
+
let bin = "";
|
|
48
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
49
|
+
return btoa(bin);
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
export function extractInlineImages(html: string, idDomain: string): { html: string; images: InlineImagePart[] } {
|
|
37
53
|
const images: InlineImagePart[] = [];
|
|
38
54
|
const byDataUri = new Map<string, string>(); // data-uri → contentId
|
|
39
55
|
const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
56
|
+
// Two encodings, one job. `;base64,` is what an editor produces when it
|
|
57
|
+
// ingests a pasted bitmap — but SVG normally arrives URL-ENCODED
|
|
58
|
+
// (`data:image/svg+xml,%3Csvg…`), which is how most tools export it and
|
|
59
|
+
// what you get pasting markup. Matching only the base64 form left those
|
|
60
|
+
// as raw `data:` URIs on the wire: invisible in Outlook, stripped by
|
|
61
|
+
// Gmail — exactly the breakage v1.2.179 fixed for bitmaps, still live for
|
|
62
|
+
// SVG until now (Bob 2026-08-09: "can I put an SVG image into a
|
|
63
|
+
// message?"). Percent-encoded payloads are decoded and re-encoded to
|
|
64
|
+
// base64 so every inline image leaves as a normal MIME part.
|
|
40
65
|
const out = html.replace(
|
|
41
|
-
/(<img\b[^>]*?\ssrc=)(["'])(data:(image\/[\w.+-]+);base64
|
|
42
|
-
(_m, prefix: string, quote: string, dataUri: string, mime: string,
|
|
66
|
+
/(<img\b[^>]*?\ssrc=)(["'])(data:(image\/[\w.+-]+)(;base64)?,([^"']+))\2/gi,
|
|
67
|
+
(_m, prefix: string, quote: string, dataUri: string, mime: string, isB64: string | undefined, payload: string) => {
|
|
43
68
|
let cid = byDataUri.get(dataUri);
|
|
44
69
|
if (!cid) {
|
|
70
|
+
let base64: string;
|
|
71
|
+
if (isB64) {
|
|
72
|
+
// Strip whitespace the editor may have wrapped into the URI;
|
|
73
|
+
// the send path re-wraps at 76 cols per RFC 2045.
|
|
74
|
+
base64 = payload.replace(/\s+/g, "");
|
|
75
|
+
} else {
|
|
76
|
+
try {
|
|
77
|
+
base64 = utf8ToBase64(decodeURIComponent(payload));
|
|
78
|
+
} catch {
|
|
79
|
+
// Malformed percent-encoding — leave the src untouched
|
|
80
|
+
// rather than emit a corrupt part. Better a broken
|
|
81
|
+
// image the sender can see in their own Sent copy than
|
|
82
|
+
// a silently mangled attachment.
|
|
83
|
+
return _m;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!base64) return _m;
|
|
45
87
|
cid = `img${images.length + 1}.${stamp}@${idDomain}`;
|
|
46
88
|
byDataUri.set(dataUri, cid);
|
|
47
|
-
|
|
48
|
-
// the send path re-wraps at 76 cols per RFC 2045.
|
|
49
|
-
images.push({ contentId: cid, mime, base64: b64.replace(/\s+/g, "") });
|
|
89
|
+
images.push({ contentId: cid, mime, base64 });
|
|
50
90
|
}
|
|
51
91
|
return `${prefix}${quote}cid:${cid}${quote}`;
|
|
52
92
|
},
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every inline image must leave as a MIME part with a `cid:` reference — no
|
|
3
|
+
* `data:` URI may survive onto the wire, in either of the two encodings a
|
|
4
|
+
* data URI comes in.
|
|
5
|
+
*
|
|
6
|
+
* Run: node packages/mailx-types/test/inline-images.test.mjs
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert";
|
|
9
|
+
import { extractInlineImages } from "../mime-inline-images.js";
|
|
10
|
+
|
|
11
|
+
const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
12
|
+
const SVG_TEXT = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#08f"/></svg>`;
|
|
13
|
+
const SVG_B64 = Buffer.from(SVG_TEXT, "utf8").toString("base64");
|
|
14
|
+
|
|
15
|
+
let n = 0;
|
|
16
|
+
const check = (name, html, expectParts, expectMime) => {
|
|
17
|
+
const r = extractInlineImages(html, "bob.ma");
|
|
18
|
+
assert.strictEqual(r.images.length, expectParts, `${name}: expected ${expectParts} part(s), got ${r.images.length}`);
|
|
19
|
+
if (expectParts > 0) {
|
|
20
|
+
assert.strictEqual(r.images[0].mime, expectMime, `${name}: wrong mime (${r.images[0].mime})`);
|
|
21
|
+
assert.ok(!/data:/i.test(r.html), `${name}: a data: URI survived into the sent HTML`);
|
|
22
|
+
assert.match(r.html, /src="cid:img1\./, `${name}: src not rewritten to cid:`);
|
|
23
|
+
assert.doesNotThrow(() => Buffer.from(r.images[0].base64, "base64"), `${name}: part payload is not valid base64`);
|
|
24
|
+
}
|
|
25
|
+
n++;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
check("base64 PNG", `<p>x</p><img src="data:image/png;base64,${PNG_B64}">`, 1, "image/png");
|
|
29
|
+
check("base64 SVG", `<p>x</p><img src="data:image/svg+xml;base64,${SVG_B64}">`, 1, "image/svg+xml");
|
|
30
|
+
|
|
31
|
+
// The form SVG actually arrives in — percent-encoded markup, not base64.
|
|
32
|
+
const urlSvg = `<p>x</p><img src="data:image/svg+xml,${encodeURIComponent(SVG_TEXT)}">`;
|
|
33
|
+
check("URL-encoded SVG", urlSvg, 1, "image/svg+xml");
|
|
34
|
+
|
|
35
|
+
// …and its payload must decode back to the original markup.
|
|
36
|
+
{
|
|
37
|
+
const r = extractInlineImages(urlSvg, "bob.ma");
|
|
38
|
+
const round = Buffer.from(r.images[0].base64, "base64").toString("utf8");
|
|
39
|
+
assert.strictEqual(round, SVG_TEXT, "URL-encoded SVG did not round-trip through the MIME part");
|
|
40
|
+
n++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Non-ASCII inside SVG text must survive (btoa alone would throw here).
|
|
44
|
+
{
|
|
45
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><text>café — naïve ✓</text></svg>`;
|
|
46
|
+
const r = extractInlineImages(`<img src="data:image/svg+xml,${encodeURIComponent(svg)}">`, "bob.ma");
|
|
47
|
+
assert.strictEqual(r.images.length, 1, "non-ASCII SVG produced no part");
|
|
48
|
+
assert.strictEqual(Buffer.from(r.images[0].base64, "base64").toString("utf8"), svg, "non-ASCII SVG mangled");
|
|
49
|
+
n++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Malformed percent-encoding: leave the tag alone rather than emit a corrupt part.
|
|
53
|
+
{
|
|
54
|
+
const bad = `<img src="data:image/svg+xml,%E0%A4%A">`;
|
|
55
|
+
const r = extractInlineImages(bad, "bob.ma");
|
|
56
|
+
assert.strictEqual(r.images.length, 0, "a malformed data URI must not become a part");
|
|
57
|
+
assert.strictEqual(r.html, bad, "a malformed data URI must be left untouched");
|
|
58
|
+
n++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The same image twice collapses to one part.
|
|
62
|
+
{
|
|
63
|
+
const two = `<img src="data:image/png;base64,${PNG_B64}"><img src="data:image/png;base64,${PNG_B64}">`;
|
|
64
|
+
const r = extractInlineImages(two, "bob.ma");
|
|
65
|
+
assert.strictEqual(r.images.length, 1, "identical images should share one part");
|
|
66
|
+
assert.strictEqual((r.html.match(/cid:/g) || []).length, 2, "both tags should reference the shared cid");
|
|
67
|
+
n++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A remote image is not ours to inline.
|
|
71
|
+
{
|
|
72
|
+
const remote = `<img src="https://example.com/x.png">`;
|
|
73
|
+
const r = extractInlineImages(remote, "bob.ma");
|
|
74
|
+
assert.strictEqual(r.images.length, 0);
|
|
75
|
+
assert.strictEqual(r.html, remote);
|
|
76
|
+
n++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(`inline-images: ${n} checks passed`);
|