smtp-send.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * @node smtp-send
  3. * @name Send Email
  4. * @category email
  5. * @version 1.0.0
  6. * @description Send an email through an SMTP server, with optional attachments taken from the file store
  7. * @icon send
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. credentialId: {
  13. type: 'string',
  14. title: 'Mail Credential',
  15. description: 'An SMTP credential, or the IMAP credential for the same mailbox',
  16. dynamicOptions: {
  17. source: 'credentials',
  18. filter: { type: ['smtp', 'imap'] }
  19. }
  20. },
  21. to: {
  22. type: 'string',
  23. title: 'To',
  24. description: 'Recipient address, or several separated by commas'
  25. },
  26. cc: {
  27. type: 'string',
  28. title: 'Cc',
  29. description: 'Copied recipients, separated by commas'
  30. },
  31. bcc: {
  32. type: 'string',
  33. title: 'Bcc',
  34. description: 'Blind copied recipients, separated by commas'
  35. },
  36. subject: {
  37. type: 'string',
  38. title: 'Subject'
  39. },
  40. body: {
  41. type: 'string',
  42. title: 'Body',
  43. description: 'Message text. Supports {{variable}} interpolation.'
  44. },
  45. html: {
  46. type: 'boolean',
  47. title: 'Send as HTML',
  48. description: 'Send the body as HTML rather than plain text',
  49. default: false
  50. },
  51. replyTo: {
  52. type: 'string',
  53. title: 'Reply-To',
  54. description: 'Address replies should go to, when it differs from the sender'
  55. },
  56. fromAddress: {
  57. type: 'string',
  58. title: 'From',
  59. description: 'Sender address. Defaults to the one on the credential.'
  60. },
  61. fromName: {
  62. type: 'string',
  63. title: 'From Name',
  64. description: 'Name shown to recipients alongside the address'
  65. },
  66. attachmentFileIds: {
  67. type: 'string',
  68. title: 'Attachment File IDs',
  69. description: 'File store ids to attach, separated by commas'
  70. }
  71. },
  72. required: ['credentialId', 'to']
  73. };
  74. const inputSchema = {
  75. type: 'object',
  76. properties: {
  77. to: { type: 'string', description: 'Overrides the configured recipients' },
  78. subject: { type: 'string', description: 'Overrides the configured subject' },
  79. body: { type: 'string', description: 'Overrides the configured body' },
  80. attachments: {
  81. type: 'array',
  82. description: 'Attachments supplied by an earlier node',
  83. items: {
  84. type: 'object',
  85. properties: {
  86. filename: { type: 'string' },
  87. mimeType: { type: 'string' },
  88. contentBase64: { type: 'string', description: 'Attachment content, base64 encoded' },
  89. fileId: { type: 'string', description: 'File store id to read the content from instead' }
  90. }
  91. }
  92. }
  93. }
  94. };
  95. const outputSchema = {
  96. type: 'object',
  97. properties: {
  98. success: { type: 'boolean' },
  99. messageId: { type: 'string', description: 'The Message-ID the mail was sent with' },
  100. accepted: { type: 'integer', description: 'How many addresses the message was submitted for' },
  101. to: { type: 'array', items: { type: 'string' } },
  102. subject: { type: 'string' }
  103. }
  104. };
  105. const outputs = [
  106. { name: 'main', displayName: 'Sent', type: 'object', color: '#3b82f6' }
  107. ];
  108. // A connection hands the previous node's whole output over as input.data, and
  109. // some nodes wrap their own return in a result object on top of that. Rather
  110. // than guess which shape arrived, the layers are flattened with the innermost
  111. // winning, so a field set anywhere in the chain is found.
  112. function incoming(input) {
  113. const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v);
  114. const merged = {};
  115. for (const layer of [input, input && input.data, input && input.data && input.data.result]) {
  116. if (!isPlain(layer)) continue;
  117. for (const key of Object.keys(layer)) {
  118. merged[key] = layer[key];
  119. }
  120. }
  121. return merged;
  122. }
  123. // Addresses are written the way people write them - one line, commas between -
  124. // so the node splits rather than asking for an array in the form.
  125. function splitAddresses(value) {
  126. if (!value) return [];
  127. if (Array.isArray(value)) return value.map(String).map(a => a.trim()).filter(Boolean);
  128. return String(value).split(',').map(a => a.trim()).filter(Boolean);
  129. }
  130. // An attachment is either carried inline by the previous node or left in the
  131. // file store; a file id means the bytes have to be fetched before sending.
  132. function resolveAttachment(entry) {
  133. if (entry.contentBase64) {
  134. return {
  135. filename: entry.filename || 'attachment',
  136. mimeType: entry.mimeType || '',
  137. contentBase64: entry.contentBase64
  138. };
  139. }
  140. if (!entry.fileId) return null;
  141. const info = smartbotic.storage.getFileInfo(entry.fileId);
  142. const file = smartbotic.storage.downloadFile(entry.fileId);
  143. if (!file || !file.success || !file.data) return null;
  144. return {
  145. filename: entry.filename || (info && info.name) || entry.fileId,
  146. mimeType: entry.mimeType || (info && info.mimeType) || '',
  147. contentBase64: file.data
  148. };
  149. }
  150. module.exports = {
  151. configSchema,
  152. inputSchema,
  153. outputSchema,
  154. outputs,
  155. async execute(config, input, context) {
  156. const from = incoming(input);
  157. if (!config.credentialId) {
  158. throw new Error('A mail credential is required');
  159. }
  160. const to = splitAddresses(from.to || config.to);
  161. if (to.length === 0) {
  162. throw new Error('At least one recipient is required');
  163. }
  164. const subject = from.subject || config.subject || '';
  165. const body = from.body || config.body || '';
  166. const attachments = [];
  167. for (const entry of (from.attachments || [])) {
  168. const resolved = resolveAttachment(entry);
  169. if (resolved) attachments.push(resolved);
  170. }
  171. for (const fileId of splitAddresses(config.attachmentFileIds)) {
  172. const resolved = resolveAttachment({ fileId });
  173. if (resolved) attachments.push(resolved);
  174. }
  175. const result = smartbotic.smtp.send({
  176. credentialId: config.credentialId,
  177. to,
  178. cc: splitAddresses(config.cc),
  179. bcc: splitAddresses(config.bcc),
  180. subject,
  181. body,
  182. html: config.html === true,
  183. replyTo: config.replyTo || '',
  184. from: config.fromAddress || '',
  185. fromName: config.fromName || '',
  186. attachments
  187. });
  188. smartbotic.log.info('Sent "' + subject + '" to ' + to.join(', '));
  189. return {
  190. success: true,
  191. messageId: result.messageId,
  192. accepted: result.accepted,
  193. to,
  194. subject
  195. };
  196. }
  197. };