FRE-709: Document duplicate recovery wake - FRE-635 already recovered via FRE-708

This commit is contained in:
2026-04-26 20:23:14 -04:00
parent e07237b6b0
commit 0ff6c74871
5880 changed files with 1643723 additions and 908 deletions

395
node_modules/postal-mime/src/address-parser.js generated vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

373
node_modules/postal-mime/src/mime-node.js generated vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

17
node_modules/postal-mime/src/pass-through-decoder.js generated vendored Normal file
View 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
View 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
View 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
View 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;
}