@bobfrankston/mailx-types 0.1.42 → 0.1.45
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/mailx-api.d.ts +7 -0
- package/mime-inline-images.d.ts +0 -4
- package/mime-inline-images.js +48 -4
- package/package.json +1 -1
- package/test/inline-images.test.mjs +79 -0
package/mailx-api.d.ts
CHANGED
|
@@ -204,6 +204,13 @@ export interface MailxApi {
|
|
|
204
204
|
status: number;
|
|
205
205
|
statusText: string;
|
|
206
206
|
}>;
|
|
207
|
+
/** Fetch a remote image's bytes for the viewer's "Copy image" action —
|
|
208
|
+
* cross-origin images can't be read inside the WebView (no CORS headers,
|
|
209
|
+
* tainted canvas), so the daemon does it. Optional: desktop-only. */
|
|
210
|
+
fetchRemoteImage?(url: string): Promise<{
|
|
211
|
+
base64: string;
|
|
212
|
+
contentType: string;
|
|
213
|
+
}>;
|
|
207
214
|
consumePendingMailto(): any | Promise<any>;
|
|
208
215
|
/** C46 Windows Share target — startup-race twin of the daemon's
|
|
209
216
|
* fs.watch on pending-share.json. Optional: desktop-only. */
|
package/mime-inline-images.d.ts
CHANGED
|
@@ -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[];
|
package/mime-inline-images.js
CHANGED
|
@@ -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
|
});
|
package/package.json
CHANGED
|
@@ -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`);
|