index.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. /* eslint no-control-regex:0 */
  2. 'use strict';
  3. const base64 = require('../base64');
  4. const qp = require('../qp');
  5. const mimeTypes = require('./mime-types');
  6. module.exports = {
  7. /**
  8. * Checks if a value is plaintext string (uses only printable 7bit chars)
  9. *
  10. * @param {String} value String to be tested
  11. * @returns {Boolean} true if it is a plaintext string
  12. */
  13. isPlainText(value) {
  14. if (typeof value !== 'string' || /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/.test(value)) {
  15. return false;
  16. } else {
  17. return true;
  18. }
  19. },
  20. /**
  21. * Checks if a multi line string containes lines longer than the selected value.
  22. *
  23. * Useful when detecting if a mail message needs any processing at all –
  24. * if only plaintext characters are used and lines are short, then there is
  25. * no need to encode the values in any way. If the value is plaintext but has
  26. * longer lines then allowed, then use format=flowed
  27. *
  28. * @param {Number} lineLength Max line length to check for
  29. * @returns {Boolean} Returns true if there is at least one line longer than lineLength chars
  30. */
  31. hasLongerLines(str, lineLength) {
  32. if (str.length > 128 * 1024) {
  33. // do not test strings longer than 128kB
  34. return true;
  35. }
  36. return new RegExp('^.{' + (lineLength + 1) + ',}', 'm').test(str);
  37. },
  38. /**
  39. * Encodes a string or an Buffer to an UTF-8 MIME Word (rfc2047)
  40. *
  41. * @param {String|Buffer} data String to be encoded
  42. * @param {String} mimeWordEncoding='Q' Encoding for the mime word, either Q or B
  43. * @param {Number} [maxLength=0] If set, split mime words into several chunks if needed
  44. * @return {String} Single or several mime words joined together
  45. */
  46. encodeWord(data, mimeWordEncoding, maxLength) {
  47. mimeWordEncoding = (mimeWordEncoding || 'Q')
  48. .toString()
  49. .toUpperCase()
  50. .trim()
  51. .charAt(0);
  52. maxLength = maxLength || 0;
  53. let encodedStr;
  54. let toCharset = 'UTF-8';
  55. if (maxLength && maxLength > 7 + toCharset.length) {
  56. maxLength -= 7 + toCharset.length;
  57. }
  58. if (mimeWordEncoding === 'Q') {
  59. // https://tools.ietf.org/html/rfc2047#section-5 rule (3)
  60. encodedStr = qp.encode(data).replace(/[^a-z0-9!*+\-/=]/gi, chr => {
  61. let ord = chr
  62. .charCodeAt(0)
  63. .toString(16)
  64. .toUpperCase();
  65. if (chr === ' ') {
  66. return '_';
  67. } else {
  68. return '=' + (ord.length === 1 ? '0' + ord : ord);
  69. }
  70. });
  71. } else if (mimeWordEncoding === 'B') {
  72. encodedStr = typeof data === 'string' ? data : base64.encode(data);
  73. maxLength = maxLength ? Math.max(3, (maxLength - maxLength % 4) / 4 * 3) : 0;
  74. }
  75. if (maxLength && (mimeWordEncoding !== 'B' ? encodedStr : base64.encode(data)).length > maxLength) {
  76. if (mimeWordEncoding === 'Q') {
  77. encodedStr = this.splitMimeEncodedString(encodedStr, maxLength).join('?= =?' + toCharset + '?' + mimeWordEncoding + '?');
  78. } else {
  79. // RFC2047 6.3 (2) states that encoded-word must include an integral number of characters, so no chopping unicode sequences
  80. let parts = [];
  81. let lpart = '';
  82. for (let i = 0, len = encodedStr.length; i < len; i++) {
  83. let chr = encodedStr.charAt(i);
  84. // check if we can add this character to the existing string
  85. // without breaking byte length limit
  86. if (Buffer.byteLength(lpart + chr) <= maxLength || i === 0) {
  87. lpart += chr;
  88. } else {
  89. // we hit the length limit, so push the existing string and start over
  90. parts.push(base64.encode(lpart));
  91. lpart = chr;
  92. }
  93. }
  94. if (lpart) {
  95. parts.push(base64.encode(lpart));
  96. }
  97. if (parts.length > 1) {
  98. encodedStr = parts.join('?= =?' + toCharset + '?' + mimeWordEncoding + '?');
  99. } else {
  100. encodedStr = parts.join('');
  101. }
  102. }
  103. } else if (mimeWordEncoding === 'B') {
  104. encodedStr = base64.encode(data);
  105. }
  106. return '=?' + toCharset + '?' + mimeWordEncoding + '?' + encodedStr + (encodedStr.substr(-2) === '?=' ? '' : '?=');
  107. },
  108. /**
  109. * Finds word sequences with non ascii text and converts these to mime words
  110. *
  111. * @param {String} value String to be encoded
  112. * @param {String} mimeWordEncoding='Q' Encoding for the mime word, either Q or B
  113. * @param {Number} [maxLength=0] If set, split mime words into several chunks if needed
  114. * @return {String} String with possible mime words
  115. */
  116. encodeWords(value, mimeWordEncoding, maxLength) {
  117. maxLength = maxLength || 0;
  118. let encodedValue;
  119. // find first word with a non-printable ascii in it
  120. let firstMatch = value.match(/(?:^|\s)([^\s]*[\u0080-\uFFFF])/);
  121. if (!firstMatch) {
  122. return value;
  123. }
  124. // find the last word with a non-printable ascii in it
  125. let lastMatch = value.match(/([\u0080-\uFFFF][^\s]*)[^\u0080-\uFFFF]*$/);
  126. if (!lastMatch) {
  127. // should not happen
  128. return value;
  129. }
  130. let startIndex =
  131. firstMatch.index +
  132. (firstMatch[0].match(/[^\s]/) || {
  133. index: 0
  134. }).index;
  135. let endIndex = lastMatch.index + (lastMatch[1] || '').length;
  136. encodedValue =
  137. (startIndex ? value.substr(0, startIndex) : '') +
  138. this.encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
  139. (endIndex < value.length ? value.substr(endIndex) : '');
  140. return encodedValue;
  141. },
  142. /**
  143. * Joins parsed header value together as 'value; param1=value1; param2=value2'
  144. * PS: We are following RFC 822 for the list of special characters that we need to keep in quotes.
  145. * Refer: https://www.w3.org/Protocols/rfc1341/4_Content-Type.html
  146. * @param {Object} structured Parsed header value
  147. * @return {String} joined header value
  148. */
  149. buildHeaderValue(structured) {
  150. let paramsArray = [];
  151. Object.keys(structured.params || {}).forEach(param => {
  152. // filename might include unicode characters so it is a special case
  153. // other values probably do not
  154. let value = structured.params[param];
  155. if (!this.isPlainText(value) || value.length >= 75) {
  156. this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
  157. if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
  158. paramsArray.push(encodedParam.key + '=' + encodedParam.value);
  159. } else {
  160. paramsArray.push(encodedParam.key + '=' + JSON.stringify(encodedParam.value));
  161. }
  162. });
  163. } else if (/[\s'"\\;:/=(),<>@[\]?]|^-/.test(value)) {
  164. paramsArray.push(param + '=' + JSON.stringify(value));
  165. } else {
  166. paramsArray.push(param + '=' + value);
  167. }
  168. });
  169. return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
  170. },
  171. /**
  172. * Encodes a string or an Buffer to an UTF-8 Parameter Value Continuation encoding (rfc2231)
  173. * Useful for splitting long parameter values.
  174. *
  175. * For example
  176. * title="unicode string"
  177. * becomes
  178. * title*0*=utf-8''unicode
  179. * title*1*=%20string
  180. *
  181. * @param {String|Buffer} data String to be encoded
  182. * @param {Number} [maxLength=50] Max length for generated chunks
  183. * @param {String} [fromCharset='UTF-8'] Source sharacter set
  184. * @return {Array} A list of encoded keys and headers
  185. */
  186. buildHeaderParam(key, data, maxLength) {
  187. let list = [];
  188. let encodedStr = typeof data === 'string' ? data : (data || '').toString();
  189. let encodedStrArr;
  190. let chr, ord;
  191. let line;
  192. let startPos = 0;
  193. let i, len;
  194. maxLength = maxLength || 50;
  195. // process ascii only text
  196. if (this.isPlainText(data)) {
  197. // check if conversion is even needed
  198. if (encodedStr.length <= maxLength) {
  199. return [
  200. {
  201. key,
  202. value: encodedStr
  203. }
  204. ];
  205. }
  206. encodedStr = encodedStr.replace(new RegExp('.{' + maxLength + '}', 'g'), str => {
  207. list.push({
  208. line: str
  209. });
  210. return '';
  211. });
  212. if (encodedStr) {
  213. list.push({
  214. line: encodedStr
  215. });
  216. }
  217. } else {
  218. if (/[\uD800-\uDBFF]/.test(encodedStr)) {
  219. // string containts surrogate pairs, so normalize it to an array of bytes
  220. encodedStrArr = [];
  221. for (i = 0, len = encodedStr.length; i < len; i++) {
  222. chr = encodedStr.charAt(i);
  223. ord = chr.charCodeAt(0);
  224. if (ord >= 0xd800 && ord <= 0xdbff && i < len - 1) {
  225. chr += encodedStr.charAt(i + 1);
  226. encodedStrArr.push(chr);
  227. i++;
  228. } else {
  229. encodedStrArr.push(chr);
  230. }
  231. }
  232. encodedStr = encodedStrArr;
  233. }
  234. // first line includes the charset and language info and needs to be encoded
  235. // even if it does not contain any unicode characters
  236. line = 'utf-8\'\'';
  237. let encoded = true;
  238. startPos = 0;
  239. // process text with unicode or special chars
  240. for (i = 0, len = encodedStr.length; i < len; i++) {
  241. chr = encodedStr[i];
  242. if (encoded) {
  243. chr = this.safeEncodeURIComponent(chr);
  244. } else {
  245. // try to urlencode current char
  246. chr = chr === ' ' ? chr : this.safeEncodeURIComponent(chr);
  247. // By default it is not required to encode a line, the need
  248. // only appears when the string contains unicode or special chars
  249. // in this case we start processing the line over and encode all chars
  250. if (chr !== encodedStr[i]) {
  251. // Check if it is even possible to add the encoded char to the line
  252. // If not, there is no reason to use this line, just push it to the list
  253. // and start a new line with the char that needs encoding
  254. if ((this.safeEncodeURIComponent(line) + chr).length >= maxLength) {
  255. list.push({
  256. line,
  257. encoded
  258. });
  259. line = '';
  260. startPos = i - 1;
  261. } else {
  262. encoded = true;
  263. i = startPos;
  264. line = '';
  265. continue;
  266. }
  267. }
  268. }
  269. // if the line is already too long, push it to the list and start a new one
  270. if ((line + chr).length >= maxLength) {
  271. list.push({
  272. line,
  273. encoded
  274. });
  275. line = chr = encodedStr[i] === ' ' ? ' ' : this.safeEncodeURIComponent(encodedStr[i]);
  276. if (chr === encodedStr[i]) {
  277. encoded = false;
  278. startPos = i - 1;
  279. } else {
  280. encoded = true;
  281. }
  282. } else {
  283. line += chr;
  284. }
  285. }
  286. if (line) {
  287. list.push({
  288. line,
  289. encoded
  290. });
  291. }
  292. }
  293. return list.map((item, i) => ({
  294. // encoded lines: {name}*{part}*
  295. // unencoded lines: {name}*{part}
  296. // if any line needs to be encoded then the first line (part==0) is always encoded
  297. key: key + '*' + i + (item.encoded ? '*' : ''),
  298. value: item.line
  299. }));
  300. },
  301. /**
  302. * Parses a header value with key=value arguments into a structured
  303. * object.
  304. *
  305. * parseHeaderValue('content-type: text/plain; CHARSET='UTF-8'') ->
  306. * {
  307. * 'value': 'text/plain',
  308. * 'params': {
  309. * 'charset': 'UTF-8'
  310. * }
  311. * }
  312. *
  313. * @param {String} str Header value
  314. * @return {Object} Header value as a parsed structure
  315. */
  316. parseHeaderValue(str) {
  317. let response = {
  318. value: false,
  319. params: {}
  320. };
  321. let key = false;
  322. let value = '';
  323. let type = 'value';
  324. let quote = false;
  325. let escaped = false;
  326. let chr;
  327. for (let i = 0, len = str.length; i < len; i++) {
  328. chr = str.charAt(i);
  329. if (type === 'key') {
  330. if (chr === '=') {
  331. key = value.trim().toLowerCase();
  332. type = 'value';
  333. value = '';
  334. continue;
  335. }
  336. value += chr;
  337. } else {
  338. if (escaped) {
  339. value += chr;
  340. } else if (chr === '\\') {
  341. escaped = true;
  342. continue;
  343. } else if (quote && chr === quote) {
  344. quote = false;
  345. } else if (!quote && chr === '"') {
  346. quote = chr;
  347. } else if (!quote && chr === ';') {
  348. if (key === false) {
  349. response.value = value.trim();
  350. } else {
  351. response.params[key] = value.trim();
  352. }
  353. type = 'key';
  354. value = '';
  355. } else {
  356. value += chr;
  357. }
  358. escaped = false;
  359. }
  360. }
  361. if (type === 'value') {
  362. if (key === false) {
  363. response.value = value.trim();
  364. } else {
  365. response.params[key] = value.trim();
  366. }
  367. } else if (value.trim()) {
  368. response.params[value.trim().toLowerCase()] = '';
  369. }
  370. // handle parameter value continuations
  371. // https://tools.ietf.org/html/rfc2231#section-3
  372. // preprocess values
  373. Object.keys(response.params).forEach(key => {
  374. let actualKey, nr, match, value;
  375. if ((match = key.match(/(\*(\d+)|\*(\d+)\*|\*)$/))) {
  376. actualKey = key.substr(0, match.index);
  377. nr = Number(match[2] || match[3]) || 0;
  378. if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
  379. response.params[actualKey] = {
  380. charset: false,
  381. values: []
  382. };
  383. }
  384. value = response.params[key];
  385. if (nr === 0 && match[0].substr(-1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
  386. response.params[actualKey].charset = match[1] || 'iso-8859-1';
  387. value = match[2];
  388. }
  389. response.params[actualKey].values[nr] = value;
  390. // remove the old reference
  391. delete response.params[key];
  392. }
  393. });
  394. // concatenate split rfc2231 strings and convert encoded strings to mime encoded words
  395. Object.keys(response.params).forEach(key => {
  396. let value;
  397. if (response.params[key] && Array.isArray(response.params[key].values)) {
  398. value = response.params[key].values.map(val => val || '').join('');
  399. if (response.params[key].charset) {
  400. // convert "%AB" to "=?charset?Q?=AB?="
  401. response.params[key] =
  402. '=?' +
  403. response.params[key].charset +
  404. '?Q?' +
  405. value
  406. // fix invalidly encoded chars
  407. .replace(/[=?_\s]/g, s => {
  408. let c = s.charCodeAt(0).toString(16);
  409. if (s === ' ') {
  410. return '_';
  411. } else {
  412. return '%' + (c.length < 2 ? '0' : '') + c;
  413. }
  414. })
  415. // change from urlencoding to percent encoding
  416. .replace(/%/g, '=') +
  417. '?=';
  418. } else {
  419. response.params[key] = value;
  420. }
  421. }
  422. });
  423. return response;
  424. },
  425. /**
  426. * Returns file extension for a content type string. If no suitable extensions
  427. * are found, 'bin' is used as the default extension
  428. *
  429. * @param {String} mimeType Content type to be checked for
  430. * @return {String} File extension
  431. */
  432. detectExtension: mimeType => mimeTypes.detectExtension(mimeType),
  433. /**
  434. * Returns content type for a file extension. If no suitable content types
  435. * are found, 'application/octet-stream' is used as the default content type
  436. *
  437. * @param {String} extension Extension to be checked for
  438. * @return {String} File extension
  439. */
  440. detectMimeType: extension => mimeTypes.detectMimeType(extension),
  441. /**
  442. * Folds long lines, useful for folding header lines (afterSpace=false) and
  443. * flowed text (afterSpace=true)
  444. *
  445. * @param {String} str String to be folded
  446. * @param {Number} [lineLength=76] Maximum length of a line
  447. * @param {Boolean} afterSpace If true, leave a space in th end of a line
  448. * @return {String} String with folded lines
  449. */
  450. foldLines(str, lineLength, afterSpace) {
  451. str = (str || '').toString();
  452. lineLength = lineLength || 76;
  453. let pos = 0,
  454. len = str.length,
  455. result = '',
  456. line,
  457. match;
  458. while (pos < len) {
  459. line = str.substr(pos, lineLength);
  460. if (line.length < lineLength) {
  461. result += line;
  462. break;
  463. }
  464. if ((match = line.match(/^[^\n\r]*(\r?\n|\r)/))) {
  465. line = match[0];
  466. result += line;
  467. pos += line.length;
  468. continue;
  469. } else if ((match = line.match(/(\s+)[^\s]*$/)) && match[0].length - (afterSpace ? (match[1] || '').length : 0) < line.length) {
  470. line = line.substr(0, line.length - (match[0].length - (afterSpace ? (match[1] || '').length : 0)));
  471. } else if ((match = str.substr(pos + line.length).match(/^[^\s]+(\s*)/))) {
  472. line = line + match[0].substr(0, match[0].length - (!afterSpace ? (match[1] || '').length : 0));
  473. }
  474. result += line;
  475. pos += line.length;
  476. if (pos < len) {
  477. result += '\r\n';
  478. }
  479. }
  480. return result;
  481. },
  482. /**
  483. * Splits a mime encoded string. Needed for dividing mime words into smaller chunks
  484. *
  485. * @param {String} str Mime encoded string to be split up
  486. * @param {Number} maxlen Maximum length of characters for one part (minimum 12)
  487. * @return {Array} Split string
  488. */
  489. splitMimeEncodedString: (str, maxlen) => {
  490. let curLine,
  491. match,
  492. chr,
  493. done,
  494. lines = [];
  495. // require at least 12 symbols to fit possible 4 octet UTF-8 sequences
  496. maxlen = Math.max(maxlen || 0, 12);
  497. while (str.length) {
  498. curLine = str.substr(0, maxlen);
  499. // move incomplete escaped char back to main
  500. if ((match = curLine.match(/[=][0-9A-F]?$/i))) {
  501. curLine = curLine.substr(0, match.index);
  502. }
  503. done = false;
  504. while (!done) {
  505. done = true;
  506. // check if not middle of a unicode char sequence
  507. if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
  508. chr = parseInt(match[1], 16);
  509. // invalid sequence, move one char back anc recheck
  510. if (chr < 0xc2 && chr > 0x7f) {
  511. curLine = curLine.substr(0, curLine.length - 3);
  512. done = false;
  513. }
  514. }
  515. }
  516. if (curLine.length) {
  517. lines.push(curLine);
  518. }
  519. str = str.substr(curLine.length);
  520. }
  521. return lines;
  522. },
  523. encodeURICharComponent: chr => {
  524. let res = '';
  525. let ord = chr
  526. .charCodeAt(0)
  527. .toString(16)
  528. .toUpperCase();
  529. if (ord.length % 2) {
  530. ord = '0' + ord;
  531. }
  532. if (ord.length > 2) {
  533. for (let i = 0, len = ord.length / 2; i < len; i++) {
  534. res += '%' + ord.substr(i, 2);
  535. }
  536. } else {
  537. res += '%' + ord;
  538. }
  539. return res;
  540. },
  541. safeEncodeURIComponent(str) {
  542. str = (str || '').toString();
  543. try {
  544. // might throw if we try to encode invalid sequences, eg. partial emoji
  545. str = encodeURIComponent(str);
  546. } catch (E) {
  547. // should never run
  548. return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
  549. }
  550. // ensure chars that are not handled by encodeURICompent are converted as well
  551. return str.replace(/[\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]/g, chr => this.encodeURICharComponent(chr));
  552. }
  553. };