mirror of
https://github.com/n8n-io/n8n-nodes-starter.git
synced 2025-10-28 14:12:24 -05:00
feat: Add Resend node and trigger
Adds a new integration for Resend, an email sending service. Includes: - Resend API credential (`ResendApi.credentials.ts`) for API key authentication. - Resend action node (`nodes/Resend/Resend.node.ts`): - Supports "Send Email" operation with parameters for to, from, subject, html, text, cc, bcc, reply_to, and tags. - Uses Resend API `https://api.resend.com/emails`. - Resend trigger node (`nodes/Resend/ResendTrigger.node.ts`): - Handles Resend webhooks for various event types (email.sent, email.delivered, etc.). - Implements webhook signature verification using Svix and a user-provided signing secret. - Allows you to select which events to listen for. - Official Resend SVG icon (`nodes/Resend/Resend.svg`). - Adds `svix` as a dependency for webhook verification.
This commit is contained in:
parent
65e06338e3
commit
30fca2b5d5
6 changed files with 5148 additions and 0 deletions
27
credentials/ResendApi.credentials.ts
Normal file
27
credentials/ResendApi.credentials.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
IAuthData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class ResendApi implements ICredentialType {
|
||||
name = 'resendApi';
|
||||
displayName = 'Resend API';
|
||||
documentationUrl = 'https://resend.com/docs/api-reference/introduction';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
// Use IAuthData for authentication
|
||||
authenticate: IAuthData = {
|
||||
type: 'apiToken',
|
||||
properties: {
|
||||
token: '={{$credentials.apiKey}}'
|
||||
}
|
||||
};
|
||||
}
|
||||
267
nodes/Resend/Resend.node.ts
Normal file
267
nodes/Resend/Resend.node.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class Resend implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Resend',
|
||||
name: 'resend',
|
||||
icon: 'file:Resend.svg',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
description: 'Sends emails via Resend API',
|
||||
defaults: {
|
||||
name: 'Resend',
|
||||
color: '#000000',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
name: 'resendApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Send Email',
|
||||
value: 'sendEmail',
|
||||
action: 'Send an email',
|
||||
routing: {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://api.resend.com/emails',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
default: 'sendEmail',
|
||||
},
|
||||
// Properties for "Send Email" operation
|
||||
{
|
||||
displayName: 'From',
|
||||
name: 'fromEmail',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'you@example.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
description: 'Sender email address',
|
||||
},
|
||||
{
|
||||
displayName: 'To',
|
||||
name: 'toEmail',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'user@example.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
description: 'Comma-separated list of recipient email addresses',
|
||||
},
|
||||
{
|
||||
displayName: 'Subject',
|
||||
name: 'subject',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'Hello from n8n!',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'HTML',
|
||||
name: 'htmlBody',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
typeOptions: {
|
||||
multiline: true,
|
||||
},
|
||||
placeholder: '<p>Your HTML content here</p>',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'textBody',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'Your plain text content here',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
description: 'Plain text content of the email (optional)',
|
||||
},
|
||||
{
|
||||
displayName: 'CC',
|
||||
name: 'ccEmail',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'cc@example.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
description: 'Comma-separated list of CC email addresses',
|
||||
},
|
||||
{
|
||||
displayName: 'BCC',
|
||||
name: 'bccEmail',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'bcc@example.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
description: 'Comma-separated list of BCC email addresses',
|
||||
},
|
||||
{
|
||||
displayName: 'Reply To',
|
||||
name: 'replyToEmail',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'reply@example.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
description: 'Email address to set as reply-to',
|
||||
},
|
||||
{
|
||||
displayName: 'Tags',
|
||||
name: 'tags',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add Tag',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['sendEmail'],
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
name: 'tag',
|
||||
displayName: 'Tag',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
description: 'Tags to categorize the email',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const length = items.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
try {
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
const credentials = await this.getCredentials('resendApi');
|
||||
const apiKey = credentials.apiKey as string;
|
||||
|
||||
if (operation === 'sendEmail') {
|
||||
const fromEmail = this.getNodeParameter('fromEmail', i) as string;
|
||||
const toEmail = this.getNodeParameter('toEmail', i) as string;
|
||||
const subject = this.getNodeParameter('subject', i) as string;
|
||||
const htmlBody = this.getNodeParameter('htmlBody', i) as string;
|
||||
const textBody = this.getNodeParameter('textBody', i, '') as string;
|
||||
const ccEmail = this.getNodeParameter('ccEmail', i, '') as string;
|
||||
const bccEmail = this.getNodeParameter('bccEmail', i, '') as string;
|
||||
const replyToEmail = this.getNodeParameter('replyToEmail', i, '') as string;
|
||||
const tagsData = this.getNodeParameter('tags', i, { tag: [] }) as { tag: Array<{ name: string; value: string }> };
|
||||
|
||||
const requestBody: any = {
|
||||
from: fromEmail,
|
||||
to: toEmail.split(',').map(email => email.trim()).filter(email => email),
|
||||
subject: subject,
|
||||
html: htmlBody,
|
||||
};
|
||||
|
||||
if (textBody) {
|
||||
requestBody.text = textBody;
|
||||
}
|
||||
if (ccEmail) {
|
||||
requestBody.cc = ccEmail.split(',').map(email => email.trim()).filter(email => email);
|
||||
}
|
||||
if (bccEmail) {
|
||||
requestBody.bcc = bccEmail.split(',').map(email => email.trim()).filter(email => email);
|
||||
}
|
||||
if (replyToEmail) {
|
||||
requestBody.reply_to = replyToEmail;
|
||||
}
|
||||
if (tagsData.tag && tagsData.tag.length > 0) {
|
||||
requestBody.tags = tagsData.tag.map(t => ({ name: t.name, value: t.value }));
|
||||
}
|
||||
|
||||
const response = await this.helpers.httpRequest({
|
||||
url: 'https://api.resend.com/emails',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: requestBody,
|
||||
json: true,
|
||||
});
|
||||
|
||||
returnData.push({ json: response, pairedItem: { item: i } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error);
|
||||
}
|
||||
}
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
3
nodes/Resend/Resend.svg
Normal file
3
nodes/Resend/Resend.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M186 447.471V154H318.062C336.788 154 353.697 158.053 368.79 166.158C384.163 174.263 396.181 185.443 404.845 199.698C413.51 213.672 417.842 229.604 417.842 247.491C417.842 265.938 413.51 282.568 404.845 297.381C396.181 311.915 384.302 323.375 369.209 331.759C354.117 340.144 337.067 344.337 318.062 344.337H253.917V447.471H186ZM348.667 447.471L274.041 314.99L346.99 304.509L430 447.471H348.667ZM253.917 289.835H311.773C319.04 289.835 325.329 288.298 330.639 285.223C336.229 281.869 340.421 277.258 343.216 271.388C346.291 265.519 347.828 258.811 347.828 251.265C347.828 243.718 346.151 237.15 342.797 231.56C339.443 225.691 334.552 221.219 328.124 218.144C321.975 215.07 314.428 213.533 305.484 213.533H253.917V289.835Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
138
nodes/Resend/ResendTrigger.node.ts
Normal file
138
nodes/Resend/ResendTrigger.node.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import {
|
||||
IHookFunctions,
|
||||
IWebhookFunctions,
|
||||
IWebhookResponseData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
NodeApiError,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { Webhook } from 'svix';
|
||||
|
||||
export class ResendTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Resend Trigger',
|
||||
name: 'resendTrigger',
|
||||
icon: 'file:Resend.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Handles Resend webhooks for various email events',
|
||||
defaults: {
|
||||
name: 'Resend Trigger',
|
||||
color: '#000000',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Webhook Signing Secret',
|
||||
name: 'webhookSigningSecret',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
description: 'Found in your Resend webhook configuration page (svix_... value).',
|
||||
},
|
||||
{
|
||||
displayName: 'Events',
|
||||
name: 'events',
|
||||
type: 'multiOptions',
|
||||
required: true,
|
||||
default: ['email.sent'],
|
||||
options: [
|
||||
{ name: 'Email Sent', value: 'email.sent' },
|
||||
{ name: 'Email Delivered', value: 'email.delivered' },
|
||||
{ name: 'Email Delivery Delayed', value: 'email.delivery_delayed' },
|
||||
{ name: 'Email Complained', value: 'email.complained' },
|
||||
{ name: 'Email Bounced', value: 'email.bounced' },
|
||||
{ name: 'Email Opened', value: 'email.opened' },
|
||||
{ name: 'Email Clicked', value: 'email.clicked' },
|
||||
{ name: 'Contact Created', value: 'contact.created' },
|
||||
{ name: 'Contact Updated', value: 'contact.updated' },
|
||||
{ name: 'Contact Deleted', value: 'contact.deleted' },
|
||||
{ name: 'Domain Created', value: 'domain.created' },
|
||||
{ name: 'Domain Updated', value: 'domain.updated' },
|
||||
{ name: 'Domain Deleted', value: 'domain.deleted' },
|
||||
],
|
||||
description: 'Select the Resend event types to listen for.',
|
||||
},
|
||||
],
|
||||
webhookDescription: {
|
||||
webhookPath: 'webhook',
|
||||
webhookVerificationMethod: 'resendSignature',
|
||||
type: 'webhook',
|
||||
},
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const req = this.getRequestObject();
|
||||
const bodyData = this.getBodyData();
|
||||
const subscribedEvents = this.getNodeParameter('events') as string[];
|
||||
|
||||
if (!bodyData || typeof bodyData !== 'object' || !('type' in bodyData)) {
|
||||
// Resend should always send a JSON object with a 'type' field
|
||||
console.warn('Received webhook data that was not in the expected format.');
|
||||
return {
|
||||
workflowData: [],
|
||||
};
|
||||
}
|
||||
|
||||
const eventType = (bodyData as { type: string }).type;
|
||||
|
||||
if (subscribedEvents.includes(eventType)) {
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray([bodyData]) as INodeExecutionData[]],
|
||||
};
|
||||
} else {
|
||||
// Event type not subscribed to, log and ignore
|
||||
console.log(`Received event type "${eventType}" but not subscribed, ignoring.`);
|
||||
return {
|
||||
workflowData: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
webhookMethods = {
|
||||
default: {
|
||||
async resendSignature(this: IHookFunctions): Promise<boolean> {
|
||||
const req = this.getRequestObject();
|
||||
const secret = this.getNodeParameter('webhookSigningSecret') as string;
|
||||
|
||||
if (!secret.startsWith('whsec_')) {
|
||||
// Basic check for Resend's Svix signing secret format
|
||||
throw new NodeApiError(
|
||||
this.getNode(),
|
||||
{ message: 'Invalid Webhook Signing Secret format. It should start with "whsec_".' },
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const svixId = req.headers['svix-id'] as string;
|
||||
const svixTimestamp = req.headers['svix-timestamp'] as string;
|
||||
const svixSignature = req.headers['svix-signature'] as string;
|
||||
|
||||
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||
throw new NodeApiError(
|
||||
this.getNode(),
|
||||
{ message: 'Request missing Svix headers' },
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.getRequestBody(); // Raw body
|
||||
|
||||
try {
|
||||
const wh = new Webhook(secret);
|
||||
wh.verify(payload, {
|
||||
'svix-id': svixId,
|
||||
'svix-timestamp': svixTimestamp,
|
||||
'svix-signature': svixSignature,
|
||||
});
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Signature verification failed.';
|
||||
throw new NodeApiError(this.getNode(), { message: `Webhook signature verification failed: ${errorMessage}` }, { statusCode: 401 });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
4710
package-lock.json
generated
Normal file
4710
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -51,5 +51,8 @@
|
|||
},
|
||||
"peerDependencies": {
|
||||
"n8n-workflow": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"svix": "^1.22.1"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue