mirror of https://github.com/eleith/emailjs.git
smtp/mime: import code & tests from emailjs-mime-codec
This commit is contained in:
parent
4f5829c0ea
commit
a48d0ce78d
1
email.ts
1
email.ts
|
@ -3,4 +3,5 @@ export * from './smtp/connection';
|
|||
export * from './smtp/date';
|
||||
export * from './smtp/error';
|
||||
export * from './smtp/message';
|
||||
export * from './smtp/mime';
|
||||
export * from './smtp/response';
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
"@typescript-eslint/parser": "3.2.0",
|
||||
"addressparser": "1.0.1",
|
||||
"ava": "3.8.2",
|
||||
"emailjs-mime-codec": "2.0.9",
|
||||
"eslint": "7.2.0",
|
||||
"eslint-config-prettier": "6.11.0",
|
||||
"eslint-plugin-prettier": "3.1.3",
|
||||
|
|
|
@ -4,9 +4,9 @@ import { hostname } from 'os';
|
|||
import { Stream } from 'stream';
|
||||
|
||||
import addressparser from 'addressparser';
|
||||
import { mimeWordEncode } from 'emailjs-mime-codec';
|
||||
|
||||
import { getRFC2822Date } from './date';
|
||||
import { mimeWordEncode } from './mime';
|
||||
|
||||
const CRLF = '\r\n' as const;
|
||||
|
||||
|
@ -150,7 +150,7 @@ export class Message {
|
|||
this.attach(attachment);
|
||||
}
|
||||
} else if (header === 'subject') {
|
||||
this.header.subject = mimeWordEncode(headers.subject);
|
||||
this.header.subject = mimeWordEncode(headers.subject as string);
|
||||
} else if (/^(cc|bcc|to|from)/i.test(header)) {
|
||||
this.header[header.toLowerCase()] = convertPersonToAddress(
|
||||
headers[header] as string | string[]
|
||||
|
|
|
@ -0,0 +1,227 @@
|
|||
// adapted from https://github.com/emailjs/emailjs-mime-codec/blob/6909c706b9f09bc0e5c3faf48f723cca53e5b352/src/mimecodec.js
|
||||
import { TextDecoder, TextEncoder } from 'util';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* @see https://tools.ietf.org/html/rfc2045#section-6.7
|
||||
*/
|
||||
const RANGES = [
|
||||
[0x09], // <TAB>
|
||||
[0x0a], // <LF>
|
||||
[0x0d], // <CR>
|
||||
[0x20, 0x3c], // <SP>!"#$%&'()*+,-./0123456789:;
|
||||
[0x3e, 0x7e], // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}
|
||||
];
|
||||
const LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(
|
||||
''
|
||||
);
|
||||
const MAX_CHUNK_LENGTH = 16383; // must be multiple of 3
|
||||
const MAX_MIME_WORD_LENGTH = 52;
|
||||
const MAX_B64_MIME_WORD_BYTE_LENGTH = 39;
|
||||
|
||||
function tripletToBase64(num: number) {
|
||||
return (
|
||||
LOOKUP[(num >> 18) & 0x3f] +
|
||||
LOOKUP[(num >> 12) & 0x3f] +
|
||||
LOOKUP[(num >> 6) & 0x3f] +
|
||||
LOOKUP[num & 0x3f]
|
||||
);
|
||||
}
|
||||
|
||||
function encodeChunk(uint8: Uint8Array, start: number, end: number) {
|
||||
let output = '';
|
||||
for (let i = start; i < end; i += 3) {
|
||||
output += tripletToBase64(
|
||||
(uint8[i] << 16) + (uint8[i + 1] << 8) + uint8[i + 2]
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function encodeBase64(data: Uint8Array) {
|
||||
const len = data.length;
|
||||
const extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes
|
||||
let output = '';
|
||||
|
||||
// go through the array every three bytes, we'll deal with trailing stuff later
|
||||
for (let i = 0, len2 = len - extraBytes; i < len2; i += MAX_CHUNK_LENGTH) {
|
||||
output += encodeChunk(
|
||||
data,
|
||||
i,
|
||||
i + MAX_CHUNK_LENGTH > len2 ? len2 : i + MAX_CHUNK_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
// pad the end with zeros, but make sure to not forget the extra bytes
|
||||
if (extraBytes === 1) {
|
||||
const tmp = data[len - 1];
|
||||
output += LOOKUP[tmp >> 2];
|
||||
output += LOOKUP[(tmp << 4) & 0x3f];
|
||||
output += '==';
|
||||
} else if (extraBytes === 2) {
|
||||
const tmp = (data[len - 2] << 8) + data[len - 1];
|
||||
output += LOOKUP[tmp >> 10];
|
||||
output += LOOKUP[(tmp >> 4) & 0x3f];
|
||||
output += LOOKUP[(tmp << 2) & 0x3f];
|
||||
output += '=';
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a mime encoded string. Needed for dividing mime words into smaller chunks
|
||||
*
|
||||
* @param {string} str Mime encoded string to be split up
|
||||
* @param {number} maxlen Maximum length of characters for one part (minimum 12)
|
||||
* @return {string[]} lines
|
||||
*/
|
||||
function splitMimeEncodedString(str: string, maxlen = 12) {
|
||||
const minWordLength = 12; // require at least 12 symbols to fit possible 4 octet UTF-8 sequences
|
||||
const maxWordLength = Math.max(maxlen, minWordLength);
|
||||
const lines = [];
|
||||
|
||||
while (str.length) {
|
||||
let curLine = str.substr(0, maxWordLength);
|
||||
|
||||
const match = curLine.match(/=[0-9A-F]?$/i); // skip incomplete escaped char
|
||||
if (match) {
|
||||
curLine = curLine.substr(0, match.index);
|
||||
}
|
||||
|
||||
let done = false;
|
||||
while (!done) {
|
||||
let chr;
|
||||
done = true;
|
||||
const match = str.substr(curLine.length).match(/^=([0-9A-F]{2})/i); // check if not middle of a unicode char sequence
|
||||
if (match) {
|
||||
chr = parseInt(match[1], 16);
|
||||
// invalid sequence, move one char back anc recheck
|
||||
if (chr < 0xc2 && chr > 0x7f) {
|
||||
curLine = curLine.substr(0, curLine.length - 3);
|
||||
done = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (curLine.length) {
|
||||
lines.push(curLine);
|
||||
}
|
||||
str = str.substr(curLine.length);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} nr number
|
||||
* @returns {boolean} if number is in range
|
||||
*/
|
||||
function checkRanges(nr: number) {
|
||||
return RANGES.reduce(
|
||||
(val, range) =>
|
||||
val ||
|
||||
(range.length === 1 && nr === range[0]) ||
|
||||
(range.length === 2 && nr >= range[0] && nr <= range[1]),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes all non printable and non ascii bytes to =XX form, where XX is the
|
||||
* byte value in hex. This function does not convert linebreaks etc. it
|
||||
* only escapes character sequences
|
||||
*
|
||||
* @see https://nodejs.org/api/util.html#util_whatwg_supported_encodings
|
||||
*
|
||||
* @param {string|Uint8Array} data Either a string or an Uint8Array
|
||||
* @param {string} encoding WHATWG supported encoding
|
||||
* @return {string} Mime encoded string
|
||||
*/
|
||||
export function mimeEncode(data: string | Uint8Array = '', encoding = 'utf-8') {
|
||||
const decoder = new TextDecoder(encoding);
|
||||
const buffer =
|
||||
typeof data === 'string'
|
||||
? encoder.encode(data)
|
||||
: encoder.encode(decoder.decode(data));
|
||||
|
||||
return buffer.reduce(
|
||||
(aggregate, ord, index) =>
|
||||
checkRanges(ord) &&
|
||||
!(
|
||||
(ord === 0x20 || ord === 0x09) &&
|
||||
(index === buffer.length - 1 ||
|
||||
buffer[index + 1] === 0x0a ||
|
||||
buffer[index + 1] === 0x0d)
|
||||
)
|
||||
? // if the char is in allowed range, then keep as is, unless it is a ws in the end of a line
|
||||
aggregate + String.fromCharCode(ord)
|
||||
: `${aggregate}=${ord < 0x10 ? '0' : ''}${ord
|
||||
.toString(16)
|
||||
.toUpperCase()}`,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a string or an Uint8Array to an UTF-8 MIME Word
|
||||
*
|
||||
* @see https://tools.ietf.org/html/rfc2047
|
||||
* @see https://nodejs.org/api/util.html#util_whatwg_supported_encodings
|
||||
*
|
||||
* @param {string|Uint8Array} data String to be encoded
|
||||
* @param {'Q' | 'B'} mimeWordEncoding='Q' Encoding for the mime word, either Q or B
|
||||
* @param {string} encoding WHATWG supported encoding
|
||||
* @return {string} Single or several mime words joined together
|
||||
*/
|
||||
export function mimeWordEncode(
|
||||
data: string | Uint8Array,
|
||||
mimeWordEncoding: 'Q' | 'B' = 'Q',
|
||||
encoding = 'utf-8'
|
||||
) {
|
||||
let parts = [];
|
||||
const decoder = new TextDecoder(encoding);
|
||||
const str = typeof data === 'string' ? data : decoder.decode(data);
|
||||
|
||||
if (mimeWordEncoding === 'Q') {
|
||||
const encodedStr = mimeEncode(str, encoding).replace(
|
||||
/[^a-z0-9!*+\-/=]/gi,
|
||||
(chr: string) =>
|
||||
chr === ' '
|
||||
? '_'
|
||||
: '=' +
|
||||
(chr.charCodeAt(0) < 0x10 ? '0' : '') +
|
||||
chr.charCodeAt(0).toString(16).toUpperCase()
|
||||
);
|
||||
parts =
|
||||
encodedStr.length < MAX_MIME_WORD_LENGTH
|
||||
? [encodedStr]
|
||||
: splitMimeEncodedString(encodedStr, MAX_MIME_WORD_LENGTH);
|
||||
} else {
|
||||
// Fits as much as possible into every line without breaking utf-8 multibyte characters' octets up across lines
|
||||
let j = 0;
|
||||
let i = 0;
|
||||
while (i < str.length) {
|
||||
if (
|
||||
encoder.encode(str.substring(j, i)).length >
|
||||
MAX_B64_MIME_WORD_BYTE_LENGTH
|
||||
) {
|
||||
// we went one character too far, substring at the char before
|
||||
parts.push(str.substring(j, i - 1));
|
||||
j = i - 1;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// add the remainder of the string
|
||||
str.substring(j) && parts.push(str.substring(j));
|
||||
parts = parts.map((x) => encoder.encode(x)).map((x) => encodeBase64(x));
|
||||
}
|
||||
|
||||
return parts
|
||||
.map((p) => `=?UTF-8?${mimeWordEncoding}?${p}?= `)
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
// adapted from https://github.com/emailjs/emailjs-mime-codec/blob/6909c706b9f09bc0e5c3faf48f723cca53e5b352/src/mimecodec-unit.js
|
||||
import test from 'ava';
|
||||
import { mimeEncode, mimeWordEncode } from '../email';
|
||||
|
||||
test('mimeEncode should encode UTF-8', (t) => {
|
||||
t.is(mimeEncode('tere ÕÄÖÕ'), 'tere =C3=95=C3=84=C3=96=C3=95');
|
||||
});
|
||||
|
||||
test('mimeEncode should encode trailing whitespace', (t) => {
|
||||
t.is(mimeEncode('tere '), 'tere =20');
|
||||
});
|
||||
|
||||
test('mimeEncode should encode non UTF-8', (t) => {
|
||||
t.is(mimeEncode(new Uint8Array([0xbd, 0xc5]), 'ks_c_5601-1987'), '=EC=8B=A0');
|
||||
});
|
||||
|
||||
test('mimeWordEncode should encode', (t) => {
|
||||
t.is('=?UTF-8?Q?See_on_=C3=B5hin_test?=', mimeWordEncode('See on õhin test'));
|
||||
});
|
||||
|
||||
test('mimeWordEncode should QP-encode mime word', (t) => {
|
||||
t.is(
|
||||
'=?UTF-8?Q?J=C3=B5ge-va=C5=BD?=',
|
||||
mimeWordEncode(
|
||||
new Uint8Array([0x4a, 0xf5, 0x67, 0x65, 0x2d, 0x76, 0x61, 0xde]),
|
||||
'Q',
|
||||
'iso-8859-13'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('mimeWordEncode should Base64-encode mime word', (t) => {
|
||||
t.is(
|
||||
mimeWordEncode('Привет и до свидания', 'B'),
|
||||
'=?UTF-8?B?0J/RgNC40LLQtdGCINC4INC00L4g0YHQstC40LTQsNC90LjRjw==?='
|
||||
);
|
||||
});
|
||||
|
||||
test('mimeWordEncode should Base64-encode a long mime word', (t) => {
|
||||
const payload =
|
||||
'üöß‹€Привет и до свиданияПривет и до свиданияПривет и до свиданияПривет и до свиданияПривет и до свиданияПривет и до свиданияПривет и до свиданияПривет и до свидания';
|
||||
const expected =
|
||||
'=?UTF-8?B?w7zDtsOf4oC54oKs0J/RgNC40LLQtdGCINC4INC00L4g0YHQstC4?= ' +
|
||||
'=?UTF-8?B?0LTQsNC90LjRj9Cf0YDQuNCy0LXRgiDQuCDQtNC+INGB0LLQuNC0?= ' +
|
||||
'=?UTF-8?B?0LDQvdC40Y/Qn9GA0LjQstC10YIg0Lgg0LTQviDRgdCy0LjQtNCw?= ' +
|
||||
'=?UTF-8?B?0L3QuNGP0J/RgNC40LLQtdGCINC4INC00L4g0YHQstC40LTQsNC9?= ' +
|
||||
'=?UTF-8?B?0LjRj9Cf0YDQuNCy0LXRgiDQuCDQtNC+INGB0LLQuNC00LDQvdC4?= ' +
|
||||
'=?UTF-8?B?0Y/Qn9GA0LjQstC10YIg0Lgg0LTQviDRgdCy0LjQtNCw0L3QuNGP?= ' +
|
||||
'=?UTF-8?B?0J/RgNC40LLQtdGCINC4INC00L4g0YHQstC40LTQsNC90LjRj9Cf?= ' +
|
||||
'=?UTF-8?B?0YDQuNCy0LXRgiDQuCDQtNC+INGB0LLQuNC00LDQvdC40Y8=?=';
|
||||
t.is(mimeWordEncode(payload, 'B'), expected);
|
||||
});
|
24
yarn.lock
24
yarn.lock
|
@ -877,20 +877,6 @@ duplexer3@^0.1.4:
|
|||
resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
|
||||
integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
|
||||
|
||||
emailjs-base64@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.npmjs.org/emailjs-base64/-/emailjs-base64-1.1.4.tgz#392fa38cb6aa35dccd3af3637ffc14c1c7ce9612"
|
||||
integrity sha512-4h0xp1jgVTnIQBHxSJWXWanNnmuc5o+k4aHEpcLXSToN8asjB5qbXAexs7+PEsUKcEyBteNYsSvXUndYT2CGGA==
|
||||
|
||||
emailjs-mime-codec@2.0.9:
|
||||
version "2.0.9"
|
||||
resolved "https://registry.npmjs.org/emailjs-mime-codec/-/emailjs-mime-codec-2.0.9.tgz#d184451b6f2e55c5868b0f0a82d18fe2b82f0c97"
|
||||
integrity sha512-7qJo4pFGcKlWh/kCeNjmcgj34YoJWY0ekZXEHYtluWg4MVBnXqGM4CRMtZQkfYwitOhUgaKN5EQktJddi/YIDQ==
|
||||
dependencies:
|
||||
emailjs-base64 "^1.1.4"
|
||||
ramda "^0.26.1"
|
||||
text-encoding "^0.7.0"
|
||||
|
||||
emittery@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.npmjs.org/emittery/-/emittery-0.6.0.tgz#e85312468d77c3ed9a6adf43bb57d34849e0c95a"
|
||||
|
@ -2181,11 +2167,6 @@ pupa@^2.0.1:
|
|||
dependencies:
|
||||
escape-goat "^2.0.0"
|
||||
|
||||
ramda@^0.26.1:
|
||||
version "0.26.1"
|
||||
resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06"
|
||||
integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==
|
||||
|
||||
rc@^1.2.8:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
|
||||
|
@ -2591,11 +2572,6 @@ term-size@^2.1.0:
|
|||
resolved "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
|
||||
integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==
|
||||
|
||||
text-encoding@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643"
|
||||
integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==
|
||||
|
||||
text-table@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||
|
|
Loading…
Reference in New Issue