@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.
Files changed (37) hide show
  1. package/client/android-bootstrap.bundle.js +24 -2
  2. package/client/android-bootstrap.bundle.js.map +2 -2
  3. package/client/app.bundle.js +59 -5
  4. package/client/app.bundle.js.map +3 -3
  5. package/client/app.js +6 -1
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +6 -1
  8. package/client/components/message-list.js +13 -2
  9. package/client/components/message-list.js.map +1 -1
  10. package/client/components/message-list.ts +13 -2
  11. package/client/components/message-viewer.js +110 -3
  12. package/client/components/message-viewer.js.map +1 -1
  13. package/client/components/message-viewer.ts +123 -3
  14. package/client/compose/compose.bundle.js +14 -0
  15. package/client/compose/compose.bundle.js.map +2 -2
  16. package/client/compose/compose.css +11 -0
  17. package/client/lib/rmf-tiny.js +14 -0
  18. package/package.json +7 -7
  19. package/packages/mailx-imap/index.d.ts +9 -0
  20. package/packages/mailx-imap/index.d.ts.map +1 -1
  21. package/packages/mailx-imap/index.js +86 -0
  22. package/packages/mailx-imap/index.js.map +1 -1
  23. package/packages/mailx-imap/index.ts +73 -0
  24. package/packages/mailx-imap/package-lock.json +2 -2
  25. package/packages/mailx-imap/package.json +1 -1
  26. package/packages/mailx-service/index.d.ts.map +1 -1
  27. package/packages/mailx-service/index.js +37 -4
  28. package/packages/mailx-service/index.js.map +1 -1
  29. package/packages/mailx-service/index.ts +34 -4
  30. package/packages/mailx-types/mime-inline-images.d.ts +0 -4
  31. package/packages/mailx-types/mime-inline-images.d.ts.map +1 -1
  32. package/packages/mailx-types/mime-inline-images.js +48 -4
  33. package/packages/mailx-types/mime-inline-images.js.map +1 -1
  34. package/packages/mailx-types/mime-inline-images.ts +45 -5
  35. package/packages/mailx-types/package.json +1 -1
  36. package/packages/mailx-types/test/inline-images.test.mjs +79 -0
  37. /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}: ${r.reason?.message || r.reason}`);
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} folders failed, ${droppedHits} hits dropped`);
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
- partial: foldersFailed > 0 || droppedHits > 0,
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;AAED;;;+BAG+B;AAC/B,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,eAAe,EAAE,CAAA;CAAE,CAmB/G"}
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
- const out = html.replace(/(<img\b[^>]*?\ssrc=)(["'])(data:(image\/[\w.+-]+);base64,([A-Za-z0-9+/=\s]+))\2/gi, (_m, prefix, quote, dataUri, mime, b64) => {
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
- // Strip whitespace the editor may have wrapped into the URI;
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,mFAAmF,EACnF,CAAC,EAAE,EAAE,MAAc,EAAE,KAAa,EAAE,OAAe,EAAE,IAAY,EAAE,GAAW,EAAE,EAAE;QAC9E,IAAI,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACjC,IAAI,CAAC,GAAG,EAAE,CAAC;YACP,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,6DAA6D;YAC7D,kDAAkD;YAClD,MAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC;QAC3E,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"}
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,([A-Za-z0-9+/=\s]+))\2/gi,
42
- (_m, prefix: string, quote: string, dataUri: string, mime: string, b64: 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
- // Strip whitespace the editor may have wrapped into the URI;
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
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -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`);