import isbot from 'isbot'; import { getHtmlSandboxCSP, isFormHtmlSandboxingDisabled } from 'n8n-core'; import type { FormFieldsParameter, IDataObject, IExecuteFunctions, INodeProperties, IWebhookFunctions, } from 'n8n-workflow'; import { NodeOperationError, SEND_AND_WAIT_OPERATION, updateDisplayOptions } from 'n8n-workflow'; import { cssVariables } from '../../nodes/Form/cssVariables'; import { formFieldsProperties } from '../../nodes/Form/Form.node'; import { parseFormFields, prepareFormData, prepareFormFields, prepareFormReturnItem, } from '../../nodes/Form/utils/utils'; import { escapeHtml } from '../utilities'; import { limitWaitTimeOption } from './descriptions'; import { ACTION_RECORDED_PAGE, BUTTON_STYLE_PRIMARY, BUTTON_STYLE_SECONDARY, createEmailBodyWithN8nAttribution, createEmailBodyWithoutN8nAttribution, } from './email-templates'; import type { IEmail } from './interfaces'; export type SendAndWaitConfig = { title: string; message: string; options: Array<{ label: string; url: string; style: string }>; appendAttribution?: boolean; }; type FormResponseTypeOptions = { messageButtonLabel?: string; responseFormTitle?: string; responseFormDescription?: string; responseFormButtonLabel?: string; responseFormCustomCss?: string; }; const INPUT_FIELD_IDENTIFIER = 'field-0'; const appendAttributionOption: INodeProperties = { displayName: 'Append n8n Attribution', name: 'appendAttribution', type: 'boolean', default: true, description: 'Whether to include the phrase "This message was sent automatically with n8n" to the end of the message', }; // Operation Properties ---------------------------------------------------------- export function getSendAndWaitProperties( targetProperties: INodeProperties[], resource: string | null = 'message', additionalProperties: INodeProperties[] = [], options?: { noButtonStyle?: boolean; defaultApproveLabel?: string; defaultDisapproveLabel?: string; extraOptions?: INodeProperties[]; }, ): INodeProperties[] { const buttonStyle: INodeProperties = { displayName: 'Button Style', name: 'buttonStyle', type: 'options', default: 'primary', options: [ { name: 'Primary', value: 'primary', }, { name: 'Secondary', value: 'secondary', }, ], }; const approvalOptionsValues = [ { displayName: 'Type of Approval', name: 'approvalType', type: 'options', placeholder: 'Add option', default: 'single', options: [ { name: 'Approve Only', value: 'single', }, { name: 'Approve and Disapprove', value: 'double', }, ], }, { displayName: 'Approve Button Label', name: 'approveLabel', type: 'string', default: options?.defaultApproveLabel || 'Approve', displayOptions: { show: { approvalType: ['single', 'double'], }, }, }, ...[ options?.noButtonStyle ? ({} as INodeProperties) : { ...buttonStyle, displayName: 'Approve Button Style', name: 'buttonApprovalStyle', displayOptions: { show: { approvalType: ['single', 'double'], }, }, }, ], { displayName: 'Disapprove Button Label', name: 'disapproveLabel', type: 'string', default: options?.defaultDisapproveLabel || 'Decline', displayOptions: { show: { approvalType: ['double'], }, }, }, ...[ options?.noButtonStyle ? ({} as INodeProperties) : { ...buttonStyle, displayName: 'Disapprove Button Style', name: 'buttonDisapprovalStyle', default: 'secondary', displayOptions: { show: { approvalType: ['double'], }, }, }, ], ].filter((p) => Object.keys(p).length) as INodeProperties[]; const sendAndWait: INodeProperties[] = [ ...targetProperties, { displayName: 'Subject', name: 'subject', type: 'string', default: '', required: true, placeholder: 'e.g. Approval required', }, { displayName: 'Message', name: 'message', type: 'string', default: '', required: true, typeOptions: { rows: 4, }, }, { displayName: 'Response Type', name: 'responseType', type: 'options', default: 'approval', options: [ { name: 'Approval', value: 'approval', description: 'User can approve/disapprove from within the message', }, { name: 'Free Text', value: 'freeText', description: 'User can submit a response via a form', }, { name: 'Custom Form', value: 'customForm', description: 'User can submit a response via a custom form', }, ], }, ...updateDisplayOptions( { show: { responseType: ['customForm'], }, }, formFieldsProperties, ), { displayName: 'Approval Options', name: 'approvalOptions', type: 'fixedCollection', placeholder: 'Add option', default: {}, options: [ { displayName: 'Values', name: 'values', values: approvalOptionsValues, }, ], displayOptions: { show: { responseType: ['approval'], }, }, }, { displayName: 'Options', name: 'options', type: 'collection', placeholder: 'Add option', default: {}, options: [limitWaitTimeOption, appendAttributionOption, ...(options?.extraOptions ?? [])], displayOptions: { show: { responseType: ['approval'], }, }, }, { displayName: 'Options', name: 'options', type: 'collection', placeholder: 'Add option', default: {}, options: [ { displayName: 'Message Button Label', name: 'messageButtonLabel', type: 'string', default: 'Respond', }, { displayName: 'Response Form Title', name: 'responseFormTitle', description: 'Title of the form that the user can access to provide their response', type: 'string', default: '', }, { displayName: 'Response Form Description', name: 'responseFormDescription', description: 'Description of the form that the user can access to provide their response', type: 'string', default: '', }, { displayName: 'Response Form Button Label', name: 'responseFormButtonLabel', type: 'string', default: 'Submit', }, { displayName: 'Response Form Custom Styling', name: 'responseFormCustomCss', type: 'string', typeOptions: { rows: 10, editor: 'cssEditor', }, default: cssVariables.trim(), description: 'Override default styling of the response form with CSS', }, limitWaitTimeOption, appendAttributionOption, ...(options?.extraOptions ?? []), ], displayOptions: { show: { responseType: ['freeText', 'customForm'], }, }, }, ...additionalProperties, ]; return updateDisplayOptions( { show: { ...(resource ? { resource: [resource] } : {}), operation: [SEND_AND_WAIT_OPERATION], }, }, sendAndWait, ); } // Webhook Function -------------------------------------------------------------- const getFormResponseCustomizations = (context: IWebhookFunctions) => { const message = context.getNodeParameter('message', '') as string; const options = context.getNodeParameter('options', {}) as FormResponseTypeOptions; let formTitle = ''; if (options.responseFormTitle) { formTitle = options.responseFormTitle; } let formDescription = message; if (options.responseFormDescription) { formDescription = options.responseFormDescription; } formDescription = formDescription.replace(/\\n/g, '\n').replace(/
/g, '\n'); let buttonLabel = 'Submit'; if (options.responseFormButtonLabel) { buttonLabel = options.responseFormButtonLabel; } return { formTitle, formDescription, buttonLabel, customCss: options.responseFormCustomCss, }; }; // Block requests from Microsoft Preview Service to prevent accidental // approval/disapproval when sending links in Teams const isMicrosoftPreviewService = (userAgent?: string) => { // The request that the Preview Service makes when the message is sent in // Teams does not have a user-agent header if (!userAgent) { return true; } userAgent = userAgent.toLowerCase(); // The request that the Preview Service makes when the link is pasted in // Teams does have a user-agent header that can be used to identify it return ['teams', 'skype', 'preview'].some((str) => userAgent.includes(str)); }; export async function sendAndWaitWebhook(this: IWebhookFunctions) { const method = this.getRequestObject().method; const res = this.getResponseObject(); const req = this.getRequestObject(); const responseType = this.getNodeParameter('responseType', 'approval') as | 'approval' | 'freeText' | 'customForm'; if ( responseType === 'approval' && (isbot(req.headers['user-agent']) || isMicrosoftPreviewService(req.headers['user-agent'])) ) { res.send(''); return { noWebhookResponse: true }; } if (responseType === 'freeText') { if (method === 'GET') { const { formTitle, formDescription, buttonLabel, customCss } = getFormResponseCustomizations(this); const data = prepareFormData({ formTitle, formDescription, formSubmittedHeader: 'Got it, thanks', formSubmittedText: 'This page can be closed now', buttonLabel, redirectUrl: undefined, formFields: [ { fieldLabel: 'Response', fieldType: 'textarea', requiredField: true, }, ], testRun: false, query: {}, customCss, }); if (!isFormHtmlSandboxingDisabled()) { res.setHeader('Content-Security-Policy', getHtmlSandboxCSP()); } res.render('form-trigger', data); return { noWebhookResponse: true, }; } if (method === 'POST') { const data = this.getBodyData().data as IDataObject; return { webhookResponse: ACTION_RECORDED_PAGE, workflowData: [[{ json: { data: { text: data[INPUT_FIELD_IDENTIFIER] } } }]], }; } } if (responseType === 'customForm') { const defineForm = this.getNodeParameter('defineForm', 'fields') as 'fields' | 'json'; let fields: FormFieldsParameter = []; if (defineForm === 'json') { fields = parseFormFields(this, { defineForm: 'json', fieldsParameterName: 'jsonOutput', }); } else { fields = parseFormFields(this, { defineForm: 'fields', fieldsParameterName: 'formFields.values', }); } if (method === 'GET') { const { formTitle, formDescription, buttonLabel, customCss } = getFormResponseCustomizations(this); fields = prepareFormFields(fields); const data = prepareFormData({ formTitle, formDescription, formSubmittedHeader: 'Got it, thanks', formSubmittedText: 'This page can be closed now', buttonLabel, redirectUrl: undefined, formFields: fields, testRun: false, query: {}, customCss, }); if (!isFormHtmlSandboxingDisabled()) { res.setHeader('Content-Security-Policy', getHtmlSandboxCSP()); } res.render('form-trigger', data); return { noWebhookResponse: true, }; } if (method === 'POST') { const returnItem = await prepareFormReturnItem(this, fields, 'production', true); const json = returnItem.json; delete json.submittedAt; delete json.formMode; returnItem.json = { data: json }; return { webhookResponse: ACTION_RECORDED_PAGE, workflowData: [[returnItem]], }; } } const query = req.query as { approved: 'false' | 'true' }; const approved = query.approved === 'true'; return { webhookResponse: ACTION_RECORDED_PAGE, workflowData: [[{ json: { data: { approved } } }]], }; } // Send and Wait Config ----------------------------------------------------------- export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitConfig { const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim()) .replace(/\\n/g, '\n') .replace(/
/g, '\n'); const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string); const approvalOptions = context.getNodeParameter('approvalOptions.values', 0, {}) as { approvalType?: 'single' | 'double'; approveLabel?: string; buttonApprovalStyle?: string; disapproveLabel?: string; buttonDisapprovalStyle?: string; }; const options = context.getNodeParameter('options', 0, {}); const config: SendAndWaitConfig = { title: subject, message, options: [], appendAttribution: options?.appendAttribution as boolean, }; const responseType = context.getNodeParameter('responseType', 0, 'approval') as string; const approvedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'true' }); if (responseType === 'freeText' || responseType === 'customForm') { const label = context.getNodeParameter('options.messageButtonLabel', 0, 'Respond') as string; config.options.push({ label, url: approvedSignedResumeUrl, style: 'primary', }); } else if (approvalOptions.approvalType === 'double') { const approveLabel = escapeHtml(approvalOptions.approveLabel || 'Approve'); const buttonApprovalStyle = approvalOptions.buttonApprovalStyle || 'primary'; const disapproveLabel = escapeHtml(approvalOptions.disapproveLabel || 'Disapprove'); const buttonDisapprovalStyle = approvalOptions.buttonDisapprovalStyle || 'secondary'; const disapprovedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'false' }); config.options.push({ label: disapproveLabel, url: disapprovedSignedResumeUrl, style: buttonDisapprovalStyle, }); config.options.push({ label: approveLabel, url: approvedSignedResumeUrl, style: buttonApprovalStyle, }); } else { const label = escapeHtml(approvalOptions.approveLabel || 'Approve'); const style = approvalOptions.buttonApprovalStyle || 'primary'; config.options.push({ label, url: approvedSignedResumeUrl, style, }); } return config; } export function createButton(url: string, label: string, style: string) { let buttonStyle = BUTTON_STYLE_PRIMARY; if (style === 'secondary') { buttonStyle = BUTTON_STYLE_SECONDARY; } return `${label}`; } export function createEmail(context: IExecuteFunctions) { const to = (context.getNodeParameter('sendTo', 0, '') as string).trim(); const config = getSendAndWaitConfig(context); if (to.indexOf('@') === -1 || (to.match(/@/g) || []).length > 1) { const description = `The email address '${to}' in the 'To' field isn't valid or contains multiple addresses. Please provide only a single email address.`; throw new NodeOperationError(context.getNode(), 'Invalid email address', { description, itemIndex: 0, }); } const buttons: string[] = []; for (const option of config.options) { buttons.push(createButton(option.url, option.label, option.style)); } let emailBody: string; if (config.appendAttribution !== false) { const instanceId = context.getInstanceId(); emailBody = createEmailBodyWithN8nAttribution(config.message, buttons.join('\n'), instanceId); } else { emailBody = createEmailBodyWithoutN8nAttribution(config.message, buttons.join('\n')); } const email: IEmail = { to, subject: config.title, body: '', htmlBody: emailBody, }; return email; } const sendAndWaitWaitingTooltip = (parameters: { operation: string }) => { if (parameters?.operation === 'sendAndWait') { return "Execution will continue after the user's response"; } return ''; }; export const SEND_AND_WAIT_WAITING_TOOLTIP = `={{ (${sendAndWaitWaitingTooltip})($parameter) }}`;