index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  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. icalEvent,
  172. eventObject;
  173. if (this.mail.text) {
  174. if (typeof this.mail.text === 'object' && (this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)) {
  175. text = this.mail.text;
  176. } else {
  177. text = {
  178. content: this.mail.text
  179. };
  180. }
  181. text.contentType = 'text/plain' + (!text.encoding && mimeFuncs.isPlainText(text.content) ? '' : '; charset=utf-8');
  182. }
  183. if (this.mail.watchHtml) {
  184. if (
  185. typeof this.mail.watchHtml === 'object' &&
  186. (this.mail.watchHtml.content || this.mail.watchHtml.path || this.mail.watchHtml.href || this.mail.watchHtml.raw)
  187. ) {
  188. watchHtml = this.mail.watchHtml;
  189. } else {
  190. watchHtml = {
  191. content: this.mail.watchHtml
  192. };
  193. }
  194. watchHtml.contentType = 'text/watch-html' + (!watchHtml.encoding && mimeFuncs.isPlainText(watchHtml.content) ? '' : '; charset=utf-8');
  195. }
  196. // only include the calendar alternative if there are no attachments
  197. // otherwise you might end up in a blank screen on some clients
  198. if (this.mail.icalEvent && !(this.mail.attachments && this.mail.attachments.length)) {
  199. if (
  200. typeof this.mail.icalEvent === 'object' &&
  201. (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
  202. ) {
  203. icalEvent = this.mail.icalEvent;
  204. } else {
  205. icalEvent = {
  206. content: this.mail.icalEvent
  207. };
  208. }
  209. eventObject = {};
  210. Object.keys(icalEvent).forEach(key => {
  211. eventObject[key] = icalEvent[key];
  212. });
  213. if (eventObject.content && typeof eventObject.content === 'object') {
  214. // we are going to have the same attachment twice, so mark this to be
  215. // resolved just once
  216. eventObject.content._resolve = true;
  217. }
  218. eventObject.filename = false;
  219. eventObject.contentType =
  220. 'text/calendar; charset="utf-8"; method=' +
  221. (eventObject.method || 'PUBLISH')
  222. .toString()
  223. .trim()
  224. .toUpperCase();
  225. if (!eventObject.headers) {
  226. eventObject.headers = {};
  227. }
  228. }
  229. if (this.mail.html) {
  230. if (typeof this.mail.html === 'object' && (this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)) {
  231. html = this.mail.html;
  232. } else {
  233. html = {
  234. content: this.mail.html
  235. };
  236. }
  237. html.contentType = 'text/html' + (!html.encoding && mimeFuncs.isPlainText(html.content) ? '' : '; charset=utf-8');
  238. }
  239. []
  240. .concat(text || [])
  241. .concat(watchHtml || [])
  242. .concat(html || [])
  243. .concat(eventObject || [])
  244. .concat(this.mail.alternatives || [])
  245. .forEach(alternative => {
  246. let data;
  247. if (/^data:/i.test(alternative.path || alternative.href)) {
  248. alternative = this._processDataUrl(alternative);
  249. }
  250. data = {
  251. contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
  252. contentTransferEncoding: alternative.contentTransferEncoding
  253. };
  254. if (alternative.filename) {
  255. data.filename = alternative.filename;
  256. }
  257. if (/^https?:\/\//i.test(alternative.path)) {
  258. alternative.href = alternative.path;
  259. alternative.path = undefined;
  260. }
  261. if (alternative.raw) {
  262. data.raw = alternative.raw;
  263. } else if (alternative.path) {
  264. data.content = {
  265. path: alternative.path
  266. };
  267. } else if (alternative.href) {
  268. data.content = {
  269. href: alternative.href
  270. };
  271. } else {
  272. data.content = alternative.content || '';
  273. }
  274. if (alternative.encoding) {
  275. data.encoding = alternative.encoding;
  276. }
  277. if (alternative.headers) {
  278. data.headers = alternative.headers;
  279. }
  280. alternatives.push(data);
  281. });
  282. return alternatives;
  283. }
  284. /**
  285. * Builds multipart/mixed node. It should always contain different type of elements on the same level
  286. * eg. text + attachments
  287. *
  288. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  289. * @returns {Object} MimeNode node element
  290. */
  291. _createMixed(parentNode) {
  292. let node;
  293. if (!parentNode) {
  294. node = new MimeNode('multipart/mixed', {
  295. baseBoundary: this.mail.baseBoundary,
  296. textEncoding: this.mail.textEncoding,
  297. boundaryPrefix: this.mail.boundaryPrefix,
  298. disableUrlAccess: this.mail.disableUrlAccess,
  299. disableFileAccess: this.mail.disableFileAccess
  300. });
  301. } else {
  302. node = parentNode.createChild('multipart/mixed', {
  303. disableUrlAccess: this.mail.disableUrlAccess,
  304. disableFileAccess: this.mail.disableFileAccess
  305. });
  306. }
  307. if (this._useAlternative) {
  308. this._createAlternative(node);
  309. } else if (this._useRelated) {
  310. this._createRelated(node);
  311. }
  312. []
  313. .concat((!this._useAlternative && this._alternatives) || [])
  314. .concat(this._attachments.attached || [])
  315. .forEach(element => {
  316. // if the element is a html node from related subpart then ignore it
  317. if (!this._useRelated || element !== this._htmlNode) {
  318. this._createContentNode(node, element);
  319. }
  320. });
  321. return node;
  322. }
  323. /**
  324. * Builds multipart/alternative node. It should always contain same type of elements on the same level
  325. * eg. text + html view of the same data
  326. *
  327. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  328. * @returns {Object} MimeNode node element
  329. */
  330. _createAlternative(parentNode) {
  331. let node;
  332. if (!parentNode) {
  333. node = new MimeNode('multipart/alternative', {
  334. baseBoundary: this.mail.baseBoundary,
  335. textEncoding: this.mail.textEncoding,
  336. boundaryPrefix: this.mail.boundaryPrefix,
  337. disableUrlAccess: this.mail.disableUrlAccess,
  338. disableFileAccess: this.mail.disableFileAccess
  339. });
  340. } else {
  341. node = parentNode.createChild('multipart/alternative', {
  342. disableUrlAccess: this.mail.disableUrlAccess,
  343. disableFileAccess: this.mail.disableFileAccess
  344. });
  345. }
  346. this._alternatives.forEach(alternative => {
  347. if (this._useRelated && this._htmlNode === alternative) {
  348. this._createRelated(node);
  349. } else {
  350. this._createContentNode(node, alternative);
  351. }
  352. });
  353. return node;
  354. }
  355. /**
  356. * Builds multipart/related node. It should always contain html node with related attachments
  357. *
  358. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  359. * @returns {Object} MimeNode node element
  360. */
  361. _createRelated(parentNode) {
  362. let node;
  363. if (!parentNode) {
  364. node = new MimeNode('multipart/related; type="text/html"', {
  365. baseBoundary: this.mail.baseBoundary,
  366. textEncoding: this.mail.textEncoding,
  367. boundaryPrefix: this.mail.boundaryPrefix,
  368. disableUrlAccess: this.mail.disableUrlAccess,
  369. disableFileAccess: this.mail.disableFileAccess
  370. });
  371. } else {
  372. node = parentNode.createChild('multipart/related; type="text/html"', {
  373. disableUrlAccess: this.mail.disableUrlAccess,
  374. disableFileAccess: this.mail.disableFileAccess
  375. });
  376. }
  377. this._createContentNode(node, this._htmlNode);
  378. this._attachments.related.forEach(alternative => this._createContentNode(node, alternative));
  379. return node;
  380. }
  381. /**
  382. * Creates a regular node with contents
  383. *
  384. * @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
  385. * @param {Object} element Node data
  386. * @returns {Object} MimeNode node element
  387. */
  388. _createContentNode(parentNode, element) {
  389. element = element || {};
  390. element.content = element.content || '';
  391. let node;
  392. let encoding = (element.encoding || 'utf8')
  393. .toString()
  394. .toLowerCase()
  395. .replace(/[-_\s]/g, '');
  396. if (!parentNode) {
  397. node = new MimeNode(element.contentType, {
  398. filename: element.filename,
  399. baseBoundary: this.mail.baseBoundary,
  400. textEncoding: this.mail.textEncoding,
  401. boundaryPrefix: this.mail.boundaryPrefix,
  402. disableUrlAccess: this.mail.disableUrlAccess,
  403. disableFileAccess: this.mail.disableFileAccess
  404. });
  405. } else {
  406. node = parentNode.createChild(element.contentType, {
  407. filename: element.filename,
  408. disableUrlAccess: this.mail.disableUrlAccess,
  409. disableFileAccess: this.mail.disableFileAccess
  410. });
  411. }
  412. // add custom headers
  413. if (element.headers) {
  414. node.addHeader(element.headers);
  415. }
  416. if (element.cid) {
  417. node.setHeader('Content-Id', '<' + element.cid.replace(/[<>]/g, '') + '>');
  418. }
  419. if (element.contentTransferEncoding) {
  420. node.setHeader('Content-Transfer-Encoding', element.contentTransferEncoding);
  421. } else if (this.mail.encoding && /^text\//i.test(element.contentType)) {
  422. node.setHeader('Content-Transfer-Encoding', this.mail.encoding);
  423. }
  424. if (!/^text\//i.test(element.contentType) || element.contentDisposition) {
  425. node.setHeader('Content-Disposition', element.contentDisposition || (element.cid ? 'inline' : 'attachment'));
  426. }
  427. if (typeof element.content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) {
  428. element.content = Buffer.from(element.content, encoding);
  429. }
  430. // prefer pregenerated raw content
  431. if (element.raw) {
  432. node.setRaw(element.raw);
  433. } else {
  434. node.setContent(element.content);
  435. }
  436. return node;
  437. }
  438. /**
  439. * Parses data uri and converts it to a Buffer
  440. *
  441. * @param {Object} element Content element
  442. * @return {Object} Parsed element
  443. */
  444. _processDataUrl(element) {
  445. let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
  446. if (!parts) {
  447. return element;
  448. }
  449. element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]));
  450. if ('path' in element) {
  451. element.path = false;
  452. }
  453. if ('href' in element) {
  454. element.href = false;
  455. }
  456. parts[1].split(';').forEach(item => {
  457. if (/^\w+\/[^/]+$/i.test(item)) {
  458. element.contentType = element.contentType || item.toLowerCase();
  459. }
  460. });
  461. return element;
  462. }
  463. }
  464. module.exports = MailComposer;