FRE-709: Document duplicate recovery wake - FRE-635 already recovered via FRE-708
This commit is contained in:
395
node_modules/postal-mime/src/address-parser.js
generated
vendored
Normal file
395
node_modules/postal-mime/src/address-parser.js
generated
vendored
Normal file
@@ -0,0 +1,395 @@
|
||||
import { decodeWords } from './decode-strings.js';
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param {Array} tokens Tokens object
|
||||
* @param {Number} depth Current recursion depth for nested group protection
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens, depth) {
|
||||
let isGroup = false;
|
||||
let state = 'text';
|
||||
let address;
|
||||
let addresses = [];
|
||||
let data = {
|
||||
address: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: [],
|
||||
textWasQuoted: [] // Track which text tokens came from inside quotes
|
||||
};
|
||||
let i;
|
||||
let len;
|
||||
let insideQuotes = false; // Track if we're currently inside a quoted string
|
||||
|
||||
// Filter out <addresses>, (comments) and regular text
|
||||
for (i = 0, len = tokens.length; i < len; i++) {
|
||||
let token = tokens[i];
|
||||
let prevToken = i ? tokens[i - 1] : null;
|
||||
if (token.type === 'operator') {
|
||||
switch (token.value) {
|
||||
case '<':
|
||||
state = 'address';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case '(':
|
||||
state = 'comment';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case ':':
|
||||
state = 'group';
|
||||
isGroup = true;
|
||||
insideQuotes = false;
|
||||
break;
|
||||
case '"':
|
||||
// Track quote state for text tokens
|
||||
insideQuotes = !insideQuotes;
|
||||
state = 'text';
|
||||
break;
|
||||
default:
|
||||
state = 'text';
|
||||
insideQuotes = false;
|
||||
break;
|
||||
}
|
||||
} else if (token.value) {
|
||||
if (state === 'address') {
|
||||
// handle use case where unquoted name includes a "<"
|
||||
// Apple Mail truncates everything between an unexpected < and an address
|
||||
// and so will we
|
||||
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
||||
}
|
||||
|
||||
if (prevToken && prevToken.noBreak && data[state].length) {
|
||||
// join values
|
||||
data[state][data[state].length - 1] += token.value;
|
||||
if (state === 'text' && insideQuotes) {
|
||||
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
||||
}
|
||||
} else {
|
||||
data[state].push(token.value);
|
||||
if (state === 'text') {
|
||||
data.textWasQuoted.push(insideQuotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
data.text = data.text.join(' ');
|
||||
|
||||
// Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
|
||||
let groupMembers = [];
|
||||
if (data.group.length) {
|
||||
let parsedGroup = addressParser(data.group.join(','), { _depth: depth + 1 });
|
||||
// Flatten: if any member is itself a group, extract its members into the sequence
|
||||
parsedGroup.forEach(member => {
|
||||
if (member.group) {
|
||||
// Nested group detected - flatten it by adding its members directly
|
||||
groupMembers = groupMembers.concat(member.group);
|
||||
} else {
|
||||
groupMembers.push(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addresses.push({
|
||||
name: decodeWords(data.text || (address && address.name)),
|
||||
group: groupMembers
|
||||
});
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.address.length && data.text.length) {
|
||||
for (i = data.text.length - 1; i >= 0; i--) {
|
||||
// Security fix: Do not extract email addresses from quoted strings
|
||||
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
|
||||
// Extracting emails from quoted text leads to misrouting vulnerabilities
|
||||
if (!data.textWasQuoted[i] && data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.address = data.text.splice(i, 1);
|
||||
data.textWasQuoted.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let _regexHandler = function (address) {
|
||||
if (!data.address.length) {
|
||||
data.address = [address.trim()];
|
||||
return ' ';
|
||||
} else {
|
||||
return address;
|
||||
}
|
||||
};
|
||||
|
||||
// still no address
|
||||
if (!data.address.length) {
|
||||
for (i = data.text.length - 1; i >= 0; i--) {
|
||||
// Security fix: Do not extract email addresses from quoted strings
|
||||
if (!data.textWasQuoted[i]) {
|
||||
// fixed the regex to parse email address correctly when email address has more than one @
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
|
||||
if (data.address.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
// Keep only the first address occurrence, push others to regular text
|
||||
if (data.address.length > 1) {
|
||||
data.text = data.text.concat(data.address.splice(1));
|
||||
}
|
||||
|
||||
// Join values with spaces
|
||||
data.text = data.text.join(' ');
|
||||
data.address = data.address.join(' ');
|
||||
|
||||
if (!data.address && /^=\?[^=]+?=$/.test(data.text.trim())) {
|
||||
// try to extract words from text content
|
||||
const decodedText = decodeWords(data.text);
|
||||
// Security: only re-parse if decoded text contains angle-bracket addresses.
|
||||
// Without this, a bare encoded email (e.g. =?utf-8?B?dGVzdEBldmlsLmNv?=)
|
||||
// would be fabricated into an address from attacker-controlled input.
|
||||
if (/<[^<>]+@[^<>]+>/.test(decodedText)) {
|
||||
const parsedSubAddresses = addressParser(decodedText);
|
||||
if (parsedSubAddresses && parsedSubAddresses.length) {
|
||||
return parsedSubAddresses;
|
||||
}
|
||||
}
|
||||
// No usable address found - treat decoded text as display name only
|
||||
return [{ address: '', name: decodedText }];
|
||||
}
|
||||
|
||||
address = {
|
||||
address: data.address || data.text || '',
|
||||
name: decodeWords(data.text || data.address || '')
|
||||
};
|
||||
|
||||
if (address.address === address.name) {
|
||||
if ((address.address || '').match(/@/)) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.address = '';
|
||||
}
|
||||
}
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Tokenizer object for tokenizing address field strings
|
||||
*
|
||||
* @constructor
|
||||
* @param {String} str Address field string
|
||||
*/
|
||||
class Tokenizer {
|
||||
constructor(str) {
|
||||
this.str = (str || '').toString();
|
||||
this.operatorCurrent = '';
|
||||
this.operatorExpecting = '';
|
||||
this.node = null;
|
||||
this.escaped = false;
|
||||
|
||||
this.list = [];
|
||||
/**
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
this.operators = {
|
||||
'"': '"',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
',': '',
|
||||
':': ';',
|
||||
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||
// than for terminating a group, but they are also not valid for any
|
||||
// other use in this context. Given that some mail clients have
|
||||
// historically allowed the semicolon as a delimiter equivalent to the
|
||||
// comma in their UI, it makes sense to treat them the same as a comma
|
||||
// when used outside of a group.
|
||||
';': ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenizes the original input string
|
||||
*
|
||||
* @return {Array} An array of operator|text tokens
|
||||
*/
|
||||
tokenize() {
|
||||
let list = [];
|
||||
|
||||
for (let i = 0, len = this.str.length; i < len; i++) {
|
||||
let chr = this.str.charAt(i);
|
||||
let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
|
||||
this.checkChar(chr, nextChr);
|
||||
}
|
||||
|
||||
this.list.forEach(node => {
|
||||
node.value = (node.value || '').toString().trim();
|
||||
if (node.value) {
|
||||
list.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a character is an operator or text and acts accordingly
|
||||
*
|
||||
* @param {String} chr Character from the address field
|
||||
*/
|
||||
checkChar(chr, nextChr) {
|
||||
if (this.escaped) {
|
||||
// ignore next condition blocks
|
||||
} else if (chr === this.operatorExpecting) {
|
||||
this.node = {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
};
|
||||
|
||||
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
|
||||
this.node.noBreak = true;
|
||||
}
|
||||
|
||||
this.list.push(this.node);
|
||||
this.node = null;
|
||||
this.operatorExpecting = '';
|
||||
this.escaped = false;
|
||||
|
||||
return;
|
||||
} else if (!this.operatorExpecting && chr in this.operators) {
|
||||
this.node = {
|
||||
type: 'operator',
|
||||
value: chr
|
||||
};
|
||||
this.list.push(this.node);
|
||||
this.node = null;
|
||||
this.operatorExpecting = this.operators[chr];
|
||||
this.escaped = false;
|
||||
return;
|
||||
} else if (this.operatorExpecting === '"' && chr === '\\') {
|
||||
this.escaped = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.node) {
|
||||
this.node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
};
|
||||
this.list.push(this.node);
|
||||
}
|
||||
|
||||
if (chr === '\n') {
|
||||
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
|
||||
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
|
||||
chr = ' ';
|
||||
}
|
||||
|
||||
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
|
||||
// skip command bytes
|
||||
this.node.value += chr;
|
||||
}
|
||||
|
||||
this.escaped = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum recursion depth for parsing nested groups.
|
||||
* RFC 5322 doesn't allow nested groups, so this is a safeguard against
|
||||
* malicious input that could cause stack overflow.
|
||||
*/
|
||||
const MAX_NESTED_GROUP_DEPTH = 50;
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* 'Name <address@domain>'
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: 'Name', address: 'address@domain'}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @param {Object} options Optional options object
|
||||
* @param {Number} options._depth Internal recursion depth counter (do not set manually)
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
function addressParser(str, options) {
|
||||
options = options || {};
|
||||
let depth = options._depth || 0;
|
||||
|
||||
// Prevent stack overflow from deeply nested groups (DoS protection)
|
||||
if (depth > MAX_NESTED_GROUP_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let tokenizer = new Tokenizer(str);
|
||||
let tokens = tokenizer.tokenize();
|
||||
|
||||
let addresses = [];
|
||||
let address = [];
|
||||
let parsedAddresses = [];
|
||||
|
||||
tokens.forEach(token => {
|
||||
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
|
||||
if (address.length) {
|
||||
addresses.push(address);
|
||||
}
|
||||
address = [];
|
||||
} else {
|
||||
address.push(token);
|
||||
}
|
||||
});
|
||||
|
||||
if (address.length) {
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
addresses.forEach(address => {
|
||||
address = _handleAddress(address, depth);
|
||||
if (address.length) {
|
||||
parsedAddresses = parsedAddresses.concat(address);
|
||||
}
|
||||
});
|
||||
|
||||
if (options.flatten) {
|
||||
let addresses = [];
|
||||
let walkAddressList = list => {
|
||||
list.forEach(address => {
|
||||
if (address.group) {
|
||||
return walkAddressList(address.group);
|
||||
} else {
|
||||
addresses.push(address);
|
||||
}
|
||||
});
|
||||
};
|
||||
walkAddressList(parsedAddresses);
|
||||
return addresses;
|
||||
}
|
||||
|
||||
return parsedAddresses;
|
||||
}
|
||||
|
||||
// expose to the world
|
||||
export default addressParser;
|
||||
48
node_modules/postal-mime/src/base64-decoder.js
generated
vendored
Normal file
48
node_modules/postal-mime/src/base64-decoder.js
generated
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
|
||||
|
||||
export default class Base64Decoder {
|
||||
constructor(opts) {
|
||||
opts = opts || {};
|
||||
|
||||
this.decoder = opts.decoder || new TextDecoder();
|
||||
|
||||
this.maxChunkSize = 100 * 1024;
|
||||
|
||||
this.chunks = [];
|
||||
|
||||
this.remainder = '';
|
||||
}
|
||||
|
||||
update(buffer) {
|
||||
let str = this.decoder.decode(buffer);
|
||||
|
||||
str = str.replace(/[^a-zA-Z0-9+\/]+/g, '');
|
||||
|
||||
this.remainder += str;
|
||||
|
||||
if (this.remainder.length >= this.maxChunkSize) {
|
||||
let allowedBytes = Math.floor(this.remainder.length / 4) * 4;
|
||||
let base64Str;
|
||||
|
||||
if (allowedBytes === this.remainder.length) {
|
||||
base64Str = this.remainder;
|
||||
this.remainder = '';
|
||||
} else {
|
||||
base64Str = this.remainder.substr(0, allowedBytes);
|
||||
this.remainder = this.remainder.substr(allowedBytes);
|
||||
}
|
||||
|
||||
if (base64Str.length) {
|
||||
this.chunks.push(decodeBase64(base64Str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalize() {
|
||||
if (this.remainder && !/^=+$/.test(this.remainder)) {
|
||||
this.chunks.push(decodeBase64(this.remainder));
|
||||
}
|
||||
|
||||
return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
}
|
||||
}
|
||||
69
node_modules/postal-mime/src/base64-encoder.js
generated
vendored
Normal file
69
node_modules/postal-mime/src/base64-encoder.js
generated
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
// Code from: https://gist.githubusercontent.com/jonleighton/958841/raw/fb05a8632efb75d85d43deb593df04367ce48371/base64ArrayBuffer.js
|
||||
|
||||
// Converts an ArrayBuffer directly to base64, without any intermediate 'convert to string then
|
||||
// use window.btoa' step. According to my tests, this appears to be a faster approach:
|
||||
// http://jsperf.com/encoding-xhr-image-data/5
|
||||
|
||||
/*
|
||||
MIT LICENSE
|
||||
|
||||
Copyright 2011 Jon Leighton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
export function base64ArrayBuffer(arrayBuffer) {
|
||||
var base64 = '';
|
||||
var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
var bytes = new Uint8Array(arrayBuffer);
|
||||
var byteLength = bytes.byteLength;
|
||||
var byteRemainder = byteLength % 3;
|
||||
var mainLength = byteLength - byteRemainder;
|
||||
|
||||
var a, b, c, d;
|
||||
var chunk;
|
||||
|
||||
// Main loop deals with bytes in chunks of 3
|
||||
for (var i = 0; i < mainLength; i = i + 3) {
|
||||
// Combine the three bytes into a single integer
|
||||
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||||
|
||||
// Use bitmasks to extract 6-bit segments from the triplet
|
||||
a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
|
||||
b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
|
||||
c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
|
||||
d = chunk & 63; // 63 = 2^6 - 1
|
||||
|
||||
// Convert the raw binary segments to the appropriate ASCII encoding
|
||||
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
|
||||
}
|
||||
|
||||
// Deal with the remaining bytes and padding
|
||||
if (byteRemainder == 1) {
|
||||
chunk = bytes[mainLength];
|
||||
|
||||
a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
|
||||
|
||||
// Set the 4 least significant bits to zero
|
||||
b = (chunk & 3) << 4; // 3 = 2^2 - 1
|
||||
|
||||
base64 += encodings[a] + encodings[b] + '==';
|
||||
} else if (byteRemainder == 2) {
|
||||
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
|
||||
|
||||
a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
|
||||
b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
|
||||
|
||||
// Set the 2 least significant bits to zero
|
||||
c = (chunk & 15) << 2; // 15 = 2^4 - 1
|
||||
|
||||
base64 += encodings[a] + encodings[b] + encodings[c] + '=';
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
287
node_modules/postal-mime/src/decode-strings.js
generated
vendored
Normal file
287
node_modules/postal-mime/src/decode-strings.js
generated
vendored
Normal file
@@ -0,0 +1,287 @@
|
||||
export const textEncoder = new TextEncoder();
|
||||
|
||||
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
// Use a lookup table to find the index.
|
||||
const base64Lookup = new Uint8Array(256);
|
||||
for (let i = 0; i < base64Chars.length; i++) {
|
||||
base64Lookup[base64Chars.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
export function decodeBase64(base64) {
|
||||
let bufferLength = Math.ceil(base64.length / 4) * 3;
|
||||
const len = base64.length;
|
||||
|
||||
let p = 0;
|
||||
|
||||
if (base64.length % 4 === 3) {
|
||||
bufferLength--;
|
||||
} else if (base64.length % 4 === 2) {
|
||||
bufferLength -= 2;
|
||||
} else if (base64[base64.length - 1] === '=') {
|
||||
bufferLength--;
|
||||
if (base64[base64.length - 2] === '=') {
|
||||
bufferLength--;
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = new ArrayBuffer(bufferLength);
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < len; i += 4) {
|
||||
let encoded1 = base64Lookup[base64.charCodeAt(i)];
|
||||
let encoded2 = base64Lookup[base64.charCodeAt(i + 1)];
|
||||
let encoded3 = base64Lookup[base64.charCodeAt(i + 2)];
|
||||
let encoded4 = base64Lookup[base64.charCodeAt(i + 3)];
|
||||
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
||||
}
|
||||
|
||||
return arrayBuffer;
|
||||
}
|
||||
|
||||
export function getDecoder(charset) {
|
||||
charset = charset || 'utf8';
|
||||
let decoder;
|
||||
|
||||
try {
|
||||
decoder = new TextDecoder(charset);
|
||||
} catch (err) {
|
||||
decoder = new TextDecoder('windows-1252');
|
||||
}
|
||||
|
||||
return decoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Blob into an ArrayBuffer
|
||||
* @param {Blob} blob Blob to convert
|
||||
* @returns {ArrayBuffer} Converted value
|
||||
*/
|
||||
export async function blobToArrayBuffer(blob) {
|
||||
if ('arrayBuffer' in blob) {
|
||||
return await blob.arrayBuffer();
|
||||
}
|
||||
|
||||
const fr = new FileReader();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fr.onload = function (e) {
|
||||
resolve(e.target.result);
|
||||
};
|
||||
|
||||
fr.onerror = function (e) {
|
||||
reject(fr.error);
|
||||
};
|
||||
|
||||
fr.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function getHex(c) {
|
||||
if (
|
||||
(c >= 0x30 /* 0 */ && c <= 0x39) /* 9 */ ||
|
||||
(c >= 0x61 /* a */ && c <= 0x66) /* f */ ||
|
||||
(c >= 0x41 /* A */ && c <= 0x46) /* F */
|
||||
) {
|
||||
return String.fromCharCode(c);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a complete mime word encoded string
|
||||
*
|
||||
* @param {String} str Mime word encoded string
|
||||
* @return {String} Decoded unicode string
|
||||
*/
|
||||
export function decodeWord(charset, encoding, str) {
|
||||
// RFC2231 added language tag to the encoding
|
||||
// see: https://tools.ietf.org/html/rfc2231#section-5
|
||||
// this implementation silently ignores this tag
|
||||
let splitPos = charset.indexOf('*');
|
||||
if (splitPos >= 0) {
|
||||
charset = charset.substr(0, splitPos);
|
||||
}
|
||||
|
||||
encoding = encoding.toUpperCase();
|
||||
|
||||
let byteStr;
|
||||
|
||||
if (encoding === 'Q') {
|
||||
str = str
|
||||
// remove spaces between = and hex char, this might indicate invalidly applied line splitting
|
||||
.replace(/=\s+([0-9a-fA-F])/g, '=$1')
|
||||
// convert all underscores to spaces
|
||||
.replace(/[_\s]/g, ' ');
|
||||
|
||||
let buf = textEncoder.encode(str);
|
||||
let encodedBytes = [];
|
||||
for (let i = 0, len = buf.length; i < len; i++) {
|
||||
let c = buf[i];
|
||||
if (i <= len - 2 && c === 0x3d /* = */) {
|
||||
let c1 = getHex(buf[i + 1]);
|
||||
let c2 = getHex(buf[i + 2]);
|
||||
if (c1 && c2) {
|
||||
let c = parseInt(c1 + c2, 16);
|
||||
encodedBytes.push(c);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
encodedBytes.push(c);
|
||||
}
|
||||
byteStr = new ArrayBuffer(encodedBytes.length);
|
||||
let dataView = new DataView(byteStr);
|
||||
for (let i = 0, len = encodedBytes.length; i < len; i++) {
|
||||
dataView.setUint8(i, encodedBytes[i]);
|
||||
}
|
||||
} else if (encoding === 'B') {
|
||||
byteStr = decodeBase64(str.replace(/[^a-zA-Z0-9\+\/=]+/g, ''));
|
||||
} else {
|
||||
// keep as is, convert ArrayBuffer to unicode string, assume utf8
|
||||
byteStr = textEncoder.encode(str);
|
||||
}
|
||||
|
||||
return getDecoder(charset).decode(byteStr);
|
||||
}
|
||||
|
||||
export function decodeWords(str) {
|
||||
let joinString = true;
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
let result = (str || '')
|
||||
.toString()
|
||||
// find base64 words that can be joined
|
||||
.replace(
|
||||
/(=\?([^?]+)\?[Bb]\?([^?]*)\?=)\s*(?==\?([^?]+)\?[Bb]\?[^?]*\?=)/g,
|
||||
(match, left, chLeft, encodedLeftStr, chRight) => {
|
||||
if (!joinString) {
|
||||
return match;
|
||||
}
|
||||
// only mark b64 chunks to be joined if charsets match and left side does not end with =
|
||||
if (chLeft === chRight && encodedLeftStr.length % 4 === 0 && !/=$/.test(encodedLeftStr)) {
|
||||
// set a joiner marker
|
||||
return left + '__\x00JOIN\x00__';
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
)
|
||||
// find QP words that can be joined
|
||||
.replace(
|
||||
/(=\?([^?]+)\?[Qq]\?[^?]*\?=)\s*(?==\?([^?]+)\?[Qq]\?[^?]*\?=)/g,
|
||||
(match, left, chLeft, chRight) => {
|
||||
if (!joinString) {
|
||||
return match;
|
||||
}
|
||||
// only mark QP chunks to be joined if charsets match
|
||||
if (chLeft === chRight) {
|
||||
// set a joiner marker
|
||||
return left + '__\x00JOIN\x00__';
|
||||
}
|
||||
return match;
|
||||
}
|
||||
)
|
||||
// join base64 encoded words
|
||||
.replace(/(\?=)?__\x00JOIN\x00__(=\?([^?]+)\?[QqBb]\?)?/g, '')
|
||||
// remove spaces between mime encoded words
|
||||
.replace(/(=\?[^?]+\?[QqBb]\?[^?]*\?=)\s+(?==\?[^?]+\?[QqBb]\?[^?]*\?=)/g, '$1')
|
||||
// decode words
|
||||
.replace(/=\?([\w_\-*]+)\?([QqBb])\?([^?]*)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeWord(charset, encoding, text)
|
||||
);
|
||||
|
||||
if (joinString && result.indexOf('\ufffd') >= 0) {
|
||||
// text contains \ufffd (EF BF BD), so unicode conversion failed, retry without joining strings
|
||||
joinString = false;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeURIComponentWithCharset(encodedStr, charset) {
|
||||
charset = charset || 'utf-8';
|
||||
|
||||
let encodedBytes = [];
|
||||
for (let i = 0; i < encodedStr.length; i++) {
|
||||
let c = encodedStr.charAt(i);
|
||||
if (c === '%' && /^[a-f0-9]{2}/i.test(encodedStr.substr(i + 1, 2))) {
|
||||
// encoded sequence
|
||||
let byte = encodedStr.substr(i + 1, 2);
|
||||
i += 2;
|
||||
encodedBytes.push(parseInt(byte, 16));
|
||||
} else if (c.charCodeAt(0) > 126) {
|
||||
c = textEncoder.encode(c);
|
||||
for (let j = 0; j < c.length; j++) {
|
||||
encodedBytes.push(c[j]);
|
||||
}
|
||||
} else {
|
||||
// "normal" char
|
||||
encodedBytes.push(c.charCodeAt(0));
|
||||
}
|
||||
}
|
||||
|
||||
const byteStr = new ArrayBuffer(encodedBytes.length);
|
||||
const dataView = new DataView(byteStr);
|
||||
for (let i = 0, len = encodedBytes.length; i < len; i++) {
|
||||
dataView.setUint8(i, encodedBytes[i]);
|
||||
}
|
||||
|
||||
return getDecoder(charset).decode(byteStr);
|
||||
}
|
||||
|
||||
export function decodeParameterValueContinuations(header) {
|
||||
// handle parameter value continuations
|
||||
// https://tools.ietf.org/html/rfc2231#section-3
|
||||
|
||||
// preprocess values
|
||||
let paramKeys = new Map();
|
||||
|
||||
Object.keys(header.params).forEach(key => {
|
||||
let match = key.match(/\*((\d+)\*?)?$/);
|
||||
if (!match) {
|
||||
// nothing to do here, does not seem like a continuation param
|
||||
return;
|
||||
}
|
||||
|
||||
let actualKey = key.substr(0, match.index).toLowerCase();
|
||||
let nr = Number(match[2]) || 0;
|
||||
|
||||
let paramVal;
|
||||
if (!paramKeys.has(actualKey)) {
|
||||
paramVal = {
|
||||
charset: false,
|
||||
values: []
|
||||
};
|
||||
paramKeys.set(actualKey, paramVal);
|
||||
} else {
|
||||
paramVal = paramKeys.get(actualKey);
|
||||
}
|
||||
|
||||
let value = header.params[key];
|
||||
if (nr === 0 && match[0].charAt(match[0].length - 1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
|
||||
paramVal.charset = match[1] || 'utf-8';
|
||||
value = match[2];
|
||||
}
|
||||
|
||||
paramVal.values.push({ nr, value });
|
||||
|
||||
// remove the old reference
|
||||
delete header.params[key];
|
||||
});
|
||||
|
||||
paramKeys.forEach((paramVal, key) => {
|
||||
header.params[key] = decodeURIComponentWithCharset(
|
||||
paramVal.values
|
||||
.sort((a, b) => a.nr - b.nr)
|
||||
.map(a => a.value)
|
||||
.join(''),
|
||||
paramVal.charset
|
||||
);
|
||||
});
|
||||
}
|
||||
2236
node_modules/postal-mime/src/html-entities.js
generated
vendored
Normal file
2236
node_modules/postal-mime/src/html-entities.js
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
373
node_modules/postal-mime/src/mime-node.js
generated
vendored
Normal file
373
node_modules/postal-mime/src/mime-node.js
generated
vendored
Normal file
@@ -0,0 +1,373 @@
|
||||
import { getDecoder, decodeParameterValueContinuations, textEncoder } from './decode-strings.js';
|
||||
import PassThroughDecoder from './pass-through-decoder.js';
|
||||
import Base64Decoder from './base64-decoder.js';
|
||||
import QPDecoder from './qp-decoder.js';
|
||||
|
||||
const defaultDecoder = getDecoder();
|
||||
|
||||
export default class MimeNode {
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
|
||||
this.postalMime = this.options.postalMime;
|
||||
|
||||
this.root = !!this.options.parentNode;
|
||||
this.childNodes = [];
|
||||
|
||||
if (this.options.parentNode) {
|
||||
this.parentNode = this.options.parentNode;
|
||||
|
||||
this.depth = this.parentNode.depth + 1;
|
||||
if (this.depth > this.options.maxNestingDepth) {
|
||||
throw new Error(`Maximum MIME nesting depth of ${this.options.maxNestingDepth} levels exceeded`);
|
||||
}
|
||||
|
||||
this.options.parentNode.childNodes.push(this);
|
||||
} else {
|
||||
this.depth = 0;
|
||||
}
|
||||
|
||||
this.state = 'header';
|
||||
|
||||
this.headerLines = [];
|
||||
this.headerSize = 0;
|
||||
|
||||
// RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
|
||||
const parentMultipartType = this.options.parentMultipartType || null;
|
||||
const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
|
||||
|
||||
this.contentType = {
|
||||
value: defaultContentType,
|
||||
default: true
|
||||
};
|
||||
|
||||
this.contentTransferEncoding = {
|
||||
value: '8bit'
|
||||
};
|
||||
|
||||
this.contentDisposition = {
|
||||
value: ''
|
||||
};
|
||||
|
||||
this.headers = [];
|
||||
|
||||
this.contentDecoder = false;
|
||||
}
|
||||
|
||||
setupContentDecoder(transferEncoding) {
|
||||
if (/base64/i.test(transferEncoding)) {
|
||||
this.contentDecoder = new Base64Decoder();
|
||||
} else if (/quoted-printable/i.test(transferEncoding)) {
|
||||
this.contentDecoder = new QPDecoder({ decoder: getDecoder(this.contentType.parsed.params.charset) });
|
||||
} else {
|
||||
this.contentDecoder = new PassThroughDecoder();
|
||||
}
|
||||
}
|
||||
|
||||
async finalize() {
|
||||
if (this.state === 'finished') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state === 'header') {
|
||||
this.processHeaders();
|
||||
}
|
||||
|
||||
// remove self from boundary listing
|
||||
let boundaries = this.postalMime.boundaries;
|
||||
for (let i = boundaries.length - 1; i >= 0; i--) {
|
||||
let boundary = boundaries[i];
|
||||
if (boundary.node === this) {
|
||||
boundaries.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.finalizeChildNodes();
|
||||
|
||||
this.content = this.contentDecoder ? await this.contentDecoder.finalize() : null;
|
||||
|
||||
this.state = 'finished';
|
||||
}
|
||||
|
||||
async finalizeChildNodes() {
|
||||
for (let childNode of this.childNodes) {
|
||||
await childNode.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
// Strip RFC 822 comments (parenthesized text) from structured header values
|
||||
stripComments(str) {
|
||||
let result = '';
|
||||
let depth = 0;
|
||||
let escaped = false;
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const chr = str.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
if (depth === 0) {
|
||||
result += chr;
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chr === '\\') {
|
||||
escaped = true;
|
||||
if (depth === 0) {
|
||||
result += chr;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chr === '"' && depth === 0) {
|
||||
inQuote = !inQuote;
|
||||
result += chr;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuote) {
|
||||
if (chr === '(') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (chr === ')' && depth > 0) {
|
||||
depth--;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
result += chr;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
parseStructuredHeader(str) {
|
||||
// Strip RFC 822 comments before parsing
|
||||
str = this.stripComments(str);
|
||||
|
||||
let response = {
|
||||
value: false,
|
||||
params: {}
|
||||
};
|
||||
|
||||
let key = false;
|
||||
let value = '';
|
||||
let stage = 'value';
|
||||
|
||||
let quote = false;
|
||||
let escaped = false;
|
||||
let chr;
|
||||
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
chr = str.charAt(i);
|
||||
switch (stage) {
|
||||
case 'key':
|
||||
if (chr === '=') {
|
||||
key = value.trim().toLowerCase();
|
||||
stage = 'value';
|
||||
value = '';
|
||||
break;
|
||||
}
|
||||
value += chr;
|
||||
break;
|
||||
case 'value':
|
||||
if (escaped) {
|
||||
value += chr;
|
||||
} else if (chr === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
} else if (quote && chr === quote) {
|
||||
quote = false;
|
||||
} else if (!quote && chr === '"') {
|
||||
quote = chr;
|
||||
} else if (!quote && chr === ';') {
|
||||
if (key === false) {
|
||||
response.value = value.trim();
|
||||
} else {
|
||||
response.params[key] = value.trim();
|
||||
}
|
||||
stage = 'key';
|
||||
value = '';
|
||||
} else {
|
||||
value += chr;
|
||||
}
|
||||
escaped = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// finalize remainder
|
||||
value = value.trim();
|
||||
if (stage === 'value') {
|
||||
if (key === false) {
|
||||
// default value
|
||||
response.value = value;
|
||||
} else {
|
||||
// subkey value
|
||||
response.params[key] = value;
|
||||
}
|
||||
} else if (value) {
|
||||
// treat as key without value, see emptykey:
|
||||
// Header-Key: somevalue; key=value; emptykey
|
||||
response.params[value.toLowerCase()] = '';
|
||||
}
|
||||
|
||||
if (response.value) {
|
||||
response.value = response.value.toLowerCase();
|
||||
}
|
||||
|
||||
// convert Parameter Value Continuations into single strings
|
||||
decodeParameterValueContinuations(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
decodeFlowedText(str, delSp) {
|
||||
return (
|
||||
str
|
||||
.split(/\r?\n/)
|
||||
// remove soft linebreaks
|
||||
// soft linebreaks are added after space symbols
|
||||
.reduce((previousValue, currentValue) => {
|
||||
if (previousValue.endsWith(' ') && previousValue !== '-- ' && !previousValue.endsWith('\n-- ')) {
|
||||
if (delSp) {
|
||||
// delsp adds space to text to be able to fold it
|
||||
// these spaces can be removed once the text is unfolded
|
||||
return previousValue.slice(0, -1) + currentValue;
|
||||
} else {
|
||||
return previousValue + currentValue;
|
||||
}
|
||||
} else {
|
||||
return previousValue + '\n' + currentValue;
|
||||
}
|
||||
})
|
||||
// remove whitespace stuffing
|
||||
// http://tools.ietf.org/html/rfc3676#section-4.4
|
||||
.replace(/^ /gm, '')
|
||||
);
|
||||
}
|
||||
|
||||
getTextContent() {
|
||||
if (!this.content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let str = getDecoder(this.contentType.parsed.params.charset).decode(this.content);
|
||||
|
||||
if (/^flowed$/i.test(this.contentType.parsed.params.format)) {
|
||||
str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp));
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
processHeaders() {
|
||||
// First pass: merge folded headers (backward iteration)
|
||||
for (let i = this.headerLines.length - 1; i >= 0; i--) {
|
||||
let line = this.headerLines[i];
|
||||
if (i && /^\s/.test(line)) {
|
||||
this.headerLines[i - 1] += '\n' + line;
|
||||
this.headerLines.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize rawHeaderLines to store unmodified lines
|
||||
this.rawHeaderLines = [];
|
||||
|
||||
// Second pass: process headers (MUST be backward to maintain this.headers order)
|
||||
// The existing code iterates backward and postal-mime.js calls .reverse()
|
||||
// We must preserve this behavior to avoid breaking changes
|
||||
for (let i = this.headerLines.length - 1; i >= 0; i--) {
|
||||
let rawLine = this.headerLines[i];
|
||||
|
||||
// Extract key from raw line for rawHeaderLines
|
||||
let sep = rawLine.indexOf(':');
|
||||
let rawKey = sep < 0 ? rawLine.trim() : rawLine.substr(0, sep).trim();
|
||||
|
||||
// Store raw line with lowercase key
|
||||
this.rawHeaderLines.push({
|
||||
key: rawKey.toLowerCase(),
|
||||
line: rawLine
|
||||
});
|
||||
|
||||
// Normalize for this.headers (existing behavior - order preserved)
|
||||
let normalizedLine = rawLine.replace(/\s+/g, ' ');
|
||||
sep = normalizedLine.indexOf(':');
|
||||
let key = sep < 0 ? normalizedLine.trim() : normalizedLine.substr(0, sep).trim();
|
||||
let value = sep < 0 ? '' : normalizedLine.substr(sep + 1).trim();
|
||||
this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'content-type':
|
||||
if (this.contentType.default) {
|
||||
this.contentType = { value, parsed: {} };
|
||||
}
|
||||
break;
|
||||
case 'content-transfer-encoding':
|
||||
this.contentTransferEncoding = { value, parsed: {} };
|
||||
break;
|
||||
case 'content-disposition':
|
||||
this.contentDisposition = { value, parsed: {} };
|
||||
break;
|
||||
case 'content-id':
|
||||
this.contentId = value;
|
||||
break;
|
||||
case 'content-description':
|
||||
this.contentDescription = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.contentType.parsed = this.parseStructuredHeader(this.contentType.value);
|
||||
this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value)
|
||||
? this.contentType.parsed.value.substr(this.contentType.parsed.value.indexOf('/') + 1)
|
||||
: false;
|
||||
|
||||
if (this.contentType.multipart && this.contentType.parsed.params.boundary) {
|
||||
// add self to boundary terminator listing
|
||||
this.postalMime.boundaries.push({
|
||||
value: textEncoder.encode(this.contentType.parsed.params.boundary),
|
||||
node: this
|
||||
});
|
||||
}
|
||||
|
||||
this.contentDisposition.parsed = this.parseStructuredHeader(this.contentDisposition.value);
|
||||
|
||||
this.contentTransferEncoding.encoding = this.contentTransferEncoding.value
|
||||
.toLowerCase()
|
||||
.split(/[^\w-]/)
|
||||
.shift();
|
||||
|
||||
this.setupContentDecoder(this.contentTransferEncoding.encoding);
|
||||
}
|
||||
|
||||
feed(line) {
|
||||
switch (this.state) {
|
||||
case 'header':
|
||||
if (!line.length) {
|
||||
this.state = 'body';
|
||||
return this.processHeaders();
|
||||
}
|
||||
|
||||
this.headerSize += line.length;
|
||||
|
||||
if (this.headerSize > this.options.maxHeadersSize) {
|
||||
let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.headerLines.push(defaultDecoder.decode(line));
|
||||
break;
|
||||
case 'body': {
|
||||
// add line to body
|
||||
this.contentDecoder.update(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
node_modules/postal-mime/src/package.json
generated
vendored
Normal file
3
node_modules/postal-mime/src/package.json
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
17
node_modules/postal-mime/src/pass-through-decoder.js
generated
vendored
Normal file
17
node_modules/postal-mime/src/pass-through-decoder.js
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import { blobToArrayBuffer } from './decode-strings.js';
|
||||
|
||||
export default class PassThroughDecoder {
|
||||
constructor() {
|
||||
this.chunks = [];
|
||||
}
|
||||
|
||||
update(line) {
|
||||
this.chunks.push(line);
|
||||
this.chunks.push('\n');
|
||||
}
|
||||
|
||||
finalize() {
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
}
|
||||
}
|
||||
584
node_modules/postal-mime/src/postal-mime.js
generated
vendored
Normal file
584
node_modules/postal-mime/src/postal-mime.js
generated
vendored
Normal file
@@ -0,0 +1,584 @@
|
||||
import MimeNode from './mime-node.js';
|
||||
import { textToHtml, htmlToText, formatTextHeader, formatHtmlHeader } from './text-format.js';
|
||||
import addressParser from './address-parser.js';
|
||||
import { decodeWords, textEncoder, blobToArrayBuffer } from './decode-strings.js';
|
||||
import { base64ArrayBuffer } from './base64-encoder.js';
|
||||
|
||||
export { addressParser, decodeWords };
|
||||
|
||||
const MAX_NESTING_DEPTH = 256;
|
||||
const MAX_HEADERS_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
function toCamelCase(key) {
|
||||
return key.replace(/-(.)/g, (o, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export default class PostalMime {
|
||||
static parse(buf, options) {
|
||||
const parser = new PostalMime(options);
|
||||
return parser.parse(buf);
|
||||
}
|
||||
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
this.mimeOptions = {
|
||||
maxNestingDepth: this.options.maxNestingDepth || MAX_NESTING_DEPTH,
|
||||
maxHeadersSize: this.options.maxHeadersSize || MAX_HEADERS_SIZE
|
||||
};
|
||||
|
||||
this.root = this.currentNode = new MimeNode({
|
||||
postalMime: this,
|
||||
...this.mimeOptions
|
||||
});
|
||||
this.boundaries = [];
|
||||
|
||||
this.textContent = {};
|
||||
this.attachments = [];
|
||||
|
||||
this.attachmentEncoding =
|
||||
(this.options.attachmentEncoding || '')
|
||||
.toString()
|
||||
.replace(/[-_\s]/g, '')
|
||||
.trim()
|
||||
.toLowerCase() || 'arraybuffer';
|
||||
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
async finalize() {
|
||||
// close all pending nodes
|
||||
await this.root.finalize();
|
||||
}
|
||||
|
||||
async processLine(line, isFinal) {
|
||||
let boundaries = this.boundaries;
|
||||
|
||||
// check if this is a mime boundary
|
||||
if (boundaries.length && line.length > 2 && line[0] === 0x2d && line[1] === 0x2d) {
|
||||
// could be a boundary marker
|
||||
for (let i = boundaries.length - 1; i >= 0; i--) {
|
||||
let boundary = boundaries[i];
|
||||
|
||||
// Line must be at least long enough for "--" + boundary
|
||||
if (line.length < boundary.value.length + 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if boundary value matches
|
||||
let boundaryMatches = true;
|
||||
for (let j = 0; j < boundary.value.length; j++) {
|
||||
if (line[j + 2] !== boundary.value[j]) {
|
||||
boundaryMatches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!boundaryMatches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for terminator (-- after boundary) and determine where boundary ends
|
||||
let boundaryEnd = boundary.value.length + 2;
|
||||
let isTerminator = false;
|
||||
|
||||
if (
|
||||
line.length >= boundary.value.length + 4 &&
|
||||
line[boundary.value.length + 2] === 0x2d &&
|
||||
line[boundary.value.length + 3] === 0x2d
|
||||
) {
|
||||
isTerminator = true;
|
||||
boundaryEnd = boundary.value.length + 4;
|
||||
}
|
||||
|
||||
// RFC 2046: boundary line may have trailing whitespace (space/tab) before CRLF
|
||||
let hasValidTrailing = true;
|
||||
for (let j = boundaryEnd; j < line.length; j++) {
|
||||
if (line[j] !== 0x20 && line[j] !== 0x09) {
|
||||
hasValidTrailing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasValidTrailing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTerminator) {
|
||||
await boundary.node.finalize();
|
||||
|
||||
this.currentNode = boundary.node.parentNode || this.root;
|
||||
} else {
|
||||
// finalize any open child nodes (should be just one though)
|
||||
await boundary.node.finalizeChildNodes();
|
||||
|
||||
this.currentNode = new MimeNode({
|
||||
postalMime: this,
|
||||
parentNode: boundary.node,
|
||||
parentMultipartType: boundary.node.contentType.multipart,
|
||||
...this.mimeOptions
|
||||
});
|
||||
}
|
||||
|
||||
if (isFinal) {
|
||||
return this.finalize();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentNode.feed(line);
|
||||
|
||||
if (isFinal) {
|
||||
return this.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
readLine() {
|
||||
let startPos = this.readPos;
|
||||
let endPos = this.readPos;
|
||||
|
||||
while (this.readPos < this.av.length) {
|
||||
const c = this.av[this.readPos++];
|
||||
|
||||
if (c !== 0x0d && c !== 0x0a) {
|
||||
endPos = this.readPos;
|
||||
}
|
||||
|
||||
if (c === 0x0a) {
|
||||
return {
|
||||
bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
|
||||
done: this.readPos >= this.av.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
|
||||
done: this.readPos >= this.av.length
|
||||
};
|
||||
}
|
||||
|
||||
async processNodeTree() {
|
||||
// get text nodes
|
||||
|
||||
let textContent = {};
|
||||
|
||||
let textTypes = new Set();
|
||||
let textMap = (this.textMap = new Map());
|
||||
|
||||
let forceRfc822Attachments = this.forceRfc822Attachments();
|
||||
|
||||
let walk = async (node, alternative, related) => {
|
||||
alternative = alternative || false;
|
||||
related = related || false;
|
||||
|
||||
if (!node.contentType.multipart) {
|
||||
// is it inline message/rfc822
|
||||
if (this.isInlineMessageRfc822(node) && !forceRfc822Attachments) {
|
||||
const subParser = new PostalMime();
|
||||
node.subMessage = await subParser.parse(node.content);
|
||||
|
||||
if (!textMap.has(node)) {
|
||||
textMap.set(node, {});
|
||||
}
|
||||
|
||||
let textEntry = textMap.get(node);
|
||||
|
||||
// default to text if there is no content
|
||||
if (node.subMessage.text || !node.subMessage.html) {
|
||||
textEntry.plain = textEntry.plain || [];
|
||||
textEntry.plain.push({ type: 'subMessage', value: node.subMessage });
|
||||
textTypes.add('plain');
|
||||
}
|
||||
|
||||
if (node.subMessage.html) {
|
||||
textEntry.html = textEntry.html || [];
|
||||
textEntry.html.push({ type: 'subMessage', value: node.subMessage });
|
||||
textTypes.add('html');
|
||||
}
|
||||
|
||||
if (subParser.textMap) {
|
||||
subParser.textMap.forEach((subTextEntry, subTextNode) => {
|
||||
textMap.set(subTextNode, subTextEntry);
|
||||
});
|
||||
}
|
||||
|
||||
for (let attachment of node.subMessage.attachments || []) {
|
||||
this.attachments.push(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
// is it text?
|
||||
else if (this.isInlineTextNode(node)) {
|
||||
let textType = node.contentType.parsed.value.substr(node.contentType.parsed.value.indexOf('/') + 1);
|
||||
|
||||
let selectorNode = alternative || node;
|
||||
if (!textMap.has(selectorNode)) {
|
||||
textMap.set(selectorNode, {});
|
||||
}
|
||||
|
||||
let textEntry = textMap.get(selectorNode);
|
||||
textEntry[textType] = textEntry[textType] || [];
|
||||
textEntry[textType].push({ type: 'text', value: node.getTextContent() });
|
||||
textTypes.add(textType);
|
||||
}
|
||||
|
||||
// is it an attachment
|
||||
else if (node.content) {
|
||||
const filename =
|
||||
node.contentDisposition?.parsed?.params?.filename ||
|
||||
node.contentType.parsed.params.name ||
|
||||
null;
|
||||
const attachment = {
|
||||
filename: filename ? decodeWords(filename) : null,
|
||||
mimeType: node.contentType.parsed.value,
|
||||
disposition: node.contentDisposition?.parsed?.value || null
|
||||
};
|
||||
|
||||
if (related && node.contentId) {
|
||||
attachment.related = true;
|
||||
}
|
||||
|
||||
if (node.contentDescription) {
|
||||
attachment.description = node.contentDescription;
|
||||
}
|
||||
|
||||
if (node.contentId) {
|
||||
attachment.contentId = node.contentId;
|
||||
}
|
||||
|
||||
switch (node.contentType.parsed.value) {
|
||||
// Special handling for calendar events
|
||||
case 'text/calendar':
|
||||
case 'application/ics': {
|
||||
if (node.contentType.parsed.params.method) {
|
||||
attachment.method = node.contentType.parsed.params.method
|
||||
.toString()
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Enforce into unicode
|
||||
const decodedText = node.getTextContent().replace(/\r?\n/g, '\n').replace(/\n*$/, '\n');
|
||||
attachment.content = textEncoder.encode(decodedText);
|
||||
break;
|
||||
}
|
||||
|
||||
// Regular attachments
|
||||
default:
|
||||
attachment.content = node.content;
|
||||
}
|
||||
|
||||
this.attachments.push(attachment);
|
||||
}
|
||||
} else if (node.contentType.multipart === 'alternative') {
|
||||
alternative = node;
|
||||
} else if (node.contentType.multipart === 'related') {
|
||||
related = node;
|
||||
}
|
||||
|
||||
for (let childNode of node.childNodes) {
|
||||
await walk(childNode, alternative, related);
|
||||
}
|
||||
};
|
||||
|
||||
await walk(this.root, false, false);
|
||||
|
||||
textMap.forEach(mapEntry => {
|
||||
textTypes.forEach(textType => {
|
||||
if (!textContent[textType]) {
|
||||
textContent[textType] = [];
|
||||
}
|
||||
|
||||
if (mapEntry[textType]) {
|
||||
mapEntry[textType].forEach(textEntry => {
|
||||
switch (textEntry.type) {
|
||||
case 'text':
|
||||
textContent[textType].push(textEntry.value);
|
||||
break;
|
||||
|
||||
case 'subMessage':
|
||||
{
|
||||
switch (textType) {
|
||||
case 'html':
|
||||
textContent[textType].push(formatHtmlHeader(textEntry.value));
|
||||
break;
|
||||
case 'plain':
|
||||
textContent[textType].push(formatTextHeader(textEntry.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let alternativeType;
|
||||
switch (textType) {
|
||||
case 'html':
|
||||
alternativeType = 'plain';
|
||||
break;
|
||||
case 'plain':
|
||||
alternativeType = 'html';
|
||||
break;
|
||||
}
|
||||
|
||||
(mapEntry[alternativeType] || []).forEach(textEntry => {
|
||||
switch (textEntry.type) {
|
||||
case 'text':
|
||||
switch (textType) {
|
||||
case 'html':
|
||||
textContent[textType].push(textToHtml(textEntry.value));
|
||||
break;
|
||||
case 'plain':
|
||||
textContent[textType].push(htmlToText(textEntry.value));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'subMessage':
|
||||
{
|
||||
switch (textType) {
|
||||
case 'html':
|
||||
textContent[textType].push(formatHtmlHeader(textEntry.value));
|
||||
break;
|
||||
case 'plain':
|
||||
textContent[textType].push(formatTextHeader(textEntry.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(textContent).forEach(textType => {
|
||||
textContent[textType] = textContent[textType].join('\n');
|
||||
});
|
||||
|
||||
this.textContent = textContent;
|
||||
}
|
||||
|
||||
isInlineTextNode(node) {
|
||||
if (node.contentDisposition?.parsed?.value === 'attachment') {
|
||||
// no matter the type, this is an attachment
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (node.contentType.parsed?.value) {
|
||||
case 'text/html':
|
||||
case 'text/plain':
|
||||
return true;
|
||||
|
||||
case 'text/calendar':
|
||||
case 'text/csv':
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isInlineMessageRfc822(node) {
|
||||
if (node.contentType.parsed?.value !== 'message/rfc822') {
|
||||
return false;
|
||||
}
|
||||
let disposition =
|
||||
node.contentDisposition?.parsed?.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
|
||||
return disposition === 'inline';
|
||||
}
|
||||
|
||||
// Check if this is a specially crafted report email where message/rfc822 content should not be inlined
|
||||
forceRfc822Attachments() {
|
||||
if (this.options.forceRfc822Attachments) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let forceRfc822Attachments = false;
|
||||
let walk = node => {
|
||||
if (!node.contentType.multipart) {
|
||||
if (
|
||||
node.contentType.parsed &&
|
||||
['message/delivery-status', 'message/feedback-report'].includes(node.contentType.parsed.value)
|
||||
) {
|
||||
forceRfc822Attachments = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (let childNode of node.childNodes) {
|
||||
walk(childNode);
|
||||
}
|
||||
};
|
||||
walk(this.root);
|
||||
return forceRfc822Attachments;
|
||||
}
|
||||
|
||||
async resolveStream(stream) {
|
||||
let chunkLen = 0;
|
||||
let chunks = [];
|
||||
const reader = stream.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
chunkLen += value.length;
|
||||
}
|
||||
|
||||
const result = new Uint8Array(chunkLen);
|
||||
let chunkPointer = 0;
|
||||
for (let chunk of chunks) {
|
||||
result.set(chunk, chunkPointer);
|
||||
chunkPointer += chunk.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async parse(buf) {
|
||||
if (this.started) {
|
||||
throw new Error('Can not reuse parser, create a new PostalMime object');
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
// Check if the input is a readable stream and resolve it into an ArrayBuffer
|
||||
if (buf && typeof buf.getReader === 'function') {
|
||||
buf = await this.resolveStream(buf);
|
||||
}
|
||||
|
||||
// Should it throw for an empty value instead of defaulting to an empty ArrayBuffer?
|
||||
buf = buf || new ArrayBuffer(0);
|
||||
|
||||
// Cast string input to Uint8Array
|
||||
if (typeof buf === 'string') {
|
||||
buf = textEncoder.encode(buf);
|
||||
}
|
||||
|
||||
// Cast Blob to ArrayBuffer
|
||||
if (buf instanceof Blob || Object.prototype.toString.call(buf) === '[object Blob]') {
|
||||
buf = await blobToArrayBuffer(buf);
|
||||
}
|
||||
|
||||
// Cast Node.js Buffer object or Uint8Array into ArrayBuffer
|
||||
if (buf.buffer instanceof ArrayBuffer) {
|
||||
buf = new Uint8Array(buf).buffer;
|
||||
}
|
||||
|
||||
this.buf = buf;
|
||||
|
||||
this.av = new Uint8Array(buf);
|
||||
this.readPos = 0;
|
||||
|
||||
while (this.readPos < this.av.length) {
|
||||
const line = this.readLine();
|
||||
|
||||
await this.processLine(line.bytes, line.done);
|
||||
}
|
||||
|
||||
await this.processNodeTree();
|
||||
|
||||
const message = {
|
||||
headers: this.root.headers
|
||||
.map(entry => ({ key: entry.key, originalKey: entry.originalKey, value: entry.value }))
|
||||
.reverse()
|
||||
};
|
||||
|
||||
for (const key of ['from', 'sender']) {
|
||||
const addressHeader = this.root.headers.find(line => line.key === key);
|
||||
if (addressHeader && addressHeader.value) {
|
||||
const addresses = addressParser(addressHeader.value);
|
||||
if (addresses && addresses.length) {
|
||||
message[key] = addresses[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ['delivered-to', 'return-path']) {
|
||||
const addressHeader = this.root.headers.find(line => line.key === key);
|
||||
if (addressHeader && addressHeader.value) {
|
||||
const addresses = addressParser(addressHeader.value);
|
||||
if (addresses && addresses.length && addresses[0].address) {
|
||||
const camelKey = toCamelCase(key);
|
||||
message[camelKey] = addresses[0].address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ['to', 'cc', 'bcc', 'reply-to']) {
|
||||
const addressHeaders = this.root.headers.filter(line => line.key === key);
|
||||
let addresses = [];
|
||||
|
||||
addressHeaders
|
||||
.filter(entry => entry && entry.value)
|
||||
.map(entry => addressParser(entry.value))
|
||||
.forEach(parsed => (addresses = addresses.concat(parsed || [])));
|
||||
|
||||
if (addresses && addresses.length) {
|
||||
const camelKey = toCamelCase(key);
|
||||
message[camelKey] = addresses;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ['subject', 'message-id', 'in-reply-to', 'references']) {
|
||||
const header = this.root.headers.find(line => line.key === key);
|
||||
if (header && header.value) {
|
||||
const camelKey = toCamelCase(key);
|
||||
message[camelKey] = decodeWords(header.value);
|
||||
}
|
||||
}
|
||||
|
||||
let dateHeader = this.root.headers.find(line => line.key === 'date');
|
||||
if (dateHeader) {
|
||||
let date = new Date(dateHeader.value);
|
||||
if (date.toString() === 'Invalid Date') {
|
||||
date = dateHeader.value;
|
||||
} else {
|
||||
// enforce ISO format if seems to be a valid date
|
||||
date = date.toISOString();
|
||||
}
|
||||
message.date = date;
|
||||
}
|
||||
|
||||
if (this.textContent?.html) {
|
||||
message.html = this.textContent.html;
|
||||
}
|
||||
|
||||
if (this.textContent?.plain) {
|
||||
message.text = this.textContent.plain;
|
||||
}
|
||||
|
||||
message.attachments = this.attachments;
|
||||
|
||||
// Expose raw header lines (reversed to match headers array order)
|
||||
message.headerLines = (this.root.rawHeaderLines || []).slice().reverse();
|
||||
|
||||
switch (this.attachmentEncoding) {
|
||||
case 'arraybuffer':
|
||||
break;
|
||||
|
||||
case 'base64':
|
||||
for (let attachment of message.attachments || []) {
|
||||
if (attachment?.content) {
|
||||
attachment.content = base64ArrayBuffer(attachment.content);
|
||||
attachment.encoding = 'base64';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'utf8':
|
||||
let attachmentDecoder = new TextDecoder('utf8');
|
||||
for (let attachment of message.attachments || []) {
|
||||
if (attachment?.content) {
|
||||
attachment.content = attachmentDecoder.decode(attachment.content);
|
||||
attachment.encoding = 'utf8';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('Unknown attachment encoding');
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
122
node_modules/postal-mime/src/qp-decoder.js
generated
vendored
Normal file
122
node_modules/postal-mime/src/qp-decoder.js
generated
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
import { blobToArrayBuffer } from './decode-strings.js';
|
||||
|
||||
// Regex patterns compiled once for performance
|
||||
const VALID_QP_REGEX = /^=[a-f0-9]{2}$/i;
|
||||
const QP_SPLIT_REGEX = /(?==[a-f0-9]{2})/i;
|
||||
const SOFT_LINE_BREAK_REGEX = /=\r?\n/g;
|
||||
const PARTIAL_QP_ENDING_REGEX = /=[a-fA-F0-9]?$/;
|
||||
|
||||
export default class QPDecoder {
|
||||
constructor(opts) {
|
||||
opts = opts || {};
|
||||
|
||||
this.decoder = opts.decoder || new TextDecoder();
|
||||
|
||||
this.maxChunkSize = 100 * 1024;
|
||||
|
||||
this.remainder = '';
|
||||
|
||||
this.chunks = [];
|
||||
}
|
||||
|
||||
decodeQPBytes(encodedBytes) {
|
||||
let buf = new ArrayBuffer(encodedBytes.length);
|
||||
let dataView = new DataView(buf);
|
||||
for (let i = 0, len = encodedBytes.length; i < len; i++) {
|
||||
dataView.setUint8(i, parseInt(encodedBytes[i], 16));
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
decodeChunks(str) {
|
||||
// unwrap newlines
|
||||
str = str.replace(SOFT_LINE_BREAK_REGEX, '');
|
||||
|
||||
let list = str.split(QP_SPLIT_REGEX);
|
||||
let encodedBytes = [];
|
||||
for (let part of list) {
|
||||
if (part.charAt(0) !== '=') {
|
||||
if (encodedBytes.length) {
|
||||
this.chunks.push(this.decodeQPBytes(encodedBytes));
|
||||
encodedBytes = [];
|
||||
}
|
||||
this.chunks.push(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.length === 3) {
|
||||
// Validate that this is actually a valid QP sequence
|
||||
if (VALID_QP_REGEX.test(part)) {
|
||||
encodedBytes.push(part.substr(1));
|
||||
} else {
|
||||
// Not a valid QP sequence, treat as literal text
|
||||
if (encodedBytes.length) {
|
||||
this.chunks.push(this.decodeQPBytes(encodedBytes));
|
||||
encodedBytes = [];
|
||||
}
|
||||
this.chunks.push(part);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.length > 3) {
|
||||
// First 3 chars should be a valid QP sequence
|
||||
const firstThree = part.substr(0, 3);
|
||||
if (VALID_QP_REGEX.test(firstThree)) {
|
||||
encodedBytes.push(part.substr(1, 2));
|
||||
this.chunks.push(this.decodeQPBytes(encodedBytes));
|
||||
encodedBytes = [];
|
||||
|
||||
part = part.substr(3);
|
||||
this.chunks.push(part);
|
||||
} else {
|
||||
// Not a valid QP sequence, treat entire part as literal
|
||||
if (encodedBytes.length) {
|
||||
this.chunks.push(this.decodeQPBytes(encodedBytes));
|
||||
encodedBytes = [];
|
||||
}
|
||||
this.chunks.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (encodedBytes.length) {
|
||||
this.chunks.push(this.decodeQPBytes(encodedBytes));
|
||||
}
|
||||
}
|
||||
|
||||
update(buffer) {
|
||||
// expect full lines, so add line terminator as well
|
||||
let str = this.decoder.decode(buffer) + '\n';
|
||||
|
||||
str = this.remainder + str;
|
||||
|
||||
if (str.length < this.maxChunkSize) {
|
||||
this.remainder = str;
|
||||
return;
|
||||
}
|
||||
|
||||
this.remainder = '';
|
||||
|
||||
let partialEnding = str.match(PARTIAL_QP_ENDING_REGEX);
|
||||
if (partialEnding) {
|
||||
if (partialEnding.index === 0) {
|
||||
this.remainder = str;
|
||||
return;
|
||||
}
|
||||
this.remainder = str.substr(partialEnding.index);
|
||||
str = str.substr(0, partialEnding.index);
|
||||
}
|
||||
|
||||
this.decodeChunks(str);
|
||||
}
|
||||
|
||||
finalize() {
|
||||
if (this.remainder.length) {
|
||||
this.decodeChunks(this.remainder);
|
||||
this.remainder = '';
|
||||
}
|
||||
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
}
|
||||
}
|
||||
351
node_modules/postal-mime/src/text-format.js
generated
vendored
Normal file
351
node_modules/postal-mime/src/text-format.js
generated
vendored
Normal file
@@ -0,0 +1,351 @@
|
||||
import htmlEntities from './html-entities.js';
|
||||
|
||||
export function decodeHTMLEntities(str) {
|
||||
return str.replace(/&(#\d+|#x[a-f0-9]+|[a-z]+\d*);?/gi, (match, entity) => {
|
||||
if (typeof htmlEntities[match] === 'string') {
|
||||
return htmlEntities[match];
|
||||
}
|
||||
|
||||
if (entity.charAt(0) !== '#' || match.charAt(match.length - 1) !== ';') {
|
||||
// keep as is, invalid or unknown sequence
|
||||
return match;
|
||||
}
|
||||
|
||||
let codePoint;
|
||||
if (entity.charAt(1) === 'x') {
|
||||
// hex
|
||||
codePoint = parseInt(entity.substr(2), 16);
|
||||
} else {
|
||||
// dec
|
||||
codePoint = parseInt(entity.substr(1), 10);
|
||||
}
|
||||
|
||||
let output = '';
|
||||
|
||||
if ((codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
|
||||
// Invalid range, return a replacement character instead
|
||||
return '\uFFFD';
|
||||
}
|
||||
|
||||
if (codePoint > 0xffff) {
|
||||
codePoint -= 0x10000;
|
||||
output += String.fromCharCode(((codePoint >>> 10) & 0x3ff) | 0xd800);
|
||||
codePoint = 0xdc00 | (codePoint & 0x3ff);
|
||||
}
|
||||
|
||||
output += String.fromCharCode(codePoint);
|
||||
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
return str.trim().replace(/[<>"'?&]/g, c => {
|
||||
let hex = c.charCodeAt(0).toString(16);
|
||||
if (hex.length < 2) {
|
||||
hex = '0' + hex;
|
||||
}
|
||||
return '&#x' + hex.toUpperCase() + ';';
|
||||
});
|
||||
}
|
||||
|
||||
export function textToHtml(str) {
|
||||
let html = escapeHtml(str).replace(/\n/g, '<br />');
|
||||
return '<div>' + html + '</div>';
|
||||
}
|
||||
|
||||
export function htmlToText(str) {
|
||||
str = str
|
||||
// we can't process tags on multiple lines so remove newlines first
|
||||
.replace(/\r?\n/g, '\u0001')
|
||||
.replace(/<\!\-\-.*?\-\->/gi, ' ')
|
||||
|
||||
.replace(/<br\b[^>]*>/gi, '\n')
|
||||
.replace(/<\/?(p|div|table|tr|td|th)\b[^>]*>/gi, '\n\n')
|
||||
.replace(/<script\b[^>]*>.*?<\/script\b[^>]*>/gi, ' ')
|
||||
.replace(/^.*<body\b[^>]*>/i, '')
|
||||
.replace(/^.*<\/head\b[^>]*>/i, '')
|
||||
.replace(/^.*<\!doctype\b[^>]*>/i, '')
|
||||
.replace(/<\/body\b[^>]*>.*$/i, '')
|
||||
.replace(/<\/html\b[^>]*>.*$/i, '')
|
||||
|
||||
.replace(/<a\b[^>]*href\s*=\s*["']?([^\s"']+)[^>]*>/gi, ' ($1) ')
|
||||
|
||||
.replace(/<\/?(span|em|i|strong|b|u|a)\b[^>]*>/gi, '')
|
||||
|
||||
.replace(/<li\b[^>]*>[\n\u0001\s]*/gi, '* ')
|
||||
|
||||
.replace(/<hr\b[^>]*>/g, '\n-------------\n')
|
||||
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
|
||||
// convert linebreak placeholders back to newlines
|
||||
.replace(/\u0001/g, '\n')
|
||||
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
|
||||
.replace(/^\s+$/gm, '')
|
||||
|
||||
.replace(/\n\n+/g, '\n\n')
|
||||
.replace(/^\n+/, '\n')
|
||||
.replace(/\n+$/, '\n');
|
||||
|
||||
str = decodeHTMLEntities(str);
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function formatTextAddress(address) {
|
||||
return []
|
||||
.concat(address.name || [])
|
||||
.concat(address.name ? `<${address.address}>` : address.address)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function formatTextAddresses(addresses) {
|
||||
let parts = [];
|
||||
|
||||
let processAddress = (address, partCounter) => {
|
||||
if (partCounter) {
|
||||
parts.push(', ');
|
||||
}
|
||||
|
||||
if (address.group) {
|
||||
let groupStart = `${address.name}:`;
|
||||
let groupEnd = `;`;
|
||||
|
||||
parts.push(groupStart);
|
||||
address.group.forEach(processAddress);
|
||||
parts.push(groupEnd);
|
||||
} else {
|
||||
parts.push(formatTextAddress(address));
|
||||
}
|
||||
};
|
||||
|
||||
addresses.forEach(processAddress);
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function formatHtmlAddress(address) {
|
||||
return `<a href="mailto:${escapeHtml(address.address)}" class="postal-email-address">${escapeHtml(address.name || `<${address.address}>`)}</a>`;
|
||||
}
|
||||
|
||||
function formatHtmlAddresses(addresses) {
|
||||
let parts = [];
|
||||
|
||||
let processAddress = (address, partCounter) => {
|
||||
if (partCounter) {
|
||||
parts.push('<span class="postal-email-address-separator">, </span>');
|
||||
}
|
||||
|
||||
if (address.group) {
|
||||
let groupStart = `<span class="postal-email-address-group">${escapeHtml(address.name)}:</span>`;
|
||||
let groupEnd = `<span class="postal-email-address-group">;</span>`;
|
||||
|
||||
parts.push(groupStart);
|
||||
address.group.forEach(processAddress);
|
||||
parts.push(groupEnd);
|
||||
} else {
|
||||
parts.push(formatHtmlAddress(address));
|
||||
}
|
||||
};
|
||||
|
||||
addresses.forEach(processAddress);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function foldLines(str, lineLength, afterSpace) {
|
||||
str = (str || '').toString();
|
||||
lineLength = lineLength || 76;
|
||||
|
||||
let pos = 0,
|
||||
len = str.length,
|
||||
result = '',
|
||||
line,
|
||||
match;
|
||||
|
||||
while (pos < len) {
|
||||
line = str.substr(pos, lineLength);
|
||||
if (line.length < lineLength) {
|
||||
result += line;
|
||||
break;
|
||||
}
|
||||
if ((match = line.match(/^[^\n\r]*(\r?\n|\r)/))) {
|
||||
line = match[0];
|
||||
result += line;
|
||||
pos += line.length;
|
||||
continue;
|
||||
} else if (
|
||||
(match = line.match(/(\s+)[^\s]*$/)) &&
|
||||
match[0].length - (afterSpace ? (match[1] || '').length : 0) < line.length
|
||||
) {
|
||||
line = line.substr(0, line.length - (match[0].length - (afterSpace ? (match[1] || '').length : 0)));
|
||||
} else if ((match = str.substr(pos + line.length).match(/^[^\s]+(\s*)/))) {
|
||||
line = line + match[0].substr(0, match[0].length - (!afterSpace ? (match[1] || '').length : 0));
|
||||
}
|
||||
|
||||
result += line;
|
||||
pos += line.length;
|
||||
if (pos < len) {
|
||||
result += '\r\n';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatTextHeader(message) {
|
||||
let rows = [];
|
||||
|
||||
if (message.from) {
|
||||
rows.push({ key: 'From', val: formatTextAddress(message.from) });
|
||||
}
|
||||
|
||||
if (message.subject) {
|
||||
rows.push({ key: 'Subject', val: message.subject });
|
||||
}
|
||||
|
||||
if (message.date) {
|
||||
let dateOptions = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
let dateStr =
|
||||
typeof Intl === 'undefined'
|
||||
? message.date
|
||||
: new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
|
||||
|
||||
rows.push({ key: 'Date', val: dateStr });
|
||||
}
|
||||
|
||||
if (message.to && message.to.length) {
|
||||
rows.push({ key: 'To', val: formatTextAddresses(message.to) });
|
||||
}
|
||||
|
||||
if (message.cc && message.cc.length) {
|
||||
rows.push({ key: 'Cc', val: formatTextAddresses(message.cc) });
|
||||
}
|
||||
|
||||
if (message.bcc && message.bcc.length) {
|
||||
rows.push({ key: 'Bcc', val: formatTextAddresses(message.bcc) });
|
||||
}
|
||||
|
||||
// Align keys and values by adding space between these two
|
||||
// Also make sure that the separator line is as long as the longest line
|
||||
// Should end up with something like this:
|
||||
/*
|
||||
-----------------------------
|
||||
From: xx xx <xxx@xxx.com>
|
||||
Subject: Example Subject
|
||||
Date: 16/02/2021, 02:57:06
|
||||
To: not@found.com
|
||||
-----------------------------
|
||||
*/
|
||||
|
||||
let maxKeyLength = rows
|
||||
.map(r => r.key.length)
|
||||
.reduce((acc, cur) => {
|
||||
return cur > acc ? cur : acc;
|
||||
}, 0);
|
||||
|
||||
rows = rows.flatMap(row => {
|
||||
let sepLen = maxKeyLength - row.key.length;
|
||||
let prefix = `${row.key}: ${' '.repeat(sepLen)}`;
|
||||
let emptyPrefix = `${' '.repeat(row.key.length + 1)} ${' '.repeat(sepLen)}`;
|
||||
|
||||
let foldedLines = foldLines(row.val, 80, true)
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim());
|
||||
|
||||
return foldedLines.map((line, i) => `${i ? emptyPrefix : prefix}${line}`);
|
||||
});
|
||||
|
||||
let maxLineLength = rows
|
||||
.map(r => r.length)
|
||||
.reduce((acc, cur) => {
|
||||
return cur > acc ? cur : acc;
|
||||
}, 0);
|
||||
|
||||
let lineMarker = '-'.repeat(maxLineLength);
|
||||
|
||||
let template = `
|
||||
${lineMarker}
|
||||
${rows.join('\n')}
|
||||
${lineMarker}
|
||||
`;
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
export function formatHtmlHeader(message) {
|
||||
let rows = [];
|
||||
|
||||
if (message.from) {
|
||||
rows.push(
|
||||
`<div class="postal-email-header-key">From</div><div class="postal-email-header-value">${formatHtmlAddress(message.from)}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.subject) {
|
||||
rows.push(
|
||||
`<div class="postal-email-header-key">Subject</div><div class="postal-email-header-value postal-email-header-subject">${escapeHtml(
|
||||
message.subject
|
||||
)}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.date) {
|
||||
let dateOptions = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
let dateStr =
|
||||
typeof Intl === 'undefined'
|
||||
? message.date
|
||||
: new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
|
||||
|
||||
rows.push(
|
||||
`<div class="postal-email-header-key">Date</div><div class="postal-email-header-value postal-email-header-date" data-date="${escapeHtml(
|
||||
message.date
|
||||
)}">${escapeHtml(dateStr)}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.to && message.to.length) {
|
||||
rows.push(
|
||||
`<div class="postal-email-header-key">To</div><div class="postal-email-header-value">${formatHtmlAddresses(message.to)}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.cc && message.cc.length) {
|
||||
rows.push(
|
||||
`<div class="postal-email-header-key">Cc</div><div class="postal-email-header-value">${formatHtmlAddresses(message.cc)}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.bcc && message.bcc.length) {
|
||||
rows.push(
|
||||
`<div class="postal-email-header-key">Bcc</div><div class="postal-email-header-value">${formatHtmlAddresses(message.bcc)}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
let template = `<div class="postal-email-header">${rows.length ? '<div class="postal-email-header-row">' : ''}${rows.join(
|
||||
'</div>\n<div class="postal-email-header-row">'
|
||||
)}${rows.length ? '</div>' : ''}</div>`;
|
||||
|
||||
return template;
|
||||
}
|
||||
Reference in New Issue
Block a user