@volter/twin-gmail 1.0.0 → 1.0.2
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/dist/src/engine/mail.js +17 -87
- package/package.json +3 -3
- package/src/engine/mail.ts +16 -69
package/dist/src/engine/mail.js
CHANGED
|
@@ -1,89 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
for (const line of head.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
|
|
9
|
-
const i = line.indexOf(':');
|
|
10
|
-
if (i > 0)
|
|
11
|
-
headers[line.slice(0, i).trim().toLowerCase()] ??= utf8(bytesOf(line.slice(i + 1).trim()));
|
|
12
|
-
}
|
|
13
|
-
return { headers, body };
|
|
14
|
-
}
|
|
15
|
-
const param = (value, name) => {
|
|
16
|
-
const m = new RegExp(`;\\s*${name}\\*?=\\s*(?:"([^"]*)"|([^;\\s]+))`, 'i').exec(value);
|
|
17
|
-
const v = m?.[1] ?? m?.[2];
|
|
18
|
-
return v?.startsWith("utf-8''") || v?.startsWith("UTF-8''") ? decodeURIComponent(v.slice(7)) : v;
|
|
19
|
-
};
|
|
20
|
-
const bytesOf = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff);
|
|
21
|
-
const utf8 = (b) => new TextDecoder().decode(b);
|
|
22
|
-
const textFor = (b, charset) => { try {
|
|
23
|
-
return new TextDecoder(charset ?? 'utf-8').decode(b);
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return utf8(b);
|
|
27
|
-
} };
|
|
28
|
-
/** A part's body decoded by its Content-Transfer-Encoding: base64 and quoted-printable to bytes (RFC 2045 §6). */
|
|
29
|
-
function decoded(part) {
|
|
30
|
-
const enc = (part.headers['content-transfer-encoding'] ?? '7bit').toLowerCase();
|
|
31
|
-
if (enc === 'base64') {
|
|
32
|
-
try {
|
|
33
|
-
return bytesOf(atob(part.body.replace(/\s+/g, '')));
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return bytesOf(part.body);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (enc === 'quoted-printable')
|
|
40
|
-
return bytesOf(part.body.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(Number.parseInt(h, 16))).split('').map((c) => (c.charCodeAt(0) > 0xff ? '?' : c)).join(''));
|
|
41
|
-
return bytesOf(part.body);
|
|
42
|
-
}
|
|
43
|
-
/** Encoded words in a header (`=?UTF-8?B?…?=`, `=?UTF-8?Q?…?=`), decoded (RFC 2047). */
|
|
44
|
-
// source: https://www.rfc-editor.org/rfc/rfc2047 "encoded-word = \"=?\" charset \"?\" encoding \"?\" encoded-text \"?=\""
|
|
45
|
-
function words(value) {
|
|
46
|
-
return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=(\s+(?==\?))?/g, (_, _cs, e, t) => {
|
|
47
|
-
try {
|
|
48
|
-
return textFor(e.toUpperCase() === 'B' ? bytesOf(atob(t)) : bytesOf(t.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(Number.parseInt(h, 16)))), _cs);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return t;
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
const addresses = (v) => (v ? words(v).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map((a) => (/<([^>]+)>/.exec(a)?.[1] ?? a).trim()).filter(Boolean) : []);
|
|
56
|
-
/** The message's parts: a multipart's each (recursively), else the one. */
|
|
57
|
-
function leaves(part) {
|
|
58
|
-
const type = part.headers['content-type'] ?? 'text/plain';
|
|
59
|
-
const boundary = /^multipart\//i.test(type) ? param(type, 'boundary') : undefined;
|
|
60
|
-
if (!boundary)
|
|
61
|
-
return [part];
|
|
62
|
-
const pieces = part.body.split(new RegExp(`\\r?\\n?--${boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?[ \\t]*\\r?\\n?`));
|
|
63
|
-
return pieces.slice(1, -1).filter((p) => p.trim()).flatMap((p) => leaves(split(p)));
|
|
64
|
-
}
|
|
1
|
+
// A message as RFC 5322 and MIME make of it (its headers, its text and HTML, its attachments), from the bytes Gmail's
|
|
2
|
+
// receiving server accepted, read by the kernel's one mail reader (world-core readMailMessage): pure, so the server's
|
|
3
|
+
// front keeps it in each recipient's mailbox (semantics/shared.ts `deliver`). The data arrives with its dot-stuffing
|
|
4
|
+
// undone (../stream.ts); the World keeps the bytes as sent.
|
|
5
|
+
import { mailAddressList, mailWords, readMailMessage } from '@volter/world-core';
|
|
6
|
+
/** An address field's mailboxes: each member's `<addr>`, or the member as written. */
|
|
7
|
+
const addresses = (v) => mailAddressList(v).map((a) => /<([^>]+)>/.exec(a)?.[1] ?? a);
|
|
65
8
|
export function parse(data) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
text = textFor(bytes, param(leaf.headers['content-type'] ?? '', 'charset'));
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (!/^attachment/i.test(disposition) && !filename && type === 'text/html' && html === null) {
|
|
80
|
-
html = textFor(bytes, param(leaf.headers['content-type'] ?? '', 'charset'));
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
let bin = '';
|
|
84
|
-
for (const b of bytes)
|
|
85
|
-
bin += String.fromCharCode(b);
|
|
86
|
-
attachments.push({ filename: filename ? words(filename) : null, contentType: type, size: bytes.length, contentId: leaf.headers['content-id']?.replace(/^<|>$/g, '') ?? null, base64: btoa(bin) });
|
|
87
|
-
}
|
|
88
|
-
return { headers: top.headers, subject: words(top.headers.subject ?? ''), from: addresses(top.headers.from)[0] ?? '', to: addresses(top.headers.to), cc: addresses(top.headers.cc), text, html, attachments };
|
|
9
|
+
const { headers, text, html, attachments } = readMailMessage(data);
|
|
10
|
+
return {
|
|
11
|
+
headers, subject: mailWords(headers.subject ?? ''), from: addresses(headers.from)[0] ?? '', to: addresses(headers.to), cc: addresses(headers.cc), text, html,
|
|
12
|
+
attachments: attachments.map(({ filename, contentType, contentId, bytes }) => {
|
|
13
|
+
let bin = '';
|
|
14
|
+
for (const b of bytes)
|
|
15
|
+
bin += String.fromCharCode(b);
|
|
16
|
+
return { filename, contentType, size: bytes.length, contentId, base64: btoa(bin) };
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
89
19
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-gmail",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Gmail twin: gmail.com's receiving SMTP server for the World's Google accounts, and the mail.google.com webmail where the person reads their mail.",
|
|
5
5
|
"author": "Volter (https://github.com/volter-ai)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@volter/twin-googleoauth": "^1.1.2",
|
|
32
|
-
"@volter/world-core": "^3.0.
|
|
32
|
+
"@volter/world-core": "^3.0.190"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22.3"
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"volterRelease": {
|
|
52
|
-
"commit": "
|
|
52
|
+
"commit": "71c14672a241845f01d16a370c666e263af87f55"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/engine/mail.ts
CHANGED
|
@@ -1,76 +1,23 @@
|
|
|
1
1
|
// A message as RFC 5322 and MIME make of it (its headers, its text and HTML, its attachments), from the bytes Gmail's
|
|
2
|
-
// receiving server accepted
|
|
3
|
-
// `deliver`). The data arrives with its dot-stuffing
|
|
4
|
-
|
|
2
|
+
// receiving server accepted, read by the kernel's one mail reader (world-core readMailMessage): pure, so the server's
|
|
3
|
+
// front keeps it in each recipient's mailbox (semantics/shared.ts `deliver`). The data arrives with its dot-stuffing
|
|
4
|
+
// undone (../stream.ts); the World keeps the bytes as sent.
|
|
5
|
+
import { mailAddressList, mailWords, readMailMessage } from '@volter/world-core';
|
|
6
|
+
|
|
5
7
|
type Attachment = { filename: string | null; contentType: string; size: number; contentId: string | null; base64: string };
|
|
6
8
|
type Parsed = { headers: Record<string, string>; subject: string; from: string; to: string[]; cc: string[]; text: string | null; html: string | null; attachments: Attachment[] };
|
|
7
9
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
function split(raw: string): Part {
|
|
11
|
-
const at = raw.search(/\r?\n\r?\n/);
|
|
12
|
-
const head = at < 0 ? raw : raw.slice(0, at);
|
|
13
|
-
const body = at < 0 ? '' : raw.slice(at).replace(/^\r?\n\r?\n/, '');
|
|
14
|
-
const headers: Record<string, string> = {};
|
|
15
|
-
for (const line of head.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
|
|
16
|
-
const i = line.indexOf(':');
|
|
17
|
-
if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] ??= utf8(bytesOf(line.slice(i + 1).trim()));
|
|
18
|
-
}
|
|
19
|
-
return { headers, body };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const param = (value: string, name: string): string | undefined => {
|
|
23
|
-
const m = new RegExp(`;\\s*${name}\\*?=\\s*(?:"([^"]*)"|([^;\\s]+))`, 'i').exec(value);
|
|
24
|
-
const v = m?.[1] ?? m?.[2];
|
|
25
|
-
return v?.startsWith("utf-8''") || v?.startsWith("UTF-8''") ? decodeURIComponent(v.slice(7)) : v;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const bytesOf = (s: string): Uint8Array => Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff);
|
|
29
|
-
const utf8 = (b: Uint8Array): string => new TextDecoder().decode(b);
|
|
30
|
-
const textFor = (b: Uint8Array, charset: string | undefined): string => { try { return new TextDecoder(charset ?? 'utf-8').decode(b); } catch { return utf8(b); } };
|
|
31
|
-
|
|
32
|
-
/** A part's body decoded by its Content-Transfer-Encoding: base64 and quoted-printable to bytes (RFC 2045 §6). */
|
|
33
|
-
function decoded(part: Part): Uint8Array {
|
|
34
|
-
const enc = (part.headers['content-transfer-encoding'] ?? '7bit').toLowerCase();
|
|
35
|
-
if (enc === 'base64') { try { return bytesOf(atob(part.body.replace(/\s+/g, ''))); } catch { return bytesOf(part.body); } }
|
|
36
|
-
if (enc === 'quoted-printable') return bytesOf(part.body.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(Number.parseInt(h, 16))).split('').map((c) => (c.charCodeAt(0) > 0xff ? '?' : c)).join(''));
|
|
37
|
-
return bytesOf(part.body);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Encoded words in a header (`=?UTF-8?B?…?=`, `=?UTF-8?Q?…?=`), decoded (RFC 2047). */
|
|
41
|
-
// source: https://www.rfc-editor.org/rfc/rfc2047 "encoded-word = \"=?\" charset \"?\" encoding \"?\" encoded-text \"?=\""
|
|
42
|
-
function words(value: string): string {
|
|
43
|
-
return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=(\s+(?==\?))?/g, (_, _cs: string, e: string, t: string) => {
|
|
44
|
-
try { return textFor(e.toUpperCase() === 'B' ? bytesOf(atob(t)) : bytesOf(t.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_m, h: string) => String.fromCharCode(Number.parseInt(h, 16)))), _cs); } catch { return t; }
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const addresses = (v: string | undefined): string[] => (v ? words(v).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map((a) => (/<([^>]+)>/.exec(a)?.[1] ?? a).trim()).filter(Boolean) : []);
|
|
49
|
-
|
|
50
|
-
/** The message's parts: a multipart's each (recursively), else the one. */
|
|
51
|
-
function leaves(part: Part): Part[] {
|
|
52
|
-
const type = part.headers['content-type'] ?? 'text/plain';
|
|
53
|
-
const boundary = /^multipart\//i.test(type) ? param(type, 'boundary') : undefined;
|
|
54
|
-
if (!boundary) return [part];
|
|
55
|
-
const pieces = part.body.split(new RegExp(`\\r?\\n?--${boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?[ \\t]*\\r?\\n?`));
|
|
56
|
-
return pieces.slice(1, -1).filter((p) => p.trim()).flatMap((p) => leaves(split(p)));
|
|
57
|
-
}
|
|
10
|
+
/** An address field's mailboxes: each member's `<addr>`, or the member as written. */
|
|
11
|
+
const addresses = (v: string | undefined): string[] => mailAddressList(v).map((a) => /<([^>]+)>/.exec(a)?.[1] ?? a);
|
|
58
12
|
|
|
59
13
|
export function parse(data: string): Parsed {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!/^attachment/i.test(disposition) && !filename && type === 'text/plain' && text === null) { text = textFor(bytes, param(leaf.headers['content-type'] ?? '', 'charset')); continue; }
|
|
70
|
-
if (!/^attachment/i.test(disposition) && !filename && type === 'text/html' && html === null) { html = textFor(bytes, param(leaf.headers['content-type'] ?? '', 'charset')); continue; }
|
|
71
|
-
let bin = '';
|
|
72
|
-
for (const b of bytes) bin += String.fromCharCode(b);
|
|
73
|
-
attachments.push({ filename: filename ? words(filename) : null, contentType: type, size: bytes.length, contentId: leaf.headers['content-id']?.replace(/^<|>$/g, '') ?? null, base64: btoa(bin) });
|
|
74
|
-
}
|
|
75
|
-
return { headers: top.headers, subject: words(top.headers.subject ?? ''), from: addresses(top.headers.from)[0] ?? '', to: addresses(top.headers.to), cc: addresses(top.headers.cc), text, html, attachments };
|
|
14
|
+
const { headers, text, html, attachments } = readMailMessage(data);
|
|
15
|
+
return {
|
|
16
|
+
headers, subject: mailWords(headers.subject ?? ''), from: addresses(headers.from)[0] ?? '', to: addresses(headers.to), cc: addresses(headers.cc), text, html,
|
|
17
|
+
attachments: attachments.map(({ filename, contentType, contentId, bytes }) => {
|
|
18
|
+
let bin = '';
|
|
19
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
20
|
+
return { filename, contentType, size: bytes.length, contentId, base64: btoa(bin) };
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
76
23
|
}
|