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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/mailfile.cjs +2035 -0
- package/dist/mailfile.js +2037 -0
- package/dist/mailfile.min.js +30 -0
- package/package.json +84 -0
- package/src/cfb/index.js +4 -0
- package/src/cfb/read.js +221 -0
- package/src/cfb/write.js +262 -0
- package/src/errors.js +49 -0
- package/src/index.js +86 -0
- package/src/mapi/bag.js +195 -0
- package/src/mapi/index.js +6 -0
- package/src/mapi/tags.js +147 -0
- package/src/message.js +199 -0
- package/src/mime/build.js +100 -0
- package/src/mime/encodings.js +188 -0
- package/src/mime/headers.js +344 -0
- package/src/mime/index.js +12 -0
- package/src/mime/parse.js +153 -0
- package/src/msg/read.js +178 -0
- package/src/msg/write.js +124 -0
- package/src/rtf/deencapsulate.js +155 -0
- package/src/rtf/index.js +3 -0
- package/src/rtf/lzfu.js +67 -0
- package/src/util.js +59 -0
- package/types/cfb/index.d.ts +2 -0
- package/types/cfb/read.d.ts +70 -0
- package/types/cfb/write.d.ts +67 -0
- package/types/errors.d.ts +36 -0
- package/types/index.d.ts +73 -0
- package/types/mapi/bag.d.ts +77 -0
- package/types/mapi/index.d.ts +2 -0
- package/types/mapi/tags.d.ts +137 -0
- package/types/message.d.ts +94 -0
- package/types/mime/build.d.ts +27 -0
- package/types/mime/encodings.d.ts +52 -0
- package/types/mime/headers.d.ts +111 -0
- package/types/mime/index.d.ts +4 -0
- package/types/mime/parse.d.ts +43 -0
- package/types/msg/read.d.ts +26 -0
- package/types/msg/write.d.ts +6 -0
- package/types/rtf/deencapsulate.d.ts +23 -0
- package/types/rtf/index.d.ts +2 -0
- package/types/rtf/lzfu.d.ts +8 -0
- package/types/util.d.ts +25 -0
package/src/msg/write.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Message data -> compound file + MAPI properties. */
|
|
2
|
+
import { addStorage, addStream, newRoot, write as writeCfb } from '../cfb/write.js';
|
|
3
|
+
import { PropertyWriter } from '../mapi/bag.js';
|
|
4
|
+
import { HeaderSize, MSG_CLSID, PropId, RecipientType, hex8 } from '../mapi/tags.js';
|
|
5
|
+
import { utf8Encode } from '../mime/encodings.js';
|
|
6
|
+
import { guessMime, shortName } from '../util.js';
|
|
7
|
+
|
|
8
|
+
const MSGFLAG_READ = 0x01;
|
|
9
|
+
const STORE_UNICODE_OK = 0x00040000;
|
|
10
|
+
const CP_UTF8 = 65001;
|
|
11
|
+
const ATT_MHTML_REF = 0x04;
|
|
12
|
+
|
|
13
|
+
const displayName = (a) => a.name || a.email;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a `.msg` file from message data.
|
|
17
|
+
* @param {object} msg
|
|
18
|
+
* @returns {Uint8Array}
|
|
19
|
+
*/
|
|
20
|
+
export function writeMsg(msg) {
|
|
21
|
+
const root = newRoot(MSG_CLSID);
|
|
22
|
+
|
|
23
|
+
// Required by the format even when no named properties are used.
|
|
24
|
+
const nameid = addStorage(root, '__nameid_version1.0');
|
|
25
|
+
addStream(nameid, '__substg1.0_00020102', new Uint8Array(0));
|
|
26
|
+
addStream(nameid, '__substg1.0_00030102', new Uint8Array(0));
|
|
27
|
+
addStream(nameid, '__substg1.0_00040102', new Uint8Array(0));
|
|
28
|
+
|
|
29
|
+
const to = msg.to || [];
|
|
30
|
+
const cc = msg.cc || [];
|
|
31
|
+
const bcc = msg.bcc || [];
|
|
32
|
+
const attachments = msg.attachments || [];
|
|
33
|
+
const from = msg.from || { name: '', email: '' };
|
|
34
|
+
|
|
35
|
+
const p = new PropertyWriter(root, HeaderSize.TOP_LEVEL);
|
|
36
|
+
p.str(PropId.MESSAGE_CLASS, msg.messageClass || 'IPM.Note');
|
|
37
|
+
p.str(PropId.SUBJECT, msg.subject || '');
|
|
38
|
+
p.str(PropId.NORMALIZED_SUBJECT, (msg.subject || '').replace(/^\s*(re|fw|fwd)\s*:\s*/i, ''));
|
|
39
|
+
p.str(PropId.BODY, msg.text || '');
|
|
40
|
+
if (msg.html) p.bin(PropId.HTML, utf8Encode(msg.html));
|
|
41
|
+
if (msg.headersRaw) p.str(PropId.TRANSPORT_HEADERS, msg.headersRaw);
|
|
42
|
+
p.str(PropId.INTERNET_MESSAGE_ID, msg.messageId || '');
|
|
43
|
+
p.str(PropId.IN_REPLY_TO_ID, msg.inReplyTo || '');
|
|
44
|
+
p.str(PropId.INTERNET_REFERENCES, msg.references || '');
|
|
45
|
+
|
|
46
|
+
const addrType = from.email ? 'SMTP' : '';
|
|
47
|
+
p.str(PropId.SENDER_NAME, from.name || from.email || '');
|
|
48
|
+
p.str(PropId.SENDER_EMAIL, from.email || '');
|
|
49
|
+
p.str(PropId.SENDER_ADDRTYPE, addrType);
|
|
50
|
+
p.str(PropId.SENDER_SMTP, from.email || '');
|
|
51
|
+
p.str(PropId.SENT_REP_NAME, from.name || from.email || '');
|
|
52
|
+
p.str(PropId.SENT_REP_EMAIL, from.email || '');
|
|
53
|
+
p.str(PropId.SENT_REP_ADDRTYPE, addrType);
|
|
54
|
+
p.str(PropId.SENT_REP_SMTP, from.email || '');
|
|
55
|
+
|
|
56
|
+
p.str(PropId.DISPLAY_TO, to.map(displayName).join('; '));
|
|
57
|
+
p.str(PropId.DISPLAY_CC, cc.map(displayName).join('; '));
|
|
58
|
+
p.str(PropId.DISPLAY_BCC, bcc.map(displayName).join('; '));
|
|
59
|
+
|
|
60
|
+
p.time(PropId.CLIENT_SUBMIT_TIME, msg.date);
|
|
61
|
+
p.time(PropId.DELIVERY_TIME, msg.date);
|
|
62
|
+
p.time(PropId.CREATION_TIME, msg.date || new Date());
|
|
63
|
+
p.time(PropId.LAST_MODIFICATION_TIME, new Date());
|
|
64
|
+
|
|
65
|
+
p.int32(PropId.MESSAGE_FLAGS, MSGFLAG_READ);
|
|
66
|
+
p.int32(PropId.STORE_SUPPORT_MASK, STORE_UNICODE_OK);
|
|
67
|
+
p.int32(PropId.INTERNET_CPID, CP_UTF8);
|
|
68
|
+
p.int32(PropId.MESSAGE_CODEPAGE, CP_UTF8);
|
|
69
|
+
if (msg.importance != null) p.int32(PropId.IMPORTANCE, msg.importance);
|
|
70
|
+
p.bool(PropId.HASATTACH, attachments.length > 0);
|
|
71
|
+
|
|
72
|
+
const recipients = [
|
|
73
|
+
...to.map((a) => ({ a, t: RecipientType.TO })),
|
|
74
|
+
...cc.map((a) => ({ a, t: RecipientType.CC })),
|
|
75
|
+
...bcc.map((a) => ({ a, t: RecipientType.BCC }))
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
recipients.forEach((r, i) => {
|
|
79
|
+
const node = addStorage(root, '__recip_version1.0_#' + hex8(i));
|
|
80
|
+
const rp = new PropertyWriter(node, HeaderSize.RECIPIENT);
|
|
81
|
+
rp.int32(PropId.ROWID, i);
|
|
82
|
+
rp.int32(PropId.RECIPIENT_TYPE, r.t);
|
|
83
|
+
rp.int32(PropId.OBJECT_TYPE, 6); // MAPI_MAILUSER
|
|
84
|
+
rp.int32(PropId.DISPLAY_TYPE, 0); // DT_MAILUSER
|
|
85
|
+
rp.str(PropId.DISPLAY_NAME, r.a.name || r.a.email);
|
|
86
|
+
rp.str(PropId.EMAIL_ADDRESS, r.a.email);
|
|
87
|
+
rp.str(PropId.SMTP_ADDRESS, r.a.email);
|
|
88
|
+
rp.str(PropId.ADDRTYPE, 'SMTP');
|
|
89
|
+
rp.finish();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
attachments.forEach((att, i) => {
|
|
93
|
+
const node = addStorage(root, '__attach_version1.0_#' + hex8(i));
|
|
94
|
+
const ap = new PropertyWriter(node, HeaderSize.ATTACHMENT);
|
|
95
|
+
ap.int32(PropId.ATTACH_NUM, i);
|
|
96
|
+
ap.int32(PropId.ATTACH_METHOD, 1); // ATTACH_BY_VALUE
|
|
97
|
+
ap.int32(PropId.OBJECT_TYPE, 7); // MAPI_ATTACH
|
|
98
|
+
ap.int32(PropId.ATTACH_SIZE, att.data.length);
|
|
99
|
+
ap.int32(PropId.RENDERING_POSITION, -1);
|
|
100
|
+
ap.bin(PropId.ATTACH_DATA_BIN, att.data);
|
|
101
|
+
ap.str(PropId.ATTACH_LONG_FILENAME, att.filename);
|
|
102
|
+
ap.str(PropId.ATTACH_FILENAME, shortName(att.filename));
|
|
103
|
+
const ext = /\.[A-Za-z0-9]+$/.exec(att.filename);
|
|
104
|
+
if (ext) ap.str(PropId.ATTACH_EXTENSION, ext[0]);
|
|
105
|
+
ap.str(PropId.ATTACH_MIME_TAG, att.mime || guessMime(att.filename));
|
|
106
|
+
if (att.cid) {
|
|
107
|
+
ap.str(PropId.ATTACH_CONTENT_ID, att.cid);
|
|
108
|
+
if (att.inline) {
|
|
109
|
+
ap.int32(PropId.ATTACH_FLAGS, ATT_MHTML_REF);
|
|
110
|
+
ap.bool(PropId.ATTACHMENT_HIDDEN, true);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
ap.finish();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
p.finish((dv) => {
|
|
117
|
+
dv.setUint32(8, recipients.length, true); // next recipient id
|
|
118
|
+
dv.setUint32(12, attachments.length, true); // next attachment id
|
|
119
|
+
dv.setUint32(16, recipients.length, true); // recipient count
|
|
120
|
+
dv.setUint32(20, attachments.length, true); // attachment count
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return writeCfb(root);
|
|
124
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Recover HTML from RTF that encapsulates it (\fromhtml1) — MS-OXRTFEX. */
|
|
2
|
+
|
|
3
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>' };
|
|
4
|
+
const esc = (s) => s.replace(/[&<>]/g, (c) => ENTITIES[c]);
|
|
5
|
+
|
|
6
|
+
const SIMPLE = {
|
|
7
|
+
lquote: '‘', rquote: '’', ldblquote: '“', rdblquote: '”',
|
|
8
|
+
emdash: '—', endash: '–', bullet: '•', nbsp: ' '
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SKIP_DESTINATIONS = new Set([
|
|
12
|
+
'fonttbl', 'colortbl', 'stylesheet', 'info', 'pntext', 'generator'
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} rtf
|
|
17
|
+
* @returns {boolean} True if this RTF carries encapsulated HTML or text.
|
|
18
|
+
*/
|
|
19
|
+
export function isEncapsulated(rtf) {
|
|
20
|
+
return /\\from(html|text)1?/.test(rtf.slice(0, 4096));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Walk the RTF and emit the original HTML.
|
|
25
|
+
*
|
|
26
|
+
* `\*\htmltag` destinations hold the real markup; `\htmlrtf`/`\htmlrtf0`
|
|
27
|
+
* brackets the RTF-only rendering text that must be dropped.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} rtf RTF as raw bytes widened to chars (one char per byte).
|
|
30
|
+
* @param {(bytes: Uint8Array) => string} decode Code-page decoder.
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function deencapsulate(rtf, decode) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const suppressStack = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
const len = rtf.length;
|
|
38
|
+
let suppress = false; // inside \htmlrtf ... \htmlrtf0
|
|
39
|
+
let inHtmlTag = 0; // group depth of the active \*\htmltag destination
|
|
40
|
+
let depth = 0;
|
|
41
|
+
let skipDest = 0; // group depth of a destination to drop entirely
|
|
42
|
+
let pendingSkipWord = false;
|
|
43
|
+
let textBytes = []; // buffered so multi-byte code pages decode correctly
|
|
44
|
+
const fromText = /\\fromtext/.test(rtf.slice(0, 4096));
|
|
45
|
+
|
|
46
|
+
function emit(s) {
|
|
47
|
+
if (skipDest || (suppress && !inHtmlTag)) return;
|
|
48
|
+
out.push(s);
|
|
49
|
+
}
|
|
50
|
+
function flushBytes() {
|
|
51
|
+
if (!textBytes.length) return;
|
|
52
|
+
const s = decode(new Uint8Array(textBytes));
|
|
53
|
+
textBytes = [];
|
|
54
|
+
emit(inHtmlTag ? s : esc(s));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
while (i < len) {
|
|
58
|
+
const c = rtf[i];
|
|
59
|
+
|
|
60
|
+
if (c === '\\') {
|
|
61
|
+
const next = rtf[i + 1];
|
|
62
|
+
if (next === '\\' || next === '{' || next === '}') {
|
|
63
|
+
textBytes.push(next.charCodeAt(0));
|
|
64
|
+
i += 2;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (next === "'") {
|
|
68
|
+
textBytes.push(parseInt(rtf.substr(i + 2, 2), 16) || 0);
|
|
69
|
+
i += 4;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (next === '*') { i += 2; pendingSkipWord = true; continue; }
|
|
73
|
+
|
|
74
|
+
const m = /^\\([a-zA-Z]+)(-?\d+)? ?/.exec(rtf.slice(i));
|
|
75
|
+
if (!m) { i += 2; continue; }
|
|
76
|
+
const word = m[1];
|
|
77
|
+
const param = m[2] === undefined ? null : parseInt(m[2], 10);
|
|
78
|
+
i += m[0].length;
|
|
79
|
+
flushBytes();
|
|
80
|
+
|
|
81
|
+
if (word === 'htmltag') { pendingSkipWord = false; inHtmlTag = depth; continue; }
|
|
82
|
+
if (word === 'htmlrtf') { suppress = param !== 0; continue; }
|
|
83
|
+
if (word === 'mhtmltag') { skipDest = depth; pendingSkipWord = false; continue; }
|
|
84
|
+
if (pendingSkipWord) {
|
|
85
|
+
// An unrecognised \* destination: drop the whole group.
|
|
86
|
+
skipDest = depth;
|
|
87
|
+
pendingSkipWord = false;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (word === 'u' && param !== null) {
|
|
91
|
+
if (!suppress || inHtmlTag) {
|
|
92
|
+
const cp = param < 0 ? param + 65536 : param;
|
|
93
|
+
emit(inHtmlTag ? String.fromCharCode(cp) : esc(String.fromCharCode(cp)));
|
|
94
|
+
}
|
|
95
|
+
if (rtf[i] === '?') i++; // skip the ANSI fallback character
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (word === 'par' || word === 'line') { if (fromText) emit('\n'); continue; }
|
|
99
|
+
if (word === 'tab') { emit('\t'); continue; }
|
|
100
|
+
if (SIMPLE[word]) { emit(SIMPLE[word]); continue; }
|
|
101
|
+
if (SKIP_DESTINATIONS.has(word)) { skipDest = depth; continue; }
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (c === '{') {
|
|
106
|
+
flushBytes();
|
|
107
|
+
depth++;
|
|
108
|
+
suppressStack.push({ suppress, inHtmlTag });
|
|
109
|
+
i++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (c === '}') {
|
|
113
|
+
flushBytes();
|
|
114
|
+
if (skipDest && depth === skipDest) skipDest = 0;
|
|
115
|
+
if (inHtmlTag && depth === inHtmlTag) inHtmlTag = 0;
|
|
116
|
+
depth--;
|
|
117
|
+
const st = suppressStack.pop();
|
|
118
|
+
if (st) suppress = st.suppress;
|
|
119
|
+
pendingSkipWord = false;
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (c === '\r' || c === '\n') { i++; continue; }
|
|
124
|
+
|
|
125
|
+
textBytes.push(rtf.charCodeAt(i) & 0xFF);
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
flushBytes();
|
|
129
|
+
|
|
130
|
+
const html = out.join('');
|
|
131
|
+
if (fromText) {
|
|
132
|
+
return '<html><body><pre style="font-family:inherit;white-space:pre-wrap">' +
|
|
133
|
+
html + '</pre></body></html>';
|
|
134
|
+
}
|
|
135
|
+
return html;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Convenience wrapper: decompressed RTF bytes in, HTML out.
|
|
140
|
+
* @param {Uint8Array} rtfBytes
|
|
141
|
+
* @param {(bytes: Uint8Array) => string} decode
|
|
142
|
+
* @returns {string|null} null when the RTF does not encapsulate HTML.
|
|
143
|
+
*/
|
|
144
|
+
export function rtfToHtml(rtfBytes, decode) {
|
|
145
|
+
if (!rtfBytes || !rtfBytes.length) return null;
|
|
146
|
+
let rtf = '';
|
|
147
|
+
for (let i = 0; i < rtfBytes.length; i++) rtf += String.fromCharCode(rtfBytes[i]);
|
|
148
|
+
if (!isEncapsulated(rtf)) return null;
|
|
149
|
+
try {
|
|
150
|
+
const html = deencapsulate(rtf, decode);
|
|
151
|
+
return html && html.trim() ? html : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/rtf/index.js
ADDED
package/src/rtf/lzfu.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** LZFu decompression for PR_RTF_COMPRESSED — MS-OXRTFCP. */
|
|
2
|
+
|
|
3
|
+
// The 207-byte preload dictionary defined by the specification.
|
|
4
|
+
const INIT_DICT =
|
|
5
|
+
'{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}' +
|
|
6
|
+
'{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArial' +
|
|
7
|
+
'Times New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n' +
|
|
8
|
+
'\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx';
|
|
9
|
+
|
|
10
|
+
const COMPRESSED = 0x75465a4c; // "LZFu"
|
|
11
|
+
const UNCOMPRESSED = 0x414c454d; // "MELA"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decompress a PR_RTF_COMPRESSED stream.
|
|
15
|
+
* Handles both the compressed ("LZFu") and stored ("MELA") containers.
|
|
16
|
+
*
|
|
17
|
+
* @param {Uint8Array} bytes
|
|
18
|
+
* @returns {Uint8Array|null} RTF bytes, or null if this is not an RTF container.
|
|
19
|
+
*/
|
|
20
|
+
export function decompress(bytes) {
|
|
21
|
+
if (!bytes || bytes.length < 16) return null;
|
|
22
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
23
|
+
const compSize = dv.getUint32(0, true);
|
|
24
|
+
const rawSize = dv.getUint32(4, true);
|
|
25
|
+
const magic = dv.getUint32(8, true);
|
|
26
|
+
|
|
27
|
+
if (magic === UNCOMPRESSED) {
|
|
28
|
+
return bytes.subarray(16, Math.min(bytes.length, 16 + rawSize));
|
|
29
|
+
}
|
|
30
|
+
if (magic !== COMPRESSED) return null;
|
|
31
|
+
|
|
32
|
+
const dict = new Uint8Array(4096);
|
|
33
|
+
for (let i = 0; i < INIT_DICT.length; i++) dict[i] = INIT_DICT.charCodeAt(i) & 0xFF;
|
|
34
|
+
let writeAt = INIT_DICT.length;
|
|
35
|
+
|
|
36
|
+
const out = new Uint8Array(rawSize);
|
|
37
|
+
let outPos = 0;
|
|
38
|
+
let pos = 16;
|
|
39
|
+
const end = Math.min(bytes.length, compSize + 4);
|
|
40
|
+
|
|
41
|
+
while (pos < end && outPos < rawSize) {
|
|
42
|
+
const control = bytes[pos++];
|
|
43
|
+
for (let bit = 0; bit < 8 && pos < end && outPos < rawSize; bit++) {
|
|
44
|
+
if (control & (1 << bit)) {
|
|
45
|
+
if (pos + 1 >= end) return out.subarray(0, outPos);
|
|
46
|
+
const b1 = bytes[pos++];
|
|
47
|
+
const b2 = bytes[pos++];
|
|
48
|
+
const offset = (b1 << 4) | (b2 >> 4);
|
|
49
|
+
const length = (b2 & 0x0F) + 2;
|
|
50
|
+
// A reference to the current write position marks the end of the stream.
|
|
51
|
+
if (offset === (writeAt % 4096)) return out.subarray(0, outPos);
|
|
52
|
+
for (let n = 0; n < length && outPos < rawSize; n++) {
|
|
53
|
+
const byte = dict[(offset + n) % 4096];
|
|
54
|
+
out[outPos++] = byte;
|
|
55
|
+
dict[writeAt % 4096] = byte;
|
|
56
|
+
writeAt++;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
const lit = bytes[pos++];
|
|
60
|
+
out[outPos++] = lit;
|
|
61
|
+
dict[writeAt % 4096] = lit;
|
|
62
|
+
writeAt++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out.subarray(0, outPos);
|
|
67
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Small shared helpers. */
|
|
2
|
+
|
|
3
|
+
const MIME_BY_EXT = {
|
|
4
|
+
pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
5
|
+
gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml',
|
|
6
|
+
txt: 'text/plain', csv: 'text/csv', html: 'text/html', htm: 'text/html',
|
|
7
|
+
xml: 'application/xml', json: 'application/json', zip: 'application/zip',
|
|
8
|
+
doc: 'application/msword', dot: 'application/msword',
|
|
9
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
10
|
+
xls: 'application/vnd.ms-excel',
|
|
11
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
12
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
13
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
14
|
+
eml: 'message/rfc822', msg: 'application/vnd.ms-outlook', ics: 'text/calendar',
|
|
15
|
+
rtf: 'application/rtf', mp3: 'audio/mpeg', mp4: 'video/mp4'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Guess a content type from a filename extension.
|
|
20
|
+
* @param {string} name
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function guessMime(name) {
|
|
24
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
|
|
25
|
+
return (m && MIME_BY_EXT[m[1].toLowerCase()]) || 'application/octet-stream';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strip path separators and characters that are illegal in filenames.
|
|
30
|
+
* @param {string} s
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function safeName(s) {
|
|
34
|
+
return String(s || 'file')
|
|
35
|
+
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
|
|
36
|
+
.replace(/^\.+/, '')
|
|
37
|
+
.slice(0, 120) || 'file';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An 8.3-style short name for PR_ATTACH_FILENAME.
|
|
42
|
+
* @param {string} name
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function shortName(name) {
|
|
46
|
+
const m = /^(.*?)(\.[A-Za-z0-9]{1,3})?$/.exec(name || 'file');
|
|
47
|
+
const base = (m[1] || 'file').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) || 'file';
|
|
48
|
+
return base + (m[2] || '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replace the extension on a filename.
|
|
53
|
+
* @param {string} name
|
|
54
|
+
* @param {string} ext Including the dot.
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function replaceExt(name, ext) {
|
|
58
|
+
return (String(name).replace(/\.[^.\\/]+$/, '') || 'message') + ext;
|
|
59
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} DirEntry
|
|
3
|
+
* @property {number} id
|
|
4
|
+
* @property {string} name
|
|
5
|
+
* @property {number} type 1 = storage, 2 = stream, 5 = root
|
|
6
|
+
* @property {number} start First sector of the stream.
|
|
7
|
+
* @property {number} size Stream length in bytes.
|
|
8
|
+
* @property {number} left Left sibling id in the storage's red-black tree.
|
|
9
|
+
* @property {number} right Right sibling id.
|
|
10
|
+
* @property {number} child First child id, for storages.
|
|
11
|
+
* @property {{list: DirEntry[], map: Record<string, DirEntry>}|null} children
|
|
12
|
+
* Memoised child index, filled in by `childrenOf`.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} CfbFile
|
|
16
|
+
* @property {DirEntry} root
|
|
17
|
+
* @property {DirEntry[]} entries
|
|
18
|
+
* @property {(entry: DirEntry) => Uint8Array} readStream
|
|
19
|
+
* @property {(entry: DirEntry) => {list: DirEntry[], map: Record<string, DirEntry>}} childrenOf
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Parse a compound file.
|
|
23
|
+
* @param {Uint8Array} bytes
|
|
24
|
+
* @returns {CfbFile}
|
|
25
|
+
*/
|
|
26
|
+
export function read(bytes: Uint8Array): CfbFile;
|
|
27
|
+
export type DirEntry = {
|
|
28
|
+
id: number;
|
|
29
|
+
name: string;
|
|
30
|
+
/**
|
|
31
|
+
* 1 = storage, 2 = stream, 5 = root
|
|
32
|
+
*/
|
|
33
|
+
type: number;
|
|
34
|
+
/**
|
|
35
|
+
* First sector of the stream.
|
|
36
|
+
*/
|
|
37
|
+
start: number;
|
|
38
|
+
/**
|
|
39
|
+
* Stream length in bytes.
|
|
40
|
+
*/
|
|
41
|
+
size: number;
|
|
42
|
+
/**
|
|
43
|
+
* Left sibling id in the storage's red-black tree.
|
|
44
|
+
*/
|
|
45
|
+
left: number;
|
|
46
|
+
/**
|
|
47
|
+
* Right sibling id.
|
|
48
|
+
*/
|
|
49
|
+
right: number;
|
|
50
|
+
/**
|
|
51
|
+
* First child id, for storages.
|
|
52
|
+
*/
|
|
53
|
+
child: number;
|
|
54
|
+
/**
|
|
55
|
+
* Memoised child index, filled in by `childrenOf`.
|
|
56
|
+
*/
|
|
57
|
+
children: {
|
|
58
|
+
list: DirEntry[];
|
|
59
|
+
map: Record<string, DirEntry>;
|
|
60
|
+
} | null;
|
|
61
|
+
};
|
|
62
|
+
export type CfbFile = {
|
|
63
|
+
root: DirEntry;
|
|
64
|
+
entries: DirEntry[];
|
|
65
|
+
readStream: (entry: DirEntry) => Uint8Array;
|
|
66
|
+
childrenOf: (entry: DirEntry) => {
|
|
67
|
+
list: DirEntry[];
|
|
68
|
+
map: Record<string, DirEntry>;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node in the storage tree being built.
|
|
3
|
+
*
|
|
4
|
+
* The `id`/`leftId`/`rightId`/`childId`/`startSector`/`streamSize` fields are
|
|
5
|
+
* assigned by {@link write} while laying the file out; callers never set them.
|
|
6
|
+
*
|
|
7
|
+
* @typedef {object} CfbNode
|
|
8
|
+
* @property {string} name
|
|
9
|
+
* @property {number} type 1 = storage, 2 = stream, 5 = root
|
|
10
|
+
* @property {Uint8Array|null} [clsid]
|
|
11
|
+
* @property {CfbNode[]} [children]
|
|
12
|
+
* @property {Uint8Array} [data]
|
|
13
|
+
* @property {number} [id]
|
|
14
|
+
* @property {number} [leftId]
|
|
15
|
+
* @property {number} [rightId]
|
|
16
|
+
* @property {number} [childId]
|
|
17
|
+
* @property {number} [startSector]
|
|
18
|
+
* @property {number} [streamSize]
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Create an empty root storage.
|
|
22
|
+
* @param {Uint8Array|null} [clsid] 16-byte class id written to the root entry.
|
|
23
|
+
* @returns {CfbNode}
|
|
24
|
+
*/
|
|
25
|
+
export function newRoot(clsid?: Uint8Array | null): CfbNode;
|
|
26
|
+
/**
|
|
27
|
+
* @param {CfbNode} parent
|
|
28
|
+
* @param {string} name
|
|
29
|
+
* @param {Uint8Array|null} [clsid]
|
|
30
|
+
* @returns {CfbNode}
|
|
31
|
+
*/
|
|
32
|
+
export function addStorage(parent: CfbNode, name: string, clsid?: Uint8Array | null): CfbNode;
|
|
33
|
+
/**
|
|
34
|
+
* @param {CfbNode} parent
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @param {Uint8Array} [data]
|
|
37
|
+
* @returns {CfbNode}
|
|
38
|
+
*/
|
|
39
|
+
export function addStream(parent: CfbNode, name: string, data?: Uint8Array): CfbNode;
|
|
40
|
+
/**
|
|
41
|
+
* Serialise a storage tree to compound file bytes.
|
|
42
|
+
* @param {CfbNode} root
|
|
43
|
+
* @returns {Uint8Array}
|
|
44
|
+
*/
|
|
45
|
+
export function write(root: CfbNode): Uint8Array;
|
|
46
|
+
/**
|
|
47
|
+
* A node in the storage tree being built.
|
|
48
|
+
*
|
|
49
|
+
* The `id`/`leftId`/`rightId`/`childId`/`startSector`/`streamSize` fields are
|
|
50
|
+
* assigned by {@link write} while laying the file out; callers never set them.
|
|
51
|
+
*/
|
|
52
|
+
export type CfbNode = {
|
|
53
|
+
name: string;
|
|
54
|
+
/**
|
|
55
|
+
* 1 = storage, 2 = stream, 5 = root
|
|
56
|
+
*/
|
|
57
|
+
type: number;
|
|
58
|
+
clsid?: Uint8Array | null;
|
|
59
|
+
children?: CfbNode[];
|
|
60
|
+
data?: Uint8Array;
|
|
61
|
+
id?: number;
|
|
62
|
+
leftId?: number;
|
|
63
|
+
rightId?: number;
|
|
64
|
+
childId?: number;
|
|
65
|
+
startSector?: number;
|
|
66
|
+
streamSize?: number;
|
|
67
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} code
|
|
3
|
+
* @param {string} message
|
|
4
|
+
* @returns {never}
|
|
5
|
+
*/
|
|
6
|
+
export function fail(code: string, message: string): never;
|
|
7
|
+
/**
|
|
8
|
+
* Error codes thrown by mailfile. Callers should branch on `error.code`
|
|
9
|
+
* rather than on message text.
|
|
10
|
+
*/
|
|
11
|
+
export type ErrorCode = string;
|
|
12
|
+
export namespace ErrorCode {
|
|
13
|
+
let NOT_COMPOUND_FILE: string;
|
|
14
|
+
let CORRUPT_CFB: string;
|
|
15
|
+
let TRUNCATED_STREAM: string;
|
|
16
|
+
let NOT_A_MESSAGE: string;
|
|
17
|
+
let EMPTY_INPUT: string;
|
|
18
|
+
let UNSUPPORTED: string;
|
|
19
|
+
let MALFORMED: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* All errors raised by mailfile.
|
|
23
|
+
*/
|
|
24
|
+
export class MailfileError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} code One of {@link ErrorCode}.
|
|
27
|
+
* @param {string} message Human-readable description.
|
|
28
|
+
* @param {{cause?: unknown}} [options]
|
|
29
|
+
*/
|
|
30
|
+
constructor(code: string, message: string, options?: {
|
|
31
|
+
cause?: unknown;
|
|
32
|
+
});
|
|
33
|
+
/** @type {string} */
|
|
34
|
+
code: string;
|
|
35
|
+
cause: unknown;
|
|
36
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identify a message file from its content, falling back to the extension.
|
|
3
|
+
*
|
|
4
|
+
* Compound-file magic bytes are authoritative, so a `.msg` renamed to `.eml`
|
|
5
|
+
* is still detected correctly.
|
|
6
|
+
*
|
|
7
|
+
* @param {Uint8Array} bytes
|
|
8
|
+
* @param {string} [filename]
|
|
9
|
+
* @returns {'msg'|'eml'}
|
|
10
|
+
*/
|
|
11
|
+
export function detect(bytes: Uint8Array, filename?: string): "msg" | "eml";
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} ConvertResult
|
|
14
|
+
* @property {Uint8Array} data Converted bytes.
|
|
15
|
+
* @property {'msg'|'eml'} format Format of `data`.
|
|
16
|
+
* @property {'msg'|'eml'} sourceFormat Format that was detected on input.
|
|
17
|
+
* @property {string} mime Content type for `data`.
|
|
18
|
+
* @property {string} [filename] Suggested output name, when one was given.
|
|
19
|
+
* @property {Message} message The parsed message.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Convert a message to the other format, detecting the input automatically.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const { data, format } = convert(bytes, { filename: 'note.msg' })
|
|
26
|
+
* // data is .eml bytes, format === 'eml'
|
|
27
|
+
*
|
|
28
|
+
* @param {Uint8Array} bytes
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {string} [opts.filename] Used for detection and for naming the output.
|
|
31
|
+
* @param {'msg'|'eml'} [opts.to] Force the target format instead of flipping.
|
|
32
|
+
* @param {'msg'|'eml'} [opts.from] Skip detection and assume this input format.
|
|
33
|
+
* @param {boolean} [opts.lazy] Defer attachment extraction (`.msg` input only).
|
|
34
|
+
* @returns {ConvertResult}
|
|
35
|
+
*/
|
|
36
|
+
export function convert(bytes: Uint8Array, opts?: {
|
|
37
|
+
filename?: string;
|
|
38
|
+
to?: "msg" | "eml";
|
|
39
|
+
from?: "msg" | "eml";
|
|
40
|
+
lazy?: boolean;
|
|
41
|
+
}): ConvertResult;
|
|
42
|
+
export { Message } from "./message.js";
|
|
43
|
+
export { Headers } from "./mime/headers.js";
|
|
44
|
+
/** @type {string} */
|
|
45
|
+
export const version: string;
|
|
46
|
+
export type ConvertResult = {
|
|
47
|
+
/**
|
|
48
|
+
* Converted bytes.
|
|
49
|
+
*/
|
|
50
|
+
data: Uint8Array;
|
|
51
|
+
/**
|
|
52
|
+
* Format of `data`.
|
|
53
|
+
*/
|
|
54
|
+
format: "msg" | "eml";
|
|
55
|
+
/**
|
|
56
|
+
* Format that was detected on input.
|
|
57
|
+
*/
|
|
58
|
+
sourceFormat: "msg" | "eml";
|
|
59
|
+
/**
|
|
60
|
+
* Content type for `data`.
|
|
61
|
+
*/
|
|
62
|
+
mime: string;
|
|
63
|
+
/**
|
|
64
|
+
* Suggested output name, when one was given.
|
|
65
|
+
*/
|
|
66
|
+
filename?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The parsed message.
|
|
69
|
+
*/
|
|
70
|
+
message: Message;
|
|
71
|
+
};
|
|
72
|
+
import { Message } from './message.js';
|
|
73
|
+
export { MailfileError, ErrorCode } from "./errors.js";
|