/** * @node smtp-send * @name Send Email * @category email * @version 1.0.0 * @description Send an email through an SMTP server, with optional attachments taken from the file store * @icon send */ const configSchema = { type: 'object', properties: { credentialId: { type: 'string', title: 'Mail Credential', description: 'An SMTP credential, or the IMAP credential for the same mailbox', dynamicOptions: { source: 'credentials', filter: { type: ['smtp', 'imap'] } } }, to: { type: 'string', title: 'To', description: 'Recipient address, or several separated by commas' }, cc: { type: 'string', title: 'Cc', description: 'Copied recipients, separated by commas' }, bcc: { type: 'string', title: 'Bcc', description: 'Blind copied recipients, separated by commas' }, subject: { type: 'string', title: 'Subject' }, body: { type: 'string', title: 'Body', description: 'Message text. Supports {{variable}} interpolation.' }, html: { type: 'boolean', title: 'Send as HTML', description: 'Send the body as HTML rather than plain text', default: false }, replyTo: { type: 'string', title: 'Reply-To', description: 'Address replies should go to, when it differs from the sender' }, fromAddress: { type: 'string', title: 'From', description: 'Sender address. Defaults to the one on the credential.' }, fromName: { type: 'string', title: 'From Name', description: 'Name shown to recipients alongside the address' }, attachmentFileIds: { type: 'string', title: 'Attachment File IDs', description: 'File store ids to attach, separated by commas' } }, required: ['credentialId', 'to'] }; const inputSchema = { type: 'object', properties: { to: { type: 'string', description: 'Overrides the configured recipients' }, subject: { type: 'string', description: 'Overrides the configured subject' }, body: { type: 'string', description: 'Overrides the configured body' }, attachments: { type: 'array', description: 'Attachments supplied by an earlier node', items: { type: 'object', properties: { filename: { type: 'string' }, mimeType: { type: 'string' }, contentBase64: { type: 'string', description: 'Attachment content, base64 encoded' }, fileId: { type: 'string', description: 'File store id to read the content from instead' } } } } } }; const outputSchema = { type: 'object', properties: { success: { type: 'boolean' }, messageId: { type: 'string', description: 'The Message-ID the mail was sent with' }, accepted: { type: 'integer', description: 'How many addresses the message was submitted for' }, to: { type: 'array', items: { type: 'string' } }, subject: { type: 'string' } } }; const outputs = [ { name: 'main', displayName: 'Sent', type: 'object', color: '#3b82f6' } ]; // A connection hands the previous node's whole output over as input.data, and // some nodes wrap their own return in a result object on top of that. Rather // than guess which shape arrived, the layers are flattened with the innermost // winning, so a field set anywhere in the chain is found. function incoming(input) { const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v); const merged = {}; for (const layer of [input, input && input.data, input && input.data && input.data.result]) { if (!isPlain(layer)) continue; for (const key of Object.keys(layer)) { merged[key] = layer[key]; } } return merged; } // Addresses are written the way people write them - one line, commas between - // so the node splits rather than asking for an array in the form. function splitAddresses(value) { if (!value) return []; if (Array.isArray(value)) return value.map(String).map(a => a.trim()).filter(Boolean); return String(value).split(',').map(a => a.trim()).filter(Boolean); } // An attachment is either carried inline by the previous node or left in the // file store; a file id means the bytes have to be fetched before sending. function resolveAttachment(entry) { if (entry.contentBase64) { return { filename: entry.filename || 'attachment', mimeType: entry.mimeType || '', contentBase64: entry.contentBase64 }; } if (!entry.fileId) return null; const info = smartbotic.storage.getFileInfo(entry.fileId); const file = smartbotic.storage.downloadFile(entry.fileId); if (!file || !file.success || !file.data) return null; return { filename: entry.filename || (info && info.name) || entry.fileId, mimeType: entry.mimeType || (info && info.mimeType) || '', contentBase64: file.data }; } module.exports = { configSchema, inputSchema, outputSchema, outputs, async execute(config, input, context) { const from = incoming(input); if (!config.credentialId) { throw new Error('A mail credential is required'); } const to = splitAddresses(from.to || config.to); if (to.length === 0) { throw new Error('At least one recipient is required'); } const subject = from.subject || config.subject || ''; const body = from.body || config.body || ''; const attachments = []; for (const entry of (from.attachments || [])) { const resolved = resolveAttachment(entry); if (resolved) attachments.push(resolved); } for (const fileId of splitAddresses(config.attachmentFileIds)) { const resolved = resolveAttachment({ fileId }); if (resolved) attachments.push(resolved); } const result = smartbotic.smtp.send({ credentialId: config.credentialId, to, cc: splitAddresses(config.cc), bcc: splitAddresses(config.bcc), subject, body, html: config.html === true, replyTo: config.replyTo || '', from: config.fromAddress || '', fromName: config.fromName || '', attachments }); smartbotic.log.info('Sent "' + subject + '" to ' + to.join(', ')); return { success: true, messageId: result.messageId, accepted: result.accepted, to, subject }; } };