1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
|
// Minimal RFC 5322/MIME parser for what neomutt hands to `sendmail`:
// RFC 2047 headers, (nested) multipart, quoted-printable/base64/8bit, charsets,
// RFC 2231 filenames and text/plain format=flowed (RFC 3676).
const decoderFor = (charset) => {
try {
return new TextDecoder((charset || 'utf-8').toLowerCase().replace(/^us-ascii$/, 'utf-8'));
} catch {
return new TextDecoder('utf-8');
}
};
export function decodeWords(s) {
// Whitespace between adjacent encoded-words does not count.
return s.replace(/(=\?[^?]+\?[BbQq]\?[^?]*\?=)\s+(?==\?)/g, '$1').replace(
/=\?([^?*]+)(?:\*[^?]*)?\?([BbQq])\?([^?]*)\?=/g,
(_, charset, enc, text) => {
const bytes = enc.toUpperCase() === 'B'
? Buffer.from(text, 'base64')
: Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return decoderFor(charset).decode(bytes);
},
);
}
function splitHeaderBody(buf) {
const text = buf.toString('latin1');
const m = /\r?\n\r?\n/.exec(text);
if (!m) return [text, Buffer.alloc(0)];
return [text.slice(0, m.index), buf.subarray(m.index + m[0].length)];
}
export function parseHeaders(raw) {
const headers = new Map();
for (const line of raw.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
const i = line.indexOf(':');
if (i <= 0) continue;
const name = line.slice(0, i).trim().toLowerCase();
// Raw headers arrive as latin1 (bytes); if they are 8-bit UTF-8, decode again.
const value = Buffer.from(line.slice(i + 1).trim(), 'latin1').toString('utf8');
if (!headers.has(name)) headers.set(name, []);
headers.get(name).push(value);
}
return headers;
}
// "text/plain; charset=utf-8; name*=utf-8''a%20b" -> { value, params }
export function parseParams(header = '') {
const [value, ...rest] = header.split(';');
const params = {};
const continuations = {};
for (const part of rest) {
const i = part.indexOf('=');
if (i < 0) continue;
let key = part.slice(0, i).trim().toLowerCase();
let val = part.slice(i + 1).trim().replace(/^"(.*)"$/s, '$1');
const cont = /^(.+?)\*(\d+)(\*)?$/.exec(key);
if (cont) {
(continuations[cont[1]] ||= []).push([Number(cont[2]), val, Boolean(cont[3])]);
continue;
}
if (key.endsWith('*')) {
key = key.slice(0, -1);
val = decode2231(val);
}
params[key] = decodeWords(val);
}
for (const [key, parts] of Object.entries(continuations)) {
parts.sort((a, b) => a[0] - b[0]);
const encoded = parts[0][2];
const joined = parts.map((p) => p[1]).join('');
params[key] = encoded ? decode2231(joined) : decodeWords(joined);
}
return { value: value.trim().toLowerCase(), params };
}
function decode2231(val) {
const m = /^([^']*)'[^']*'(.*)$/.exec(val);
if (!m) return val;
const bytes = Buffer.from(m[2].replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return decoderFor(m[1]).decode(bytes);
}
function decodeBody(buf, encoding = '') {
switch (encoding.toLowerCase()) {
case 'base64':
return Buffer.from(buf.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, ''), 'base64');
case 'quoted-printable': {
const s = buf.toString('latin1').replace(/=\r?\n/g, '');
return Buffer.from(s.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
}
default:
return buf;
}
}
// RFC 3676: lines ending in a space continue on the next one.
export function unflow(text, delsp = false) {
const out = [];
let cur = null;
for (let line of text.split(/\r?\n/)) {
if (line !== '-- ' && line.startsWith(' ')) line = line.slice(1); // space-stuffing
const soft = line.endsWith(' ') && line !== '-- ';
const piece = soft && delsp ? line.slice(0, -1) : line;
cur = cur === null ? piece : cur + piece;
if (!soft) {
out.push(cur);
cur = null;
}
}
if (cur !== null) out.push(cur);
return out.join('\n');
}
function parsePart(buf, parts) {
const [rawHeaders, body] = splitHeaderBody(buf);
const headers = parseHeaders(rawHeaders);
const get = (n) => headers.get(n)?.[0];
const ctype = parseParams(get('content-type') || 'text/plain; charset=us-ascii');
const disposition = parseParams(get('content-disposition') || '');
if (ctype.value.startsWith('multipart/') && ctype.params.boundary) {
const text = body.toString('latin1');
const delim = `--${ctype.params.boundary}`;
const chunks = text.split(new RegExp(`(?:^|\\r?\\n)${delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?[ \\t]*(?:\\r?\\n|$)`));
for (const chunk of chunks.slice(1, -1)) parsePart(Buffer.from(chunk, 'latin1'), parts);
// with no closing "--boundary--", the last chunk is a part too
const last = chunks[chunks.length - 1];
if (chunks.length > 1 && !new RegExp(`${delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}--`).test(text) && last.trim()) {
parsePart(Buffer.from(last, 'latin1'), parts);
}
return headers;
}
const data = decodeBody(body, get('content-transfer-encoding'));
const filename = disposition.params.filename || ctype.params.name || null;
const isAttachment = disposition.value === 'attachment' || (filename && !ctype.value.startsWith('text/'))
|| !ctype.value.startsWith('text/');
if (!isAttachment) {
let text = decoderFor(ctype.params.charset).decode(data);
if ((ctype.params.format || '').toLowerCase() === 'flowed') {
text = unflow(text, (ctype.params.delsp || '').toLowerCase() === 'yes');
}
parts.push({ kind: 'text', contentType: ctype.value, text });
} else {
parts.push({ kind: 'attachment', contentType: ctype.value, filename, data });
}
return headers;
}
// Devuelve { headers: Map, text, html, attachments: [{filename, contentType, data}] }
export function parseMessage(input) {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
const parts = [];
const headers = parsePart(buf, parts);
const texts = parts.filter((p) => p.kind === 'text');
return {
headers,
header: (name) => {
const v = headers.get(name.toLowerCase())?.[0];
return v === undefined ? undefined : decodeWords(v);
},
text: texts.find((p) => p.contentType === 'text/plain')?.text ?? null,
html: texts.find((p) => p.contentType === 'text/html')?.text ?? null,
attachments: parts.filter((p) => p.kind === 'attachment'),
};
}
// "Juan <5491@c.us>, 5492@c.us" -> ['5491@c.us', '5492@c.us']
export function addresses(value = '') {
const out = [];
// drop comments and quoted names before splitting on commas
const cleaned = value.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/\([^)]*\)/g, '');
for (const part of cleaned.split(',')) {
const angle = /<([^>]+)>/.exec(part);
const addr
|