mailfile 0.1.0

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/mailfile.cjs +2035 -0
  4. package/dist/mailfile.js +2037 -0
  5. package/dist/mailfile.min.js +30 -0
  6. package/package.json +84 -0
  7. package/src/cfb/index.js +4 -0
  8. package/src/cfb/read.js +221 -0
  9. package/src/cfb/write.js +262 -0
  10. package/src/errors.js +49 -0
  11. package/src/index.js +86 -0
  12. package/src/mapi/bag.js +195 -0
  13. package/src/mapi/index.js +6 -0
  14. package/src/mapi/tags.js +147 -0
  15. package/src/message.js +199 -0
  16. package/src/mime/build.js +100 -0
  17. package/src/mime/encodings.js +188 -0
  18. package/src/mime/headers.js +344 -0
  19. package/src/mime/index.js +12 -0
  20. package/src/mime/parse.js +153 -0
  21. package/src/msg/read.js +178 -0
  22. package/src/msg/write.js +124 -0
  23. package/src/rtf/deencapsulate.js +155 -0
  24. package/src/rtf/index.js +3 -0
  25. package/src/rtf/lzfu.js +67 -0
  26. package/src/util.js +59 -0
  27. package/types/cfb/index.d.ts +2 -0
  28. package/types/cfb/read.d.ts +70 -0
  29. package/types/cfb/write.d.ts +67 -0
  30. package/types/errors.d.ts +36 -0
  31. package/types/index.d.ts +73 -0
  32. package/types/mapi/bag.d.ts +77 -0
  33. package/types/mapi/index.d.ts +2 -0
  34. package/types/mapi/tags.d.ts +137 -0
  35. package/types/message.d.ts +94 -0
  36. package/types/mime/build.d.ts +27 -0
  37. package/types/mime/encodings.d.ts +52 -0
  38. package/types/mime/headers.d.ts +111 -0
  39. package/types/mime/index.d.ts +4 -0
  40. package/types/mime/parse.d.ts +43 -0
  41. package/types/msg/read.d.ts +26 -0
  42. package/types/msg/write.d.ts +6 -0
  43. package/types/rtf/deencapsulate.d.ts +23 -0
  44. package/types/rtf/index.d.ts +2 -0
  45. package/types/rtf/lzfu.d.ts +8 -0
  46. package/types/util.d.ts +25 -0
@@ -0,0 +1,344 @@
1
+ /** Header field parsing and generation: RFC 2047 words, RFC 2231 parameters,
2
+ * address lists, folding, and the ordered Headers multimap. */
3
+ import {
4
+ base64ToBytes, bytesToBase64, decodeBytes, decodeQP, utf8Encode
5
+ } from './encodings.js';
6
+
7
+ /**
8
+ * @typedef {object} Address
9
+ * @property {string} name Display name, already decoded.
10
+ * @property {string} email
11
+ */
12
+
13
+ /**
14
+ * Decode RFC 2047 encoded words (`=?UTF-8?B?...?=`) anywhere in a string.
15
+ * @param {string} str
16
+ * @returns {string}
17
+ */
18
+ export function decodeWords(str) {
19
+ if (!str || str.indexOf('=?') < 0) return str || '';
20
+ // Whitespace between adjacent encoded words is not significant, including
21
+ // the folding whitespace a long run of words is broken across.
22
+ const joined = str.replace(/(\?=)(?:\r?\n)?[ \t]+(=\?)/g, '$1$2');
23
+ return joined.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (m, cs, enc, text) => {
24
+ try {
25
+ const raw = enc.toUpperCase() === 'B' ? base64ToBytes(text) : decodeQP(text, true);
26
+ return decodeBytes(raw, cs);
27
+ } catch {
28
+ return m;
29
+ }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * @param {string} s
35
+ * @returns {boolean}
36
+ */
37
+ export function needsEncoding(s) {
38
+ return /[^\x20-\x7E]/.test(s);
39
+ }
40
+
41
+ /**
42
+ * Encode a string as one or more RFC 2047 words, if it needs it.
43
+ * @param {string} s
44
+ * @returns {string}
45
+ */
46
+ export function encodeWord(s) {
47
+ if (!needsEncoding(s)) return s;
48
+ // Chunk on code points so a multi-byte character never straddles two words.
49
+ const parts = [];
50
+ let cur = '';
51
+ let curBytes = 0;
52
+ const chars = Array.from(s);
53
+ for (const ch of chars) {
54
+ const n = utf8Encode(ch).length;
55
+ if (curBytes + n > 36) { parts.push(cur); cur = ''; curBytes = 0; }
56
+ cur += ch;
57
+ curBytes += n;
58
+ }
59
+ if (cur) parts.push(cur);
60
+ return parts.map((p) => '=?UTF-8?B?' + bytesToBase64(utf8Encode(p)) + '?=').join('\r\n ');
61
+ }
62
+
63
+ /**
64
+ * Fold a long header onto continuation lines.
65
+ * @param {string} name
66
+ * @param {string} value
67
+ * @returns {string}
68
+ */
69
+ export function foldHeader(name, value) {
70
+ const line = name + ': ' + value;
71
+ if (line.length <= 78 || /\r\n/.test(line)) return line;
72
+ let out = '';
73
+ let cur = name + ':';
74
+ const tokens = value.split(' ');
75
+ for (const tok of tokens) {
76
+ if (cur.length + 1 + tok.length > 78 && cur !== name + ':') {
77
+ out += cur + '\r\n';
78
+ cur = ' ' + tok;
79
+ } else {
80
+ cur += (cur === ' ' ? '' : ' ') + tok;
81
+ }
82
+ }
83
+ return out + cur;
84
+ }
85
+
86
+ /**
87
+ * @param {string} name
88
+ * @param {string} email
89
+ * @returns {string}
90
+ */
91
+ export function formatAddress(name, email) {
92
+ email = (email || '').trim();
93
+ name = (name || '').trim();
94
+ if (!email) return name ? encodeWord(name) : '';
95
+ if (!name || name.toLowerCase() === email.toLowerCase()) return email;
96
+ if (needsEncoding(name)) return encodeWord(name) + ' <' + email + '>';
97
+ if (/[()<>@,;:\\".\[\]]/.test(name)) {
98
+ return '"' + name.replace(/(["\\])/g, '\\$1') + '" <' + email + '>';
99
+ }
100
+ return name + ' <' + email + '>';
101
+ }
102
+
103
+ // Split on commas that are not inside quotes, angle brackets or comments.
104
+ function splitAddressList(str) {
105
+ const out = [];
106
+ let cur = '';
107
+ let inQuote = false;
108
+ let depth = 0;
109
+ for (let i = 0; i < str.length; i++) {
110
+ const c = str[i];
111
+ if (c === '"' && str[i - 1] !== '\\') inQuote = !inQuote;
112
+ if (!inQuote && (c === '<' || c === '(')) depth++;
113
+ if (!inQuote && (c === '>' || c === ')')) depth--;
114
+ if (c === ',' && !inQuote && depth <= 0) { out.push(cur); cur = ''; continue; }
115
+ cur += c;
116
+ }
117
+ if (cur.trim()) out.push(cur);
118
+ return out.map((s) => s.trim()).filter(Boolean);
119
+ }
120
+
121
+ /**
122
+ * @param {string} str
123
+ * @returns {Address}
124
+ */
125
+ export function parseAddress(str) {
126
+ const s = decodeWords(String(str).trim());
127
+ const m = s.match(/^(.*)<([^>]*)>\s*$/);
128
+ if (m) {
129
+ const name = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(["\\])/g, '$1');
130
+ return { name, email: m[2].trim() };
131
+ }
132
+ return { name: '', email: s.replace(/^<|>$/g, '').trim() };
133
+ }
134
+
135
+ /**
136
+ * @param {string} str
137
+ * @returns {Address[]}
138
+ */
139
+ export function parseAddressList(str) {
140
+ if (!str) return [];
141
+ return splitAddressList(String(str)).map(parseAddress).filter((a) => a.email || a.name);
142
+ }
143
+
144
+ /**
145
+ * Parse a structured header into its value and parameters, reassembling
146
+ * RFC 2231 continuations and percent-encoding (`filename*0*=`, `filename*=`).
147
+ * @param {string} raw
148
+ * @returns {{value: string, params: Record<string, string>}}
149
+ */
150
+ export function parseHeaderValue(raw) {
151
+ let value = '';
152
+ /** @type {Record<string, string>} */
153
+ const params = {};
154
+ let i = 0;
155
+ const str = String(raw || '');
156
+ while (i < str.length && str[i] !== ';') { value += str[i]; i++; }
157
+ const rest = str.slice(i + 1);
158
+ const re = /\s*([^=\s;]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;]*)\s*(?:;|$)/g;
159
+ const raws = {};
160
+ let m;
161
+ while ((m = re.exec(rest))) {
162
+ if (!m[1]) break;
163
+ let v = m[2].trim();
164
+ if (v[0] === '"') v = v.slice(1, -1).replace(/\\(.)/g, '$1');
165
+ raws[m[1].toLowerCase()] = v;
166
+ }
167
+
168
+ const pieces = {};
169
+ for (const k of Object.keys(raws)) {
170
+ const mm = k.match(/^([^*]+)(?:\*(\d+))?(\*)?$/);
171
+ if (!mm) { params[k] = raws[k]; continue; }
172
+ const base = mm[1];
173
+ const idx = mm[2] === undefined ? -1 : parseInt(mm[2], 10);
174
+ const ext = !!mm[3];
175
+ if (idx < 0 && !ext) {
176
+ if (!(base in pieces)) params[base] = raws[k];
177
+ continue;
178
+ }
179
+ pieces[base] = pieces[base] || [];
180
+ pieces[base].push({ idx: idx < 0 ? 0 : idx, ext, v: raws[k] });
181
+ }
182
+
183
+ for (const base of Object.keys(pieces)) {
184
+ const list = pieces[base].sort((a, b) => a.idx - b.idx);
185
+ let charset = null;
186
+ let acc = '';
187
+ list.forEach((p, n) => {
188
+ let v = p.v;
189
+ if (p.ext) {
190
+ if (n === 0 && v.indexOf("'") >= 0) {
191
+ const bits = v.split("'");
192
+ charset = bits[0];
193
+ v = bits.slice(2).join("'");
194
+ }
195
+ acc += v;
196
+ } else {
197
+ acc += v;
198
+ }
199
+ });
200
+ if (charset || /%[0-9A-Fa-f]{2}/.test(acc)) {
201
+ const bytes = [];
202
+ for (let j = 0; j < acc.length; j++) {
203
+ if (acc[j] === '%' && /^[0-9A-Fa-f]{2}$/.test(acc.substr(j + 1, 2))) {
204
+ bytes.push(parseInt(acc.substr(j + 1, 2), 16));
205
+ j += 2;
206
+ } else {
207
+ bytes.push(acc.charCodeAt(j) & 0xFF);
208
+ }
209
+ }
210
+ params[base] = decodeBytes(new Uint8Array(bytes), charset || 'utf-8');
211
+ } else {
212
+ params[base] = decodeWords(acc);
213
+ }
214
+ }
215
+ return { value: value.trim(), params };
216
+ }
217
+
218
+ /**
219
+ * Emit a parameter, using RFC 2231 with an ASCII fallback when non-ASCII.
220
+ * @param {string} name
221
+ * @param {string} val
222
+ * @returns {string}
223
+ */
224
+ export function encodeParamValue(name, val) {
225
+ if (!needsEncoding(val) && !/[";\\]/.test(val)) return name + '="' + val + '"';
226
+ if (!needsEncoding(val)) return name + '="' + val.replace(/(["\\])/g, '\\$1') + '"';
227
+ const bytes = utf8Encode(val);
228
+ let enc = '';
229
+ for (const b of bytes) {
230
+ if ((b >= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122) ||
231
+ b === 45 || b === 46 || b === 95) {
232
+ enc += String.fromCharCode(b);
233
+ } else {
234
+ enc += '%' + ('0' + b.toString(16).toUpperCase()).slice(-2);
235
+ }
236
+ }
237
+ const ascii = val.replace(/[^\x20-\x7E]/g, '_').replace(/(["\\])/g, '');
238
+ return name + '="' + ascii + '"; ' + name + "*=UTF-8''" + enc;
239
+ }
240
+
241
+ /**
242
+ * An ordered, case-insensitive, repeat-preserving header collection.
243
+ */
244
+ export class Headers {
245
+ /** @param {Array<[string, string]>} [entries] */
246
+ constructor(entries) {
247
+ /** @type {Array<[string, string]>} */
248
+ this.entries = entries ? entries.slice() : [];
249
+ }
250
+
251
+ /**
252
+ * Parse a raw header block. Continuation lines are unfolded.
253
+ * @param {string} text
254
+ * @returns {Headers}
255
+ */
256
+ static parse(text) {
257
+ const unfolded = String(text || '').replace(/\r?\n[ \t]+/g, ' ');
258
+ /** @type {Array<[string, string]>} */
259
+ const entries = [];
260
+ for (const line of unfolded.split(/\r?\n/)) {
261
+ if (!line.trim()) continue;
262
+ const idx = line.indexOf(':');
263
+ if (idx < 0) continue;
264
+ entries.push([line.slice(0, idx).trim(), line.slice(idx + 1).trim()]);
265
+ }
266
+ return new Headers(entries);
267
+ }
268
+
269
+ /** @param {string} name @returns {string|null} First matching value. */
270
+ get(name) {
271
+ const lower = name.toLowerCase();
272
+ for (const [k, v] of this.entries) if (k.toLowerCase() === lower) return v;
273
+ return null;
274
+ }
275
+
276
+ /** @param {string} name @returns {string[]} Every matching value, in order. */
277
+ getAll(name) {
278
+ const lower = name.toLowerCase();
279
+ return this.entries.filter(([k]) => k.toLowerCase() === lower).map(([, v]) => v);
280
+ }
281
+
282
+ /** @param {string} name @returns {boolean} */
283
+ has(name) {
284
+ return this.get(name) !== null;
285
+ }
286
+
287
+ /** Replace all occurrences with a single value. @param {string} name @param {string} value */
288
+ set(name, value) {
289
+ this.delete(name);
290
+ this.entries.push([name, value]);
291
+ return this;
292
+ }
293
+
294
+ /** Append without removing existing occurrences. @param {string} name @param {string} value */
295
+ add(name, value) {
296
+ this.entries.push([name, value]);
297
+ return this;
298
+ }
299
+
300
+ /** @param {string} name */
301
+ delete(name) {
302
+ const lower = name.toLowerCase();
303
+ this.entries = this.entries.filter(([k]) => k.toLowerCase() !== lower);
304
+ return this;
305
+ }
306
+
307
+ /** Header block as text, with folding applied. @returns {string} */
308
+ get raw() {
309
+ return this.entries.map(([k, v]) => foldHeader(k, v)).join('\r\n');
310
+ }
311
+
312
+ [Symbol.iterator]() {
313
+ return this.entries[Symbol.iterator]();
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Format a Date as an RFC 5322 date-time in local time.
319
+ * @param {Date} d
320
+ * @returns {string}
321
+ */
322
+ export function formatDate(d) {
323
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
324
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
325
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
326
+ const p = (n) => (n < 10 ? '0' : '') + n;
327
+ let tz = -d.getTimezoneOffset();
328
+ const sign = tz >= 0 ? '+' : '-';
329
+ tz = Math.abs(tz);
330
+ return days[d.getDay()] + ', ' + d.getDate() + ' ' + months[d.getMonth()] + ' ' +
331
+ d.getFullYear() + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' +
332
+ p(d.getSeconds()) + ' ' + sign + p(Math.floor(tz / 60)) + p(tz % 60);
333
+ }
334
+
335
+ /**
336
+ * Parse an RFC 5322 date, tolerating a trailing `(GMT)`-style comment.
337
+ * @param {string} str
338
+ * @returns {Date|null}
339
+ */
340
+ export function parseDate(str) {
341
+ if (!str) return null;
342
+ const d = new Date(String(str).replace(/\s*\([^)]*\)\s*$/, ''));
343
+ return isNaN(d.getTime()) ? null : d;
344
+ }
@@ -0,0 +1,12 @@
1
+ /** RFC 5322 / MIME parsing and generation. */
2
+ export { parseEml, parseNode } from './parse.js';
3
+ export { buildEml } from './build.js';
4
+ export {
5
+ Headers, decodeWords, encodeWord, foldHeader, formatAddress, formatDate,
6
+ parseAddress, parseAddressList, parseDate, parseHeaderValue, encodeParamValue,
7
+ needsEncoding
8
+ } from './headers.js';
9
+ export {
10
+ base64ToBytes, bytesToBase64, decodeBytes, decodeQP, encodeQP,
11
+ utf8Encode, utf16Decode, utf16Encode, wrap
12
+ } from './encodings.js';
@@ -0,0 +1,153 @@
1
+ /** MIME document parsing. */
2
+ import { base64ToBytes, decodeBytes, decodeQP, utf8Encode } from './encodings.js';
3
+ import { Headers, decodeWords, parseHeaderValue } from './headers.js';
4
+
5
+ /**
6
+ * @typedef {object} MimeNode
7
+ * @property {Headers} headers
8
+ * @property {string} contentType Lower-cased, without parameters.
9
+ * @property {Record<string,string>} params
10
+ * @property {MimeNode[]} children
11
+ * @property {Uint8Array} [content] Decoded body, for leaf nodes only.
12
+ */
13
+
14
+ /**
15
+ * @typedef {object} ParsedEml
16
+ * @property {Headers} headers Top-level headers.
17
+ * @property {string} text
18
+ * @property {string} html
19
+ * @property {Array<{filename: string, mime: string, data: Uint8Array, cid: string, inline: boolean}>} attachments
20
+ * @property {MimeNode} root
21
+ */
22
+
23
+ function findHeaderEnd(bytes) {
24
+ for (let i = 0; i + 1 < bytes.length; i++) {
25
+ if (bytes[i] === 10 && bytes[i + 1] === 10) return { end: i, bodyStart: i + 2 };
26
+ if (bytes[i] === 13 && bytes[i + 1] === 10 &&
27
+ bytes[i + 2] === 13 && bytes[i + 3] === 10) {
28
+ return { end: i, bodyStart: i + 4 };
29
+ }
30
+ }
31
+ return { end: bytes.length, bodyStart: bytes.length };
32
+ }
33
+
34
+ function splitOnBoundary(bytes, boundary) {
35
+ const marker = utf8Encode('--' + boundary);
36
+ const parts = [];
37
+ const starts = [];
38
+ for (let i = 0; i <= bytes.length - marker.length; i++) {
39
+ if (bytes[i] !== 45 || bytes[i + 1] !== 45) continue;
40
+ if (i > 0 && bytes[i - 1] !== 10) continue;
41
+ let ok = true;
42
+ for (let j = 0; j < marker.length; j++) {
43
+ if (bytes[i + j] !== marker[j]) { ok = false; break; }
44
+ }
45
+ if (!ok) continue;
46
+ const after = i + marker.length;
47
+ const isEnd = bytes[after] === 45 && bytes[after + 1] === 45;
48
+ // A delimiter must be followed by optional whitespace then CRLF (or EOF).
49
+ let k = after + (isEnd ? 2 : 0);
50
+ while (bytes[k] === 32 || bytes[k] === 9) k++;
51
+ if (!(bytes[k] === 13 || bytes[k] === 10 || k >= bytes.length)) continue;
52
+ let contentStart = k;
53
+ if (bytes[contentStart] === 13) contentStart++;
54
+ if (bytes[contentStart] === 10) contentStart++;
55
+ starts.push({ at: i, contentStart, isEnd });
56
+ if (isEnd) break;
57
+ }
58
+ for (let s = 0; s < starts.length - 1; s++) {
59
+ const from = starts[s].contentStart;
60
+ let to = starts[s + 1].at;
61
+ if (to > from && bytes[to - 1] === 10) to--;
62
+ if (to > from && bytes[to - 1] === 13) to--;
63
+ parts.push(bytes.subarray(from, Math.max(from, to)));
64
+ }
65
+ return parts;
66
+ }
67
+
68
+ function decodePart(bytes, encoding) {
69
+ const enc = (encoding || '7bit').trim().toLowerCase();
70
+ if (enc === 'base64') return base64ToBytes(decodeBytes(bytes, 'utf-8'));
71
+ if (enc === 'quoted-printable') {
72
+ let s = '';
73
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
74
+ return decodeQP(s, false);
75
+ }
76
+ return bytes;
77
+ }
78
+
79
+ /**
80
+ * Parse a MIME entity and its children.
81
+ * @param {Uint8Array} bytes
82
+ * @param {number} [depth]
83
+ * @returns {MimeNode}
84
+ */
85
+ export function parseNode(bytes, depth = 0) {
86
+ const split = findHeaderEnd(bytes);
87
+ const headers = Headers.parse(decodeBytes(bytes.subarray(0, split.end), 'utf-8'));
88
+ const body = bytes.subarray(split.bodyStart);
89
+ const ct = parseHeaderValue(headers.get('content-type') || 'text/plain');
90
+ /** @type {MimeNode} */
91
+ const node = {
92
+ headers,
93
+ contentType: (ct.value || 'text/plain').toLowerCase(),
94
+ params: ct.params,
95
+ children: []
96
+ };
97
+ if (node.contentType.startsWith('multipart/') && ct.params.boundary && depth < 20) {
98
+ for (const kid of splitOnBoundary(body, ct.params.boundary)) {
99
+ node.children.push(parseNode(kid, depth + 1));
100
+ }
101
+ } else {
102
+ node.content = decodePart(body, headers.get('content-transfer-encoding'));
103
+ }
104
+ return node;
105
+ }
106
+
107
+ const EXT_BY_MIME = {
108
+ 'text/plain': 'txt', 'text/html': 'html', 'image/png': 'png', 'image/jpeg': 'jpg',
109
+ 'image/gif': 'gif', 'application/pdf': 'pdf', 'message/rfc822': 'eml'
110
+ };
111
+
112
+ function guessName(mime, n) {
113
+ return 'attachment-' + (n + 1) + '.' + (EXT_BY_MIME[mime] || 'bin');
114
+ }
115
+
116
+ function collectParts(node, out, parentType) {
117
+ if (node.children.length) {
118
+ for (const c of node.children) collectParts(c, out, node.contentType);
119
+ return;
120
+ }
121
+ const disp = parseHeaderValue(node.headers.get('content-disposition') || '');
122
+ const dispType = (disp.value || '').toLowerCase();
123
+ const filename = disp.params.filename || node.params.name || '';
124
+ const cid = (node.headers.get('content-id') || '').replace(/^<|>$/g, '').trim();
125
+ const isText = node.contentType === 'text/plain' || node.contentType === 'text/html';
126
+ const isBody = isText && dispType !== 'attachment' && !filename;
127
+
128
+ if (isBody) {
129
+ const text = decodeBytes(node.content, node.params.charset || 'utf-8');
130
+ if (node.contentType === 'text/html') out.html = out.html || text;
131
+ else out.text = out.text || text;
132
+ return;
133
+ }
134
+ out.attachments.push({
135
+ filename: decodeWords(filename) || guessName(node.contentType, out.attachments.length),
136
+ mime: node.contentType,
137
+ data: node.content || new Uint8Array(0),
138
+ cid,
139
+ inline: dispType === 'inline' || (!!cid && parentType === 'multipart/related')
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Parse a complete `.eml` document.
145
+ * @param {Uint8Array} bytes
146
+ * @returns {ParsedEml}
147
+ */
148
+ export function parseEml(bytes) {
149
+ const root = parseNode(bytes, 0);
150
+ const out = { text: '', html: '', attachments: [], headers: root.headers, root };
151
+ collectParts(root, out, root.contentType);
152
+ return out;
153
+ }
@@ -0,0 +1,178 @@
1
+ /** Compound file + MAPI properties -> message data. */
2
+ import { read as readCfb } from '../cfb/read.js';
3
+ import { PropertyBag } from '../mapi/bag.js';
4
+ import {
5
+ AttachMethod, HeaderSize, PropId, PropType, RecipientType, hex4, tagName
6
+ } from '../mapi/tags.js';
7
+ import { decodeBytes, utf16Decode } from '../mime/encodings.js';
8
+ import { Headers, parseAddressList } from '../mime/headers.js';
9
+ import { decompress } from '../rtf/lzfu.js';
10
+ import { rtfToHtml } from '../rtf/deencapsulate.js';
11
+ import { guessMime, safeName } from '../util.js';
12
+
13
+ function sniffHtmlCharset(bytes, fallback) {
14
+ let head = '';
15
+ for (let i = 0; i < Math.min(bytes.length, 2048); i++) head += String.fromCharCode(bytes[i]);
16
+ const m = /charset\s*=\s*["']?\s*([\w-]+)/i.exec(head);
17
+ return m ? m[1] : fallback;
18
+ }
19
+
20
+ /**
21
+ * Resolve a display name / address pair, preferring a real SMTP address over
22
+ * an Exchange (`/O=…/CN=…`) one.
23
+ */
24
+ function pickAddress(bag, nameId, emailId, addrTypeId, smtpId) {
25
+ const name = bag.str(nameId);
26
+ let email = bag.str(smtpId);
27
+ if (!email) {
28
+ const at = (bag.str(addrTypeId) || '').toUpperCase();
29
+ const raw = bag.str(emailId);
30
+ if (at === 'SMTP' || (raw && raw.indexOf('@') > 0 && raw.indexOf('/') < 0)) email = raw;
31
+ }
32
+ return { name, email };
33
+ }
34
+
35
+ /**
36
+ * @param {import('../cfb/read.js').CfbFile} cfb
37
+ * @param {object} storage
38
+ * @param {number} headerSize
39
+ * @param {{lazy?: boolean}} opts
40
+ * @param {number} depth
41
+ */
42
+ function readMessageStorage(cfb, storage, headerSize, opts, depth) {
43
+ const bag = new PropertyBag(cfb, storage, headerSize);
44
+
45
+ const data = {
46
+ messageClass: bag.str(PropId.MESSAGE_CLASS),
47
+ subject: bag.str(PropId.SUBJECT) || bag.str(PropId.NORMALIZED_SUBJECT),
48
+ messageId: bag.str(PropId.INTERNET_MESSAGE_ID),
49
+ inReplyTo: bag.str(PropId.IN_REPLY_TO_ID),
50
+ references: bag.str(PropId.INTERNET_REFERENCES),
51
+ date: bag.date(PropId.CLIENT_SUBMIT_TIME) || bag.date(PropId.DELIVERY_TIME) ||
52
+ bag.date(PropId.LAST_MODIFICATION_TIME) || null,
53
+ importance: bag.int(PropId.IMPORTANCE),
54
+ text: bag.str(PropId.BODY),
55
+ html: '',
56
+ to: [],
57
+ cc: [],
58
+ bcc: [],
59
+ attachments: [],
60
+ headers: Headers.parse(bag.str(PropId.TRANSPORT_HEADERS)),
61
+ props: bag
62
+ };
63
+
64
+ // From: the "sent representing" identity is what a client displays.
65
+ let from = pickAddress(bag, PropId.SENT_REP_NAME, PropId.SENT_REP_EMAIL,
66
+ PropId.SENT_REP_ADDRTYPE, PropId.SENT_REP_SMTP);
67
+ if (!from.email && !from.name) {
68
+ from = pickAddress(bag, PropId.SENDER_NAME, PropId.SENDER_EMAIL,
69
+ PropId.SENDER_ADDRTYPE, PropId.SENDER_SMTP);
70
+ } else if (!from.email) {
71
+ const s = pickAddress(bag, PropId.SENDER_NAME, PropId.SENDER_EMAIL,
72
+ PropId.SENDER_ADDRTYPE, PropId.SENDER_SMTP);
73
+ if (s.email) from.email = s.email;
74
+ }
75
+ data.from = from;
76
+
77
+ // HTML body: PR_HTML, then its Unicode variant, then encapsulated RTF.
78
+ const htmlBin = bag.bin(PropId.HTML);
79
+ if (htmlBin && htmlBin.length) {
80
+ data.html = decodeBytes(htmlBin, sniffHtmlCharset(htmlBin, bag.charset));
81
+ } else if (bag.vars[hex4(PropId.HTML) + '001F']) {
82
+ data.html = utf16Decode(bag.vars[hex4(PropId.HTML) + '001F']);
83
+ }
84
+ if (!data.html) {
85
+ const comp = bag.bin(PropId.RTF_COMPRESSED);
86
+ if (comp) {
87
+ const rtfBytes = decompress(comp);
88
+ if (rtfBytes) {
89
+ const cs = bag.charset;
90
+ const html = rtfToHtml(rtfBytes, (b) => decodeBytes(b, cs));
91
+ if (html) data.html = html;
92
+ }
93
+ }
94
+ }
95
+
96
+ for (const r of bag.storagesMatching(/^__recip_version1\.0_/)) {
97
+ const rb = new PropertyBag(cfb, r, HeaderSize.RECIPIENT);
98
+ const addr = pickAddress(rb, PropId.DISPLAY_NAME, PropId.EMAIL_ADDRESS,
99
+ PropId.ADDRTYPE, PropId.SMTP_ADDRESS);
100
+ if (!addr.email && !addr.name) continue;
101
+ const type = rb.int(PropId.RECIPIENT_TYPE);
102
+ if (type === RecipientType.CC) data.cc.push(addr);
103
+ else if (type === RecipientType.BCC) data.bcc.push(addr);
104
+ else data.to.push(addr);
105
+ }
106
+ // Some senders write only the display strings, with no recipient table.
107
+ if (!data.to.length && !data.cc.length) {
108
+ data.to = parseAddressList(bag.str(PropId.DISPLAY_TO));
109
+ data.cc = parseAddressList(bag.str(PropId.DISPLAY_CC));
110
+ }
111
+
112
+ for (const a of bag.storagesMatching(/^__attach_version1\.0_/)) {
113
+ const ab = new PropertyBag(cfb, a, HeaderSize.ATTACHMENT);
114
+ const method = ab.int(PropId.ATTACH_METHOD);
115
+ const filename = ab.str(PropId.ATTACH_LONG_FILENAME) || ab.str(PropId.ATTACH_FILENAME);
116
+ const cid = ab.str(PropId.ATTACH_CONTENT_ID);
117
+ const mime = ab.str(PropId.ATTACH_MIME_TAG);
118
+ const flags = ab.int(PropId.ATTACH_FLAGS) || 0;
119
+ const hidden = ab.bool(PropId.ATTACHMENT_HIDDEN);
120
+
121
+ if (method === AttachMethod.EMBEDDED_MSG) {
122
+ const embedded = ab.childMap[tagName(PropId.ATTACH_DATA_BIN, PropType.OBJECT)];
123
+ if (embedded && depth < 10) {
124
+ try {
125
+ const inner = readMessageStorage(cfb, embedded, HeaderSize.EMBEDDED, opts, depth + 1);
126
+ data.attachments.push({
127
+ filename: safeName(filename || inner.subject || 'message') + '.eml',
128
+ mime: 'message/rfc822',
129
+ embedded: inner,
130
+ cid,
131
+ inline: false
132
+ });
133
+ } catch { /* an unreadable embedded message should not fail the parent */ }
134
+ }
135
+ continue;
136
+ }
137
+
138
+ const name = filename ||
139
+ ('attachment' + (data.attachments.length + 1) + (ab.str(PropId.ATTACH_EXTENSION) || '.bin'));
140
+ const att = {
141
+ filename: name,
142
+ mime: mime || guessMime(name),
143
+ cid,
144
+ inline: !!cid && ((flags & 0x4) !== 0 || hidden === true ||
145
+ (!!data.html && data.html.indexOf('cid:' + cid) >= 0))
146
+ };
147
+ if (opts.lazy) {
148
+ // Defer walking the sector chain until the bytes are actually wanted.
149
+ let cached = null;
150
+ Object.defineProperty(att, 'data', {
151
+ enumerable: true,
152
+ configurable: true,
153
+ get() {
154
+ if (!cached) cached = ab.bin(PropId.ATTACH_DATA_BIN) || new Uint8Array(0);
155
+ return cached;
156
+ }
157
+ });
158
+ if (!ab.has(PropId.ATTACH_DATA_BIN)) continue;
159
+ } else {
160
+ const bin = ab.bin(PropId.ATTACH_DATA_BIN);
161
+ if (!bin) continue;
162
+ att.data = bin;
163
+ }
164
+ data.attachments.push(att);
165
+ }
166
+
167
+ return data;
168
+ }
169
+
170
+ /**
171
+ * Read a `.msg` file into plain message data.
172
+ * @param {Uint8Array} bytes
173
+ * @param {{lazy?: boolean}} [opts]
174
+ */
175
+ export function readMsg(bytes, opts = {}) {
176
+ const cfb = readCfb(bytes);
177
+ return readMessageStorage(cfb, cfb.root, HeaderSize.TOP_LEVEL, opts, 0);
178
+ }