index.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. /* eslint no-undefined: 0 */
  2. 'use strict';
  3. const MimeNode = require('../mime-node');
  4. const mimeFuncs = require('../mime-funcs');
  5. /**
  6. * Creates the object for composing a MimeNode instance out from the mail options
  7. *
  8. * @constructor
  9. * @param {Object} mail Mail options
  10. */
  11. class MailComposer {
  12. constructor(mail) {
  13. this.mail = mail || {};
  14. this.message = false;
  15. }
  16. /**
  17. * Builds MimeNode instance
  18. */
  19. compile() {
  20. this._alternatives = this.getAlternatives();
  21. this._htmlNode = this._alternatives.filter(alternative => /^text\/html\b/i.test(alternative.contentType)).pop();
  22. this._attachments = this.getAttachments(!!this._htmlNode);
  23. this._useRelated = !!(this._htmlNode && this._attachments.related.length);
  24. this._useAlternative = this._alternatives.length > 1;
  25. this._useMixed = this._attachments.attached.length > 1 || (this._alternatives.length && this._attachments.attached.length === 1);
  26. // Compose MIME tree
  27. if (this.mail.raw) {
  28. this.message = new MimeNode().setRaw(this.mail.raw);
  29. } else if (this._useMixed) {
  30. this.message = this._createMixed();
  31. } else if (this._useAlternative) {
  32. this.message = this._createAlternative();
  33. } else if (this._useRelated) {
  34. this.message = this._createRelated();
  35. } else {
  36. this.message = this._createContentNode(
  37. false,
  38. []
  39. .concat(this._alternatives || [])
  40. .concat(this._attachments.attached || [])
  41. .shift() || {
  42. contentType: 'text/plain',
  43. content: ''
  44. }
  45. );
  46. }
  47. // Add custom headers
  48. if (this.mail.headers) {
  49. this.message.addHeader(this.mail.headers);
  50. }
  51. // Add headers to the root node, always overrides custom headers
  52. ['from', 'sender', 'to', 'cc', 'bcc', 'reply-to', 'in-reply-to', 'references', 'subject', 'message-id', 'date'].forEach(header => {
  53. let key = header.replace(/-(\w)/g, (o, c) => c.toUpperCase());
  54. if (this.mail[key]) {
  55. this.message.setHeader(header, this.mail[key]);
  56. }
  57. });
  58. // Sets custom envelope
  59. if (this.mail.envelope) {
  60. this.message.setEnvelope(this.mail.envelope);
  61. }
  62. // ensure Message-Id value
  63. this.message.messageId();
  64. return this.message;
  65. }
  66. /**
  67. * List all attachments. Resulting attachment objects can be used as input for MimeNode nodes
  68. *
  69. * @param {Boolean} findRelated If true separate related attachments from attached ones
  70. * @returns {Object} An object of arrays (`related` and `attached`)
  71. */
  72. getAttachments(findRelated) {
  73. let icalEvent, eventObject;
  74. let attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
  75. let data;
  76. let isMessageNode = /^message\//i.test(attachment.contentType);
  77. if (/^data:/i.test(attachment.path || attachment.href)) {
  78. attachment = this._processDataUrl(attachment);
  79. }
  80. data = {
  81. contentType: attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin'),
  82. contentDisposition: attachment.contentDisposition || (isMessageNode ? 'inline' : 'attachment'),
  83. contentTransferEncoding: 'contentTransferEncoding' in attachment ? attachment.contentTransferEncoding : 'base64'
  84. };
  85. if (attachment.filename) {
  86. data.filename = attachment.filename;
  87. } else if (!isMessageNode && attachment.filename !== false) {
  88. data.filename =
  89. (attachment.path || attachment.href || '')
  90. .split('/')
  91. .pop()
  92. .split('?')
  93. .shift() || 'attachment-' + (i + 1);
  94. if (data.filename.indexOf('.') < 0) {
  95. data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
  96. }
  97. }
  98. if (/^https?:\/\//i.test(attachment.path)) {
  99. attachment.href = attachment.path;
  100. attachment.path = undefined;
  101. }
  102. if (attachment.cid) {
  103. data.cid = attachment.cid;
  104. }
  105. if (attachment.raw) {
  106. data.raw = attachment.raw;
  107. } else if (attachment.path) {
  108. data.content = {
  109. path: attachment.path
  110. };
  111. } else if (attachment.href) {
  112. data.content = {
  113. href: attachment.href
  114. };
  115. } else {
  116. data.content = attachment.content || '';
  117. }
  118. if (attachment.encoding) {
  119. data.encoding = attachment.encoding;
  120. }
  121. if (attachment.headers) {
  122. data.headers = attachment.headers;
  123. }
  124. return data;
  125. });
  126. if (this.mail.icalEvent) {
  127. if (
  128. typeof this.mail.icalEvent === 'object' &&
  129. (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
  130. ) {
  131. icalEvent = this.mail.icalEvent;
  132. } else {
  133. icalEvent = {
  134. content: this.mail.icalEvent
  135. };
  136. }
  137. eventObject = {};
  138. Object.keys(icalEvent).forEach(key => {
  139. eventObject[key] = icalEvent[key];
  140. });
  141. eventObject.contentType = 'application/ics';
  142. if (!eventObject.headers) {
  143. eventObject.headers = {};
  144. }
  145. eventObject.filename = eventObject.filename || 'invite.ics';
  146. eventObject.headers['Content-Disposition'] = 'attachment';
  147. eventObject.headers['Content-Transfer-Encoding'] = 'base64';
  148. }
  149. if (!findRelated) {
  150. return {
  151. attached: attachments.concat(eventObject || []),
  152. related: []
  153. };
  154. } else {
  155. return {
  156. attached: attachments.filter(attachment => !attachment.cid).concat(eventObject || []),
  157. related: attachments.filter(attachment => !!attachment.cid)
  158. };
  159. }
  160. }
  161. /**
  162. * List alternatives. Resulting objects can be used as input for MimeNode nodes
  163. *
  164. * @returns {Array} An array of alternative elements. Includes the `text` and `html` values as well
  165. */
  166. getAlternatives() {
  167. let alternatives = [],
  168. text,
  169. html,
  170. watchHtml,
  171. amp,
  172. icalEvent,
  173. eventObject;
  174. if (this.mail.text) {
  175. if (typeof this.mail.text === 'object' && (this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)) {
  176. text = this.mail.text;
  177. } else {
  178. text = {
  179. content: this.mail.text
  180. };
  181. }
  182. text.contentType = 'text/plain' + (!text.encoding && mimeFuncs.isPlainText(text.content) ? '' : '; charset=utf-8');
  183. }
  184. if (this.mail.watchHtml) {
  185. if (
  186. typeof this.mail.watchHtml === 'object' &&
  187. (this.mail.watchHtml.content || this.mail.watchHtml.path || this.mail.watchHtml.href || this.mail.watchHtml.raw)
  188. ) {
  189. watchHtml = this.mail.watchHtml;
  190. } else {
  191. watchHtml = {
  192. content: this.mail.watchHtml
  193. };
  194. }
  195. watchHtml.contentType = 'text/watch-html' + (!watchHtml.encoding && mimeFuncs.isPlainText(watchHtml.content) ? '' : '; charset=utf-8');
  196. }
  197. if (this.mail.amp) {
  198. if (typeof this.mail.amp === 'object' && (this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)) {
  199. amp = this.mail.amp;
  200. } else {
  201. amp = {
  202. content: this.mail.amp
  203. };
  204. }
  205. amp.contentType = 'text/x-amp-html' + (!amp.encoding && mimeFuncs.isPlainText(amp.content) ? '' : '; charset=utf-8');
  206. }
  207. // only include the calendar alternative if there are no attachments
  208. // otherwise you might end up in a blank screen on some clients
  209. if (this.mail.icalEvent && !(this.mail.attachments && this.mail.attachments.length)) {
  210. if (
  211. typeof this.mail.icalEvent === 'object' &&
  212. (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
  213. ) {
  214. icalEvent = this.mail.icalEvent;
  215. } else {
  216. icalEvent = {
  217. content: this.mail.icalEvent
  218. };
  219. }
  220. eventObject = {};
  221. Object.keys(icalEvent).forEach(key => {
  222. eventObject[key] = icalEvent[key];
  223. });
  224. if (eventObject.content && typeof eventObject.content === 'object') {
  225. // we are going to have the same attachment twice, so mark this to be
  226. // resolved just once
  227. eventObject.content._resolve = true;
  228. }
  229. eventObject.filename = false;
  230. eventObject.contentType =
  231. 'text/calendar; charset="utf-8"; method=' +
  232. (eventObject.method || 'PUBLISH')
  233. .toString()
  234. .trim()
  235. .toUpperCase();
  236. if (!eventObject.headers) {
  237. eventObject.headers = {};
  238. }
  239. }
  240. if (this.mail.html) {
  241. if (typeof this.mail.html === 'object' && (this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)) {
  242. html = this.mail.html;
  243. } else {
  244. html = {
  245. content: this.mail.html
  246. };
  247. }
  248. html.contentType = 'text/html' + (!html.encoding && mimeFuncs.isPlainText(html.content) ? '' : '; charset=utf-8');
  249. }
  250. []
  251. .concat(text || [])
  252. .concat(watchHtml || [])
  253. .concat(amp || [])
  254. .concat(html || [])
  255. .concat(eventObject || [])
  256. .concat(this.mail.alternatives || [])
  257. .forEach(alternative => {
  258. let data;
  259. if (/^data:/i.test(alternative.path || alternative.href)) {
  260. alternative = this._processDataUrl(alternative);
  261. }
  262. data = {
  263. contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
  264. contentTransferEncoding: alternative.contentTransferEncoding
  265. };
  266. if (alternative.filename) {
  267. data.filename = alternative.filename;
  268. }
  269. if (/^https?:\/\//i.test(alternative.path)) {
  270. alternative.href = alternative.path;
  271. alternative.path = undefined;
  272. }
  273. if (alternative.raw) {
  274. data.raw = alternative.raw;
  275. } else if (alternative.path) {
  276. data.content = {
  277. path: alternative.path
  278. };
  279. } else if (alternative.href) {
  280. data.content = {
  281. href: alternative.href
  282. };
  283. } else {
  284. data.content = alternative.content || '';
  285. }
  286. if (alternative.encoding) {
  287. data.encoding = alternative.encoding;
  288. }
  289. if (alternative.headers) {
  290. data.headers = alternative.headers;
  291. }
  292. alternatives.push(data);
  293. });
  294. return alternatives;
  295. }
  296. /**
  297. * Builds multipart/mixed node. It should always contain different type of elements on the same level
  298. * eg. text + attachments
  299. *
  300. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  301. * @returns {Object} MimeNode node element
  302. */
  303. _createMixed(parentNode) {
  304. let node;
  305. if (!parentNode) {
  306. node = new MimeNode('multipart/mixed', {
  307. baseBoundary: this.mail.baseBoundary,
  308. textEncoding: this.mail.textEncoding,
  309. boundaryPrefix: this.mail.boundaryPrefix,
  310. disableUrlAccess: this.mail.disableUrlAccess,
  311. disableFileAccess: this.mail.disableFileAccess,
  312. normalizeHeaderKey: this.mail.normalizeHeaderKey
  313. });
  314. } else {
  315. node = parentNode.createChild('multipart/mixed', {
  316. disableUrlAccess: this.mail.disableUrlAccess,
  317. disableFileAccess: this.mail.disableFileAccess,
  318. normalizeHeaderKey: this.mail.normalizeHeaderKey
  319. });
  320. }
  321. if (this._useAlternative) {
  322. this._createAlternative(node);
  323. } else if (this._useRelated) {
  324. this._createRelated(node);
  325. }
  326. []
  327. .concat((!this._useAlternative && this._alternatives) || [])
  328. .concat(this._attachments.attached || [])
  329. .forEach(element => {
  330. // if the element is a html node from related subpart then ignore it
  331. if (!this._useRelated || element !== this._htmlNode) {
  332. this._createContentNode(node, element);
  333. }
  334. });
  335. return node;
  336. }
  337. /**
  338. * Builds multipart/alternative node. It should always contain same type of elements on the same level
  339. * eg. text + html view of the same data
  340. *
  341. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  342. * @returns {Object} MimeNode node element
  343. */
  344. _createAlternative(parentNode) {
  345. let node;
  346. if (!parentNode) {
  347. node = new MimeNode('multipart/alternative', {
  348. baseBoundary: this.mail.baseBoundary,
  349. textEncoding: this.mail.textEncoding,
  350. boundaryPrefix: this.mail.boundaryPrefix,
  351. disableUrlAccess: this.mail.disableUrlAccess,
  352. disableFileAccess: this.mail.disableFileAccess,
  353. normalizeHeaderKey: this.mail.normalizeHeaderKey
  354. });
  355. } else {
  356. node = parentNode.createChild('multipart/alternative', {
  357. disableUrlAccess: this.mail.disableUrlAccess,
  358. disableFileAccess: this.mail.disableFileAccess,
  359. normalizeHeaderKey: this.mail.normalizeHeaderKey
  360. });
  361. }
  362. this._alternatives.forEach(alternative => {
  363. if (this._useRelated && this._htmlNode === alternative) {
  364. this._createRelated(node);
  365. } else {
  366. this._createContentNode(node, alternative);
  367. }
  368. });
  369. return node;
  370. }
  371. /**
  372. * Builds multipart/related node. It should always contain html node with related attachments
  373. *
  374. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  375. * @returns {Object} MimeNode node element
  376. */
  377. _createRelated(parentNode) {
  378. let node;
  379. if (!parentNode) {
  380. node = new MimeNode('multipart/related; type="text/html"', {
  381. baseBoundary: this.mail.baseBoundary,
  382. textEncoding: this.mail.textEncoding,
  383. boundaryPrefix: this.mail.boundaryPrefix,
  384. disableUrlAccess: this.mail.disableUrlAccess,
  385. disableFileAccess: this.mail.disableFileAccess,
  386. normalizeHeaderKey: this.mail.normalizeHeaderKey
  387. });
  388. } else {
  389. node = parentNode.createChild('multipart/related; type="text/html"', {
  390. disableUrlAccess: this.mail.disableUrlAccess,
  391. disableFileAccess: this.mail.disableFileAccess,
  392. normalizeHeaderKey: this.mail.normalizeHeaderKey
  393. });
  394. }
  395. this._createContentNode(node, this._htmlNode);
  396. this._attachments.related.forEach(alternative => this._createContentNode(node, alternative));
  397. return node;
  398. }
  399. /**
  400. * Creates a regular node with contents
  401. *
  402. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  403. * @param {Object} element Node data
  404. * @returns {Object} MimeNode node element
  405. */
  406. _createContentNode(parentNode, element) {
  407. element = element || {};
  408. element.content = element.content || '';
  409. let node;
  410. let encoding = (element.encoding || 'utf8')
  411. .toString()
  412. .toLowerCase()
  413. .replace(/[-_\s]/g, '');
  414. if (!parentNode) {
  415. node = new MimeNode(element.contentType, {
  416. filename: element.filename,
  417. baseBoundary: this.mail.baseBoundary,
  418. textEncoding: this.mail.textEncoding,
  419. boundaryPrefix: this.mail.boundaryPrefix,
  420. disableUrlAccess: this.mail.disableUrlAccess,
  421. disableFileAccess: this.mail.disableFileAccess
  422. });
  423. } else {
  424. node = parentNode.createChild(element.contentType, {
  425. filename: element.filename,
  426. disableUrlAccess: this.mail.disableUrlAccess,
  427. disableFileAccess: this.mail.disableFileAccess,
  428. normalizeHeaderKey: this.mail.normalizeHeaderKey
  429. });
  430. }
  431. // add custom headers
  432. if (element.headers) {
  433. node.addHeader(element.headers);
  434. }
  435. if (element.cid) {
  436. node.setHeader('Content-Id', '<' + element.cid.replace(/[<>]/g, '') + '>');
  437. }
  438. if (element.contentTransferEncoding) {
  439. node.setHeader('Content-Transfer-Encoding', element.contentTransferEncoding);
  440. } else if (this.mail.encoding && /^text\//i.test(element.contentType)) {
  441. node.setHeader('Content-Transfer-Encoding', this.mail.encoding);
  442. }
  443. if (!/^text\//i.test(element.contentType) || element.contentDisposition) {
  444. node.setHeader('Content-Disposition', element.contentDisposition || (element.cid ? 'inline' : 'attachment'));
  445. }
  446. if (typeof element.content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) {
  447. element.content = Buffer.from(element.content, encoding);
  448. }
  449. // prefer pregenerated raw content
  450. if (element.raw) {
  451. node.setRaw(element.raw);
  452. } else {
  453. node.setContent(element.content);
  454. }
  455. return node;
  456. }
  457. /**
  458. * Parses data uri and converts it to a Buffer
  459. *
  460. * @param {Object} element Content element
  461. * @return {Object} Parsed element
  462. */
  463. _processDataUrl(element) {
  464. let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
  465. if (!parts) {
  466. return element;
  467. }
  468. element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]));
  469. if ('path' in element) {
  470. element.path = false;
  471. }
  472. if ('href' in element) {
  473. element.href = false;
  474. }
  475. parts[1].split(';').forEach(item => {
  476. if (/^\w+\/[^/]+$/i.test(item)) {
  477. element.contentType = element.contentType || item.toLowerCase();
  478. }
  479. });
  480. return element;
  481. }
  482. }
  483. module.exports = MailComposer;