postal-mime 2.0.0 → 2.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.
@@ -0,0 +1,271 @@
1
+ import { getDecoder, decodeParameterValueContinuations, textEncoder } from './decode-strings.js';
2
+ import PassThroughDecoder from './pass-through-decoder.js';
3
+ import Base64Decoder from './base64-decoder.js';
4
+ import QPDecoder from './qp-decoder.js';
5
+
6
+ export default class MimeNode {
7
+ constructor(opts) {
8
+ opts = opts || {};
9
+
10
+ this.postalMime = opts.postalMime;
11
+
12
+ this.root = !!opts.parentNode;
13
+ this.childNodes = [];
14
+ if (opts.parentNode) {
15
+ opts.parentNode.childNodes.push(this);
16
+ }
17
+
18
+ this.state = 'header';
19
+
20
+ this.headerLines = [];
21
+ this.decoders = new Map();
22
+
23
+ this.contentType = {
24
+ value: 'text/plain',
25
+ default: true
26
+ };
27
+
28
+ this.contentTransferEncoding = {
29
+ value: '8bit'
30
+ };
31
+
32
+ this.contentDisposition = {
33
+ value: ''
34
+ };
35
+
36
+ this.headers = [];
37
+
38
+ this.contentDecoder = false;
39
+ }
40
+
41
+ setupContentDecoder(transferEncoding) {
42
+ if (/base64/i.test(transferEncoding)) {
43
+ this.contentDecoder = new Base64Decoder();
44
+ } else if (/quoted-printable/i.test(transferEncoding)) {
45
+ this.contentDecoder = new QPDecoder({ decoder: getDecoder(this.contentType.parsed.params.charset) });
46
+ } else {
47
+ this.contentDecoder = new PassThroughDecoder();
48
+ }
49
+ }
50
+
51
+ async finalize() {
52
+ if (this.state === 'finished') {
53
+ return;
54
+ }
55
+
56
+ if (this.state === 'header') {
57
+ this.processHeaders();
58
+ }
59
+
60
+ // remove self from boundary listing
61
+ let boundaries = this.postalMime.boundaries;
62
+ for (let i = boundaries.length - 1; i >= 0; i--) {
63
+ let boundary = boundaries[i];
64
+ if (boundary.node === this) {
65
+ boundaries.splice(i, 1);
66
+ break;
67
+ }
68
+ }
69
+
70
+ await this.finalizeChildNodes();
71
+
72
+ this.content = this.contentDecoder ? await this.contentDecoder.finalize() : null;
73
+
74
+ this.state = 'finished';
75
+ }
76
+
77
+ async finalizeChildNodes() {
78
+ for (let childNode of this.childNodes) {
79
+ await childNode.finalize();
80
+ }
81
+ }
82
+
83
+ parseStructuredHeader(str) {
84
+ let response = {
85
+ value: false,
86
+ params: {}
87
+ };
88
+
89
+ let key = false;
90
+ let value = '';
91
+ let stage = 'value';
92
+
93
+ let quote = false;
94
+ let escaped = false;
95
+ let chr;
96
+
97
+ for (let i = 0, len = str.length; i < len; i++) {
98
+ chr = str.charAt(i);
99
+ switch (stage) {
100
+ case 'key':
101
+ if (chr === '=') {
102
+ key = value.trim().toLowerCase();
103
+ stage = 'value';
104
+ value = '';
105
+ break;
106
+ }
107
+ value += chr;
108
+ break;
109
+ case 'value':
110
+ if (escaped) {
111
+ value += chr;
112
+ } else if (chr === '\\') {
113
+ escaped = true;
114
+ continue;
115
+ } else if (quote && chr === quote) {
116
+ quote = false;
117
+ } else if (!quote && chr === '"') {
118
+ quote = chr;
119
+ } else if (!quote && chr === ';') {
120
+ if (key === false) {
121
+ response.value = value.trim();
122
+ } else {
123
+ response.params[key] = value.trim();
124
+ }
125
+ stage = 'key';
126
+ value = '';
127
+ } else {
128
+ value += chr;
129
+ }
130
+ escaped = false;
131
+ break;
132
+ }
133
+ }
134
+
135
+ // finalize remainder
136
+ value = value.trim();
137
+ if (stage === 'value') {
138
+ if (key === false) {
139
+ // default value
140
+ response.value = value;
141
+ } else {
142
+ // subkey value
143
+ response.params[key] = value;
144
+ }
145
+ } else if (value) {
146
+ // treat as key without value, see emptykey:
147
+ // Header-Key: somevalue; key=value; emptykey
148
+ response.params[value.toLowerCase()] = '';
149
+ }
150
+
151
+ if (response.value) {
152
+ response.value = response.value.toLowerCase();
153
+ }
154
+
155
+ // convert Parameter Value Continuations into single strings
156
+ decodeParameterValueContinuations(response);
157
+
158
+ return response;
159
+ }
160
+
161
+ decodeFlowedText(str, delSp) {
162
+ return (
163
+ str
164
+ .split(/\r?\n/)
165
+ // remove soft linebreaks
166
+ // soft linebreaks are added after space symbols
167
+ .reduce((previousValue, currentValue) => {
168
+ if (/ $/.test(previousValue) && !/(^|\n)-- $/.test(previousValue)) {
169
+ if (delSp) {
170
+ // delsp adds space to text to be able to fold it
171
+ // these spaces can be removed once the text is unfolded
172
+ return previousValue.slice(0, -1) + currentValue;
173
+ } else {
174
+ return previousValue + currentValue;
175
+ }
176
+ } else {
177
+ return previousValue + '\n' + currentValue;
178
+ }
179
+ })
180
+ // remove whitespace stuffing
181
+ // http://tools.ietf.org/html/rfc3676#section-4.4
182
+ .replace(/^ /gm, '')
183
+ );
184
+ }
185
+
186
+ getTextContent() {
187
+ if (!this.content) {
188
+ return '';
189
+ }
190
+
191
+ let str = getDecoder(this.contentType.parsed.params.charset).decode(this.content);
192
+
193
+ if (/^flowed$/i.test(this.contentType.parsed.params.format)) {
194
+ str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp));
195
+ }
196
+
197
+ return str;
198
+ }
199
+
200
+ processHeaders() {
201
+ for (let i = this.headerLines.length - 1; i >= 0; i--) {
202
+ let line = this.headerLines[i];
203
+ if (i && /^\s/.test(line)) {
204
+ this.headerLines[i - 1] += '\n' + line;
205
+ this.headerLines.splice(i, 1);
206
+ } else {
207
+ // remove folding and extra WS
208
+ line = line.replace(/\s+/g, ' ');
209
+ let sep = line.indexOf(':');
210
+ let key = sep < 0 ? line.trim() : line.substr(0, sep).trim();
211
+ let value = sep < 0 ? '' : line.substr(sep + 1).trim();
212
+ this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
213
+
214
+ switch (key.toLowerCase()) {
215
+ case 'content-type':
216
+ if (this.contentType.default) {
217
+ this.contentType = { value, parsed: {} };
218
+ }
219
+ break;
220
+ case 'content-transfer-encoding':
221
+ this.contentTransferEncoding = { value, parsed: {} };
222
+ break;
223
+ case 'content-disposition':
224
+ this.contentDisposition = { value, parsed: {} };
225
+ break;
226
+ case 'content-id':
227
+ this.contentId = value;
228
+ break;
229
+ }
230
+ }
231
+ }
232
+
233
+ this.contentType.parsed = this.parseStructuredHeader(this.contentType.value);
234
+ this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value)
235
+ ? this.contentType.parsed.value.substr(this.contentType.parsed.value.indexOf('/') + 1)
236
+ : false;
237
+
238
+ if (this.contentType.multipart && this.contentType.parsed.params.boundary) {
239
+ // add self to boundary terminator listing
240
+ this.postalMime.boundaries.push({
241
+ value: textEncoder.encode(this.contentType.parsed.params.boundary),
242
+ node: this
243
+ });
244
+ }
245
+
246
+ this.contentDisposition.parsed = this.parseStructuredHeader(this.contentDisposition.value);
247
+
248
+ this.contentTransferEncoding.encoding = this.contentTransferEncoding.value
249
+ .toLowerCase()
250
+ .split(/[^\w-]/)
251
+ .shift();
252
+
253
+ this.setupContentDecoder(this.contentTransferEncoding.encoding);
254
+ }
255
+
256
+ feed(line) {
257
+ switch (this.state) {
258
+ case 'header':
259
+ if (!line.length) {
260
+ this.state = 'body';
261
+ return this.processHeaders();
262
+ }
263
+ this.headerLines.push(getDecoder().decode(line));
264
+ break;
265
+ case 'body': {
266
+ // add line to body
267
+ this.contentDecoder.update(line);
268
+ }
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,17 @@
1
+ import { blobToArrayBuffer } from './decode-strings.js';
2
+
3
+ export default class PassThroughDecoder {
4
+ constructor() {
5
+ this.chunks = [];
6
+ }
7
+
8
+ update(line) {
9
+ this.chunks.push(line);
10
+ this.chunks.push('\n');
11
+ }
12
+
13
+ finalize() {
14
+ // convert an array of arraybuffers into a blob and then back into a single arraybuffer
15
+ return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
16
+ }
17
+ }
@@ -0,0 +1,395 @@
1
+ import MimeNode from './mime-node.js';
2
+ import { textToHtml, htmlToText, formatTextHeader, formatHtmlHeader } from './text-format.js';
3
+ import addressParser from './address-parser.js';
4
+ import { decodeWords, textEncoder, blobToArrayBuffer } from './decode-strings.js';
5
+
6
+ export default class PostalMime {
7
+ constructor() {
8
+ this.root = this.currentNode = new MimeNode({
9
+ postalMime: this
10
+ });
11
+ this.boundaries = [];
12
+
13
+ this.textContent = {};
14
+ this.attachments = [];
15
+
16
+ this.started = false;
17
+ }
18
+
19
+ async finalize() {
20
+ // close all pending nodes
21
+ await this.root.finalize();
22
+ }
23
+
24
+ async processLine(line, isFinal) {
25
+ let boundaries = this.boundaries;
26
+
27
+ // check if this is a mime boundary
28
+ if (boundaries.length && line.length > 2 && line[0] === 0x2d && line[1] === 0x2d) {
29
+ // could be a boundary marker
30
+ for (let i = boundaries.length - 1; i >= 0; i--) {
31
+ let boundary = boundaries[i];
32
+
33
+ if (line.length !== boundary.value.length + 2 && line.length !== boundary.value.length + 4) {
34
+ continue;
35
+ }
36
+
37
+ let isTerminator = line.length === boundary.value.length + 4;
38
+
39
+ if (isTerminator && (line[line.length - 2] !== 0x2d || line[line.length - 1] !== 0x2d)) {
40
+ continue;
41
+ }
42
+
43
+ let boudaryMatches = true;
44
+ for (let i = 0; i < boundary.value.length; i++) {
45
+ if (line[i + 2] !== boundary.value[i]) {
46
+ boudaryMatches = false;
47
+ break;
48
+ }
49
+ }
50
+ if (!boudaryMatches) {
51
+ continue;
52
+ }
53
+
54
+ if (isTerminator) {
55
+ await boundary.node.finalize();
56
+
57
+ this.currentNode = boundary.node.parentNode || this.root;
58
+ } else {
59
+ // finalize any open child nodes (should be just one though)
60
+ await boundary.node.finalizeChildNodes();
61
+
62
+ this.currentNode = new MimeNode({
63
+ postalMime: this,
64
+ parentNode: boundary.node
65
+ });
66
+ }
67
+
68
+ if (isFinal) {
69
+ return this.finalize();
70
+ }
71
+
72
+ return;
73
+ }
74
+ }
75
+
76
+ this.currentNode.feed(line);
77
+
78
+ if (isFinal) {
79
+ return this.finalize();
80
+ }
81
+ }
82
+
83
+ readLine() {
84
+ let startPos = this.readPos;
85
+ let endPos = this.readPos;
86
+
87
+ let res = () => {
88
+ return {
89
+ bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
90
+ done: this.readPos >= this.av.length
91
+ };
92
+ };
93
+
94
+ while (this.readPos < this.av.length) {
95
+ const c = this.av[this.readPos++];
96
+
97
+ if (c !== 0x0d && c !== 0x0a) {
98
+ endPos = this.readPos;
99
+ }
100
+
101
+ if (c === 0x0a) {
102
+ return res();
103
+ }
104
+ }
105
+
106
+ return res();
107
+ }
108
+
109
+ async processNodeTree() {
110
+ // get text nodes
111
+
112
+ let textContent = {};
113
+
114
+ let textTypes = new Set();
115
+ let textMap = (this.textMap = new Map());
116
+
117
+ let walk = async (node, alternative, related) => {
118
+ alternative = alternative || false;
119
+ related = related || false;
120
+
121
+ if (!node.contentType.multipart) {
122
+ // regular node
123
+
124
+ // is it inline message/rfc822
125
+ if (node.contentType.parsed.value === 'message/rfc822' && node.contentDisposition.parsed.value !== 'attachment') {
126
+ const subParser = new PostalMime();
127
+ node.subMessage = await subParser.parse(node.content);
128
+
129
+ if (!textMap.has(node)) {
130
+ textMap.set(node, {});
131
+ }
132
+
133
+ let textEntry = textMap.get(node);
134
+
135
+ // default to text if there is no content
136
+ if (node.subMessage.text || !node.subMessage.html) {
137
+ textEntry.plain = textEntry.plain || [];
138
+ textEntry.plain.push({ type: 'subMessage', value: node.subMessage });
139
+ textTypes.add('plain');
140
+ }
141
+
142
+ if (node.subMessage.html) {
143
+ textEntry.html = textEntry.html || [];
144
+ textEntry.html.push({ type: 'subMessage', value: node.subMessage });
145
+ textTypes.add('html');
146
+ }
147
+
148
+ if (subParser.textMap) {
149
+ subParser.textMap.forEach((subTextEntry, subTextNode) => {
150
+ textMap.set(subTextNode, subTextEntry);
151
+ });
152
+ }
153
+
154
+ for (let attachment of node.subMessage.attachments || []) {
155
+ this.attachments.push(attachment);
156
+ }
157
+ }
158
+
159
+ // is it text?
160
+ else if (
161
+ (/^text\//i.test(node.contentType.parsed.value) || node.contentType.parsed.value === 'message/delivery-status') &&
162
+ node.contentDisposition.parsed.value !== 'attachment'
163
+ ) {
164
+ let textType = node.contentType.parsed.value.substr(node.contentType.parsed.value.indexOf('/') + 1);
165
+ if (node.contentType.parsed.value === 'message/delivery-status') {
166
+ textType = 'plain';
167
+ }
168
+
169
+ let selectorNode = alternative || node;
170
+ if (!textMap.has(selectorNode)) {
171
+ textMap.set(selectorNode, {});
172
+ }
173
+
174
+ let textEntry = textMap.get(selectorNode);
175
+ textEntry[textType] = textEntry[textType] || [];
176
+ textEntry[textType].push({ type: 'text', value: node.getTextContent() });
177
+ textTypes.add(textType);
178
+ }
179
+
180
+ // is it an attachment
181
+ else if (node.content) {
182
+ let filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
183
+ let attachment = {
184
+ filename: decodeWords(filename),
185
+ mimeType: node.contentType.parsed.value,
186
+ disposition: node.contentDisposition.parsed.value || null
187
+ };
188
+
189
+ if (related && node.contentId) {
190
+ attachment.related = true;
191
+ }
192
+
193
+ if (node.contentId) {
194
+ attachment.contentId = node.contentId;
195
+ }
196
+
197
+ attachment.content = node.content;
198
+
199
+ this.attachments.push(attachment);
200
+ }
201
+ } else if (node.contentType.multipart === 'alternative') {
202
+ alternative = node;
203
+ } else if (node.contentType.multipart === 'related') {
204
+ related = node;
205
+ }
206
+
207
+ for (let childNode of node.childNodes) {
208
+ await walk(childNode, alternative, related);
209
+ }
210
+ };
211
+
212
+ await walk(this.root, false, []);
213
+
214
+ textMap.forEach(mapEntry => {
215
+ textTypes.forEach(textType => {
216
+ if (!textContent[textType]) {
217
+ textContent[textType] = [];
218
+ }
219
+
220
+ if (mapEntry[textType]) {
221
+ mapEntry[textType].forEach(textEntry => {
222
+ switch (textEntry.type) {
223
+ case 'text':
224
+ textContent[textType].push(textEntry.value);
225
+ break;
226
+
227
+ case 'subMessage':
228
+ {
229
+ switch (textType) {
230
+ case 'html':
231
+ textContent[textType].push(formatHtmlHeader(textEntry.value));
232
+ break;
233
+ case 'plain':
234
+ textContent[textType].push(formatTextHeader(textEntry.value));
235
+ break;
236
+ }
237
+ }
238
+ break;
239
+ }
240
+ });
241
+ } else {
242
+ let alternativeType;
243
+ switch (textType) {
244
+ case 'html':
245
+ alternativeType = 'plain';
246
+ break;
247
+ case 'plain':
248
+ alternativeType = 'html';
249
+ break;
250
+ }
251
+
252
+ (mapEntry[alternativeType] || []).forEach(textEntry => {
253
+ switch (textEntry.type) {
254
+ case 'text':
255
+ switch (textType) {
256
+ case 'html':
257
+ textContent[textType].push(textToHtml(textEntry.value));
258
+ break;
259
+ case 'plain':
260
+ textContent[textType].push(htmlToText(textEntry.value));
261
+ break;
262
+ }
263
+ break;
264
+
265
+ case 'subMessage':
266
+ {
267
+ switch (textType) {
268
+ case 'html':
269
+ textContent[textType].push(formatHtmlHeader(textEntry.value));
270
+ break;
271
+ case 'plain':
272
+ textContent[textType].push(formatTextHeader(textEntry.value));
273
+ break;
274
+ }
275
+ }
276
+ break;
277
+ }
278
+ });
279
+ }
280
+ });
281
+ });
282
+
283
+ Object.keys(textContent).forEach(textType => {
284
+ textContent[textType] = textContent[textType].join('\n');
285
+ });
286
+
287
+ this.textContent = textContent;
288
+ }
289
+
290
+ async parse(buf) {
291
+ if (this.started) {
292
+ throw new Error('Can not reuse parser, create a new PostalMime object');
293
+ }
294
+ this.started = true;
295
+
296
+ // should it thrown on empty value instead?
297
+ buf = buf || ArrayBuffer(0);
298
+
299
+ if (typeof buf === 'string') {
300
+ // cast string input to ArrayBuffer
301
+ buf = textEncoder.encode(buf);
302
+ }
303
+
304
+ if (buf instanceof Blob || Object.prototype.toString.call(buf) === '[object Blob]') {
305
+ // can't process blob directly, cast to ArrayBuffer
306
+ buf = await blobToArrayBuffer(buf);
307
+ }
308
+
309
+ if (buf.buffer instanceof ArrayBuffer) {
310
+ // Node.js Buffer object or Uint8Array
311
+ buf = new Uint8Array(buf).buffer;
312
+ }
313
+
314
+ this.buf = buf;
315
+ this.av = new Uint8Array(buf);
316
+ this.readPos = 0;
317
+
318
+ while (this.readPos < this.av.length) {
319
+ const line = this.readLine();
320
+
321
+ await this.processLine(line.bytes, line.done);
322
+ }
323
+
324
+ await this.processNodeTree();
325
+
326
+ let message = {
327
+ headers: this.root.headers.map(entry => ({ key: entry.key, value: entry.value })).reverse()
328
+ };
329
+
330
+ for (let key of ['from', 'sender', 'reply-to']) {
331
+ let addressHeader = this.root.headers.find(line => line.key === key);
332
+ if (addressHeader && addressHeader.value) {
333
+ let addresses = addressParser(addressHeader.value);
334
+ if (addresses && addresses.length) {
335
+ message[key.replace(/\-(.)/g, (o, c) => c.toUpperCase())] = addresses[0];
336
+ }
337
+ }
338
+ }
339
+
340
+ for (let key of ['delivered-to', 'return-path']) {
341
+ let addressHeader = this.root.headers.find(line => line.key === key);
342
+ if (addressHeader && addressHeader.value) {
343
+ let addresses = addressParser(addressHeader.value);
344
+ if (addresses && addresses.length && addresses[0].address) {
345
+ message[key.replace(/\-(.)/g, (o, c) => c.toUpperCase())] = addresses[0].address;
346
+ }
347
+ }
348
+ }
349
+
350
+ for (let key of ['to', 'cc', 'bcc']) {
351
+ let addressHeaders = this.root.headers.filter(line => line.key === key);
352
+ let addresses = [];
353
+
354
+ addressHeaders
355
+ .filter(entry => entry && entry.value)
356
+ .map(entry => addressParser(entry.value))
357
+ .forEach(parsed => (addresses = addresses.concat(parsed || [])));
358
+
359
+ if (addresses && addresses.length) {
360
+ message[key] = addresses;
361
+ }
362
+ }
363
+
364
+ for (let key of ['subject', 'message-id', 'in-reply-to', 'references']) {
365
+ let header = this.root.headers.find(line => line.key === key);
366
+ if (header && header.value) {
367
+ message[key.replace(/\-(.)/g, (o, c) => c.toUpperCase())] = decodeWords(header.value);
368
+ }
369
+ }
370
+
371
+ let dateHeader = this.root.headers.find(line => line.key === 'date');
372
+ if (dateHeader) {
373
+ let date = new Date(dateHeader.value);
374
+ if (!date || date.toString() === 'Invalid Date') {
375
+ date = dateHeader.value;
376
+ } else {
377
+ // enforce ISO format if seems to be a valid date
378
+ date = date.toISOString();
379
+ }
380
+ message.date = date;
381
+ }
382
+
383
+ if (this.textContent && this.textContent.html) {
384
+ message.html = this.textContent.html;
385
+ }
386
+
387
+ if (this.textContent && this.textContent.plain) {
388
+ message.text = this.textContent.plain;
389
+ }
390
+
391
+ message.attachments = this.attachments;
392
+
393
+ return message;
394
+ }
395
+ }