From e7ba5868924e8fbea01bbeaf3c715f191531c12c Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Sat, 11 Apr 2026 10:38:52 -0500
Subject: [PATCH 1/2] feat(sendgrid): add transactional email integration (#18)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds @jam-nodes/nodes SendGrid integration with three nodes:
- sendgridSendEmailNode — POST /v3/mail/send (template + attachment support, x-message-id header)
- sendgridCreateContactNode — PUT /v3/marketing/contacts (upsert; returns job_id)
- sendgridGetContactsNode — POST /v3/marketing/contacts/search (SGQL filter, 50/call cap, client-side limit/offset)
Also extends @jam-nodes/core NodeCredentials with a sendgrid?: { apiKey } entry
and registers the new exports in packages/nodes/src/integrations/index.ts.
48 unit tests cover credential metadata, schema validation, all happy paths,
template/attachment handling, SGQL escaping, error responses, and 401 auth
failures. Full nodes test suite goes from 195 -> 243 with no regressions.
Closes #18
---
packages/core/src/types/node.ts | 4 +
packages/nodes/src/integrations/index.ts | 26 +
.../sendgrid/__tests__/sendgrid.test.ts | 658 ++++++++++++++++++
.../src/integrations/sendgrid/credentials.ts | 17 +
.../nodes/src/integrations/sendgrid/index.ts | 34 +
.../src/integrations/sendgrid/schemas.ts | 92 +++
.../sendgrid/sendgrid-create-contact.ts | 113 +++
.../sendgrid/sendgrid-get-contacts.ts | 116 +++
.../sendgrid/sendgrid-send-email.ts | 152 ++++
9 files changed, 1212 insertions(+)
create mode 100644 packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts
create mode 100644 packages/nodes/src/integrations/sendgrid/credentials.ts
create mode 100644 packages/nodes/src/integrations/sendgrid/index.ts
create mode 100644 packages/nodes/src/integrations/sendgrid/schemas.ts
create mode 100644 packages/nodes/src/integrations/sendgrid/sendgrid-create-contact.ts
create mode 100644 packages/nodes/src/integrations/sendgrid/sendgrid-get-contacts.ts
create mode 100644 packages/nodes/src/integrations/sendgrid/sendgrid-send-email.ts
diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts
index 45327ef..1b2606a 100644
--- a/packages/core/src/types/node.ts
+++ b/packages/core/src/types/node.ts
@@ -84,6 +84,10 @@ export interface NodeCredentials {
refreshToken: string
expiresAt: number
}
+ /** SendGrid API credentials */
+ sendgrid?: {
+ apiKey: string
+ }
}
/**
diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts
index 25707be..8d0e0fa 100644
--- a/packages/nodes/src/integrations/index.ts
+++ b/packages/nodes/src/integrations/index.ts
@@ -275,3 +275,29 @@ export {
type SlackSearchMatch,
slackCredential,
} from './slack/index.js'
+
+// SendGrid integrations
+export {
+ sendgridSendEmailNode,
+ SendgridSendEmailInputSchema,
+ SendgridSendEmailOutputSchema,
+ type SendgridSendEmailInput,
+ type SendgridSendEmailOutput,
+ sendgridCreateContactNode,
+ SendgridCreateContactInputSchema,
+ SendgridCreateContactOutputSchema,
+ type SendgridCreateContactInput,
+ type SendgridCreateContactOutput,
+ sendgridGetContactsNode,
+ SendgridGetContactsInputSchema,
+ SendgridGetContactsOutputSchema,
+ type SendgridGetContactsInput,
+ type SendgridGetContactsOutput,
+ SendgridContentSchema,
+ SendgridAttachmentSchema,
+ SendgridContactSchema,
+ type SendgridContent,
+ type SendgridAttachment,
+ type SendgridContact,
+ sendgridCredential,
+} from './sendgrid/index.js'
diff --git a/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts b/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts
new file mode 100644
index 0000000..c7eb209
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts
@@ -0,0 +1,658 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ sendgridCredential,
+ sendgridSendEmailNode,
+ sendgridCreateContactNode,
+ sendgridGetContactsNode,
+ SendgridSendEmailInputSchema,
+ SendgridCreateContactInputSchema,
+ SendgridGetContactsInputSchema,
+} from '../index.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+const makeCtx = (withCreds = true) => ({
+ userId: 'u1',
+ workflowExecutionId: 'w1',
+ variables: {},
+ resolveNestedPath: () => undefined,
+ ...(withCreds ? { credentials: { sendgrid: { apiKey: 'SG.test' } } } : {}),
+});
+
+// ─── credentials ────────────────────────────────────────────────────────────
+
+describe('sendgrid credentials', () => {
+ it('defines apiKey credential metadata', () => {
+ expect(sendgridCredential.name).toBe('sendgrid');
+ expect(sendgridCredential.type).toBe('apiKey');
+ expect(sendgridCredential.displayName).toBe('SendGrid API');
+ });
+
+ it('uses Bearer header interpolation', () => {
+ expect(sendgridCredential.authenticate.type).toBe('header');
+ expect(sendgridCredential.authenticate.properties.Authorization).toBe('Bearer {{apiKey}}');
+ });
+
+ it('rejects empty apiKey', () => {
+ const result = sendgridCredential.schema.safeParse({ apiKey: '' });
+ expect(result.success).toBe(false);
+ });
+
+ it('accepts valid apiKey', () => {
+ const result = sendgridCredential.schema.safeParse({ apiKey: 'SG.xxx' });
+ expect(result.success).toBe(true);
+ });
+});
+
+// ─── schemas ────────────────────────────────────────────────────────────────
+
+describe('sendgrid schemas', () => {
+ it('validates send-email with single to', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'a@x.com',
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/html', value: '
hi
' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('validates send-email with to array', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: ['a@x.com', 'b@x.com'],
+ from: 'c@x.com',
+ subject: 's',
+ content: { type: 'text/plain', value: 'hi' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects send-email missing subject', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'a@x.com',
+ from: 'b@x.com',
+ content: { type: 'text/plain', value: 'hi' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects send-email with non-email to', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'not-an-email',
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/plain', value: 'hi' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects send-email with empty to array', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: [],
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/plain', value: 'hi' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('accepts send-email with template', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'a@x.com',
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/html', value: 'fallback' },
+ templateId: 'd-abc',
+ dynamicTemplateData: { name: 'Ada' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('accepts send-email with attachments', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'a@x.com',
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/plain', value: 'hi' },
+ attachments: [
+ { content: 'SGVsbG8=', filename: 'hello.txt', type: 'text/plain' },
+ ],
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('validates create-contact with only email', () => {
+ const result = SendgridCreateContactInputSchema.safeParse({ email: 'a@x.com' });
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects create-contact with malformed email', () => {
+ const result = SendgridCreateContactInputSchema.safeParse({ email: 'nope' });
+ expect(result.success).toBe(false);
+ });
+
+ it('accepts empty get-contacts input (limit/offset are optional)', () => {
+ const result = SendgridGetContactsInputSchema.safeParse({});
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects get-contacts with limit over 50', () => {
+ const result = SendgridGetContactsInputSchema.safeParse({ limit: 500 });
+ expect(result.success).toBe(false);
+ });
+});
+
+// ─── sendgrid_send_email ────────────────────────────────────────────────────
+
+describe('sendgrid_send_email', () => {
+ const basicInput = {
+ to: 'recipient@example.com',
+ from: 'sender@example.com',
+ subject: 'Hello',
+ content: { type: 'text/html' as const, value: 'Hi
' },
+ };
+
+ const mockAccepted = (messageId: string | null = 'mid-123') => {
+ const headers: Record = { 'Content-Type': 'application/json' };
+ if (messageId !== null) headers['x-message-id'] = messageId;
+ return vi.fn().mockResolvedValue(
+ new Response(null, {
+ status: 202,
+ headers,
+ })
+ );
+ };
+
+ it('fails when API key is missing', async () => {
+ const result = await sendgridSendEmailNode.executor(basicInput, makeCtx(false));
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('sendgrid.apiKey');
+ });
+
+ it('sends basic email and returns messageId from header', async () => {
+ const fetchMock = mockAccepted('mid-abc');
+ vi.stubGlobal('fetch', fetchMock);
+
+ const result = await sendgridSendEmailNode.executor(basicInput, makeCtx());
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.messageId).toBe('mid-abc');
+ expect(result.output.status).toBe('sent');
+ }
+
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe('https://api.sendgrid.com/v3/mail/send');
+ expect(init.method).toBe('POST');
+ const headers = init.headers as Record;
+ expect(headers.Authorization).toBe('Bearer SG.test');
+ const body = JSON.parse(String(init.body));
+ expect(body.personalizations[0].to[0].email).toBe('recipient@example.com');
+ });
+
+ it('normalizes to array when given string', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(basicInput, makeCtx());
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.personalizations[0].to).toEqual([{ email: 'recipient@example.com' }]);
+ });
+
+ it('normalizes to array when given multiple recipients', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(
+ { ...basicInput, to: ['a@x.com', 'b@x.com'] },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.personalizations[0].to).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
+ });
+
+ it('includes templateId and dynamicTemplateData', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(
+ { ...basicInput, templateId: 'd-tmpl', dynamicTemplateData: { name: 'Ada' } },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.template_id).toBe('d-tmpl');
+ expect(body.personalizations[0].dynamic_template_data.name).toBe('Ada');
+ expect(body.content).toBeUndefined();
+ });
+
+ it('omits top-level content when templateId is provided', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(
+ {
+ ...basicInput,
+ templateId: 'd-tmpl',
+ content: { type: 'text/html', value: 'fallback
' },
+ },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.template_id).toBe('d-tmpl');
+ expect(body.content).toBeUndefined();
+ });
+
+ it('includes top-level content when templateId is absent', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(basicInput, makeCtx());
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.content).toEqual([{ type: 'text/html', value: 'Hi
' }]);
+ expect(body.template_id).toBeUndefined();
+ });
+
+ it('passes attachments verbatim', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(
+ {
+ ...basicInput,
+ attachments: [
+ { content: 'SGVsbG8=', filename: 'hello.txt', type: 'text/plain' },
+ ],
+ },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.attachments).toHaveLength(1);
+ expect(body.attachments[0].filename).toBe('hello.txt');
+ expect(body.attachments[0].content).toBe('SGVsbG8=');
+ expect(body.attachments[0].type).toBe('text/plain');
+ });
+
+ it('sets status to scheduled when sendAt provided', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ const result = await sendgridSendEmailNode.executor(
+ { ...basicInput, sendAt: 1699999999 },
+ makeCtx()
+ );
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.output.status).toBe('scheduled');
+
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.send_at).toBe(1699999999);
+ });
+
+ it('includes cc, bcc, replyTo when provided', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(
+ { ...basicInput, cc: ['cc@x.com'], bcc: ['bcc@x.com'], replyTo: 'reply@x.com' },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.personalizations[0].cc).toEqual([{ email: 'cc@x.com' }]);
+ expect(body.personalizations[0].bcc).toEqual([{ email: 'bcc@x.com' }]);
+ expect(body.reply_to).toEqual({ email: 'reply@x.com' });
+ });
+
+ it('returns error on non-2xx response', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response('{"errors":[{"message":"bad from"}]}', {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const result = await sendgridSendEmailNode.executor(basicInput, makeCtx());
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('400');
+ });
+
+ it('handles empty x-message-id header gracefully', async () => {
+ const fetchMock = mockAccepted(null);
+ vi.stubGlobal('fetch', fetchMock);
+ const result = await sendgridSendEmailNode.executor(basicInput, makeCtx());
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.output.messageId).toBe('');
+ });
+
+ it('handles 401 Unauthorized from SendGrid', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response('unauthorized', { status: 401 })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const result = await sendgridSendEmailNode.executor(basicInput, makeCtx());
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('401');
+ });
+});
+
+// ─── sendgrid_create_contact ────────────────────────────────────────────────
+
+describe('sendgrid_create_contact', () => {
+ const basicInput = { email: 'contact@example.com' };
+
+ const mockAccepted = (body: unknown = { job_id: 'job-1' }) =>
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(body), {
+ status: 202,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+
+ it('fails when API key is missing', async () => {
+ const result = await sendgridCreateContactNode.executor(basicInput, makeCtx(false));
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('sendgrid.apiKey');
+ });
+
+ it('upserts contact and returns job id', async () => {
+ vi.stubGlobal('fetch', mockAccepted({ job_id: 'job-abc' }));
+ const result = await sendgridCreateContactNode.executor(basicInput, makeCtx());
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contactJobId).toBe('job-abc');
+ expect(result.output.status).toBe('queued');
+ expect(result.output.email).toBe('contact@example.com');
+ }
+ });
+
+ it('sends PUT and correct body', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridCreateContactNode.executor(
+ { email: 'x@y.com', firstName: 'Ada', lastName: 'Lovelace' },
+ makeCtx()
+ );
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe('https://api.sendgrid.com/v3/marketing/contacts');
+ expect(init.method).toBe('PUT');
+ const body = JSON.parse(String(init.body));
+ expect(body.contacts[0].email).toBe('x@y.com');
+ expect(body.contacts[0].first_name).toBe('Ada');
+ expect(body.contacts[0].last_name).toBe('Lovelace');
+ });
+
+ it('passes listIds through as list_ids', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridCreateContactNode.executor(
+ { ...basicInput, listIds: ['l1', 'l2'] },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.list_ids).toEqual(['l1', 'l2']);
+ });
+
+ it('omits list_ids when not provided', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridCreateContactNode.executor(basicInput, makeCtx());
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.list_ids).toBeUndefined();
+ });
+
+ it('omits list_ids when provided as empty array', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridCreateContactNode.executor(
+ { ...basicInput, listIds: [] },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.list_ids).toBeUndefined();
+ });
+
+ it('passes customFields through as custom_fields', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridCreateContactNode.executor(
+ { ...basicInput, customFields: { region: 'NA' } },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.contacts[0].custom_fields.region).toBe('NA');
+ });
+
+ it('returns error on non-2xx', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response('bad request', { status: 400 })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const result = await sendgridCreateContactNode.executor(basicInput, makeCtx());
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('400');
+ });
+
+ it('returns error when response body has no job_id', async () => {
+ vi.stubGlobal('fetch', mockAccepted({}));
+ const result = await sendgridCreateContactNode.executor(basicInput, makeCtx());
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('job_id');
+ });
+});
+
+// ─── sendgrid_get_contacts ──────────────────────────────────────────────────
+
+describe('sendgrid_get_contacts', () => {
+ const mockResult = (body: unknown) =>
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+
+ const makeContact = (id: string, overrides: Record = {}) => ({
+ id,
+ email: `${id}@x.com`,
+ first_name: 'First',
+ last_name: 'Last',
+ list_ids: ['L1'],
+ ...overrides,
+ });
+
+ it('fails when API key is missing', async () => {
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 50, offset: 0 },
+ makeCtx(false)
+ );
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('sendgrid.apiKey');
+ });
+
+ it('fetches contacts and maps fields', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ mockResult({
+ result: [
+ {
+ id: 'c1',
+ email: 'a@x.com',
+ first_name: 'Ada',
+ last_name: 'L',
+ list_ids: ['L1'],
+ },
+ ],
+ contact_count: 1,
+ })
+ );
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 50, offset: 0 },
+ makeCtx()
+ );
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contacts).toHaveLength(1);
+ expect(result.output.contacts[0]?.firstName).toBe('Ada');
+ expect(result.output.contacts[0]?.listIds[0]).toBe('L1');
+ expect(result.output.total).toBe(1);
+ }
+ });
+
+ it('sends empty query when listId omitted', async () => {
+ const fetchMock = mockResult({ result: [], contact_count: 0 });
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridGetContactsNode.executor({ limit: 50, offset: 0 }, makeCtx());
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe('https://api.sendgrid.com/v3/marketing/contacts/search');
+ const body = JSON.parse(String(init.body));
+ expect(body.query).toBe('');
+ });
+
+ it('builds SGQL list filter when listId provided', async () => {
+ const fetchMock = mockResult({ result: [], contact_count: 0 });
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridGetContactsNode.executor(
+ { listId: 'list-42', limit: 50, offset: 0 },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.query).toBe("CONTAINS(list_ids, 'list-42')");
+ });
+
+ it('escapes single quotes in listId', async () => {
+ const fetchMock = mockResult({ result: [], contact_count: 0 });
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridGetContactsNode.executor(
+ { listId: "o'brien", limit: 50, offset: 0 },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.query).toBe("CONTAINS(list_ids, 'o\\'brien')");
+ });
+
+ it('applies limit client-side', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ mockResult({
+ result: [
+ makeContact('c1'),
+ makeContact('c2'),
+ makeContact('c3'),
+ makeContact('c4'),
+ makeContact('c5'),
+ ],
+ contact_count: 5,
+ })
+ );
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 2, offset: 0 },
+ makeCtx()
+ );
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contacts).toHaveLength(2);
+ expect(result.output.total).toBe(5);
+ }
+ });
+
+ it('applies offset client-side', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ mockResult({
+ result: [
+ makeContact('c1'),
+ makeContact('c2'),
+ makeContact('c3'),
+ makeContact('c4'),
+ makeContact('c5'),
+ ],
+ contact_count: 5,
+ })
+ );
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 2, offset: 2 },
+ makeCtx()
+ );
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contacts[0]?.id).toBe('c3');
+ }
+ });
+
+ it('handles null first_name / last_name in response', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ mockResult({
+ result: [
+ {
+ id: 'c1',
+ email: 'a@x.com',
+ first_name: null,
+ last_name: null,
+ list_ids: [],
+ },
+ ],
+ contact_count: 1,
+ })
+ );
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 50, offset: 0 },
+ makeCtx()
+ );
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contacts[0]?.firstName).toBeNull();
+ expect(result.output.contacts[0]?.lastName).toBeNull();
+ }
+ });
+
+ it('returns error on non-2xx', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response('server error', { status: 500 })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 50, offset: 0 },
+ makeCtx()
+ );
+ expect(result.success).toBe(false);
+ });
+
+ it('handles empty results', async () => {
+ vi.stubGlobal('fetch', mockResult({ result: [], contact_count: 0 }));
+ const result = await sendgridGetContactsNode.executor(
+ { limit: 50, offset: 0 },
+ makeCtx()
+ );
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contacts).toHaveLength(0);
+ expect(result.output.total).toBe(0);
+ }
+ });
+
+ it('executor applies default limit=50 and offset=0 when omitted', async () => {
+ const five = [
+ makeContact('c1'),
+ makeContact('c2'),
+ makeContact('c3'),
+ makeContact('c4'),
+ makeContact('c5'),
+ ];
+ vi.stubGlobal('fetch', mockResult({ result: five, contact_count: 5 }));
+ const result = await sendgridGetContactsNode.executor({}, makeCtx());
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.output.contacts).toHaveLength(5);
+ expect(result.output.contacts[0]?.id).toBe('c1');
+ expect(result.output.total).toBe(5);
+ }
+ });
+});
diff --git a/packages/nodes/src/integrations/sendgrid/credentials.ts b/packages/nodes/src/integrations/sendgrid/credentials.ts
new file mode 100644
index 0000000..c69c062
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/credentials.ts
@@ -0,0 +1,17 @@
+import { defineApiKeyCredential } from '@jam-nodes/core';
+import { z } from 'zod';
+
+export const sendgridCredential = defineApiKeyCredential({
+ name: 'sendgrid',
+ displayName: 'SendGrid API',
+ documentationUrl: 'https://docs.sendgrid.com/api-reference',
+ schema: z.object({
+ apiKey: z.string().min(1, 'SendGrid API key is required'),
+ }),
+ authenticate: {
+ type: 'header',
+ properties: {
+ Authorization: 'Bearer {{apiKey}}',
+ },
+ },
+});
diff --git a/packages/nodes/src/integrations/sendgrid/index.ts b/packages/nodes/src/integrations/sendgrid/index.ts
new file mode 100644
index 0000000..e713c65
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/index.ts
@@ -0,0 +1,34 @@
+export {
+ sendgridSendEmailNode,
+ SendgridSendEmailInputSchema,
+ SendgridSendEmailOutputSchema,
+ type SendgridSendEmailInput,
+ type SendgridSendEmailOutput,
+} from './sendgrid-send-email.js';
+
+export {
+ sendgridCreateContactNode,
+ SendgridCreateContactInputSchema,
+ SendgridCreateContactOutputSchema,
+ type SendgridCreateContactInput,
+ type SendgridCreateContactOutput,
+} from './sendgrid-create-contact.js';
+
+export {
+ sendgridGetContactsNode,
+ SendgridGetContactsInputSchema,
+ SendgridGetContactsOutputSchema,
+ type SendgridGetContactsInput,
+ type SendgridGetContactsOutput,
+} from './sendgrid-get-contacts.js';
+
+export {
+ SendgridContentSchema,
+ SendgridAttachmentSchema,
+ SendgridContactSchema,
+ type SendgridContent,
+ type SendgridAttachment,
+ type SendgridContact,
+} from './schemas.js';
+
+export { sendgridCredential } from './credentials.js';
diff --git a/packages/nodes/src/integrations/sendgrid/schemas.ts b/packages/nodes/src/integrations/sendgrid/schemas.ts
new file mode 100644
index 0000000..d2e9e91
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/schemas.ts
@@ -0,0 +1,92 @@
+import { z } from 'zod';
+
+// ----- Shared sub-schemas -----
+
+export const SendgridContentSchema = z.object({
+ type: z.enum(['text/plain', 'text/html']),
+ value: z.string().min(1, 'Content value is required'),
+});
+
+export const SendgridAttachmentSchema = z.object({
+ content: z.string().min(1, 'Attachment content (base64) is required'),
+ filename: z.string().min(1, 'Attachment filename is required'),
+ type: z.string().min(1, 'Attachment MIME type is required'),
+ disposition: z.enum(['attachment', 'inline']).optional(),
+ contentId: z.string().optional(),
+});
+
+// ----- sendgridSendEmail -----
+
+export const SendgridSendEmailInputSchema = z.object({
+ to: z.union([
+ z.string().email(),
+ z.array(z.string().email()).min(1, 'At least one recipient is required'),
+ ]),
+ from: z.string().email(),
+ subject: z.string().min(1, 'Subject is required'),
+ content: SendgridContentSchema,
+ cc: z.array(z.string().email()).optional(),
+ bcc: z.array(z.string().email()).optional(),
+ replyTo: z.string().email().optional(),
+ templateId: z.string().optional(),
+ dynamicTemplateData: z.record(z.string(), z.unknown()).optional(),
+ sendAt: z.number().int().positive().optional(),
+ attachments: z.array(SendgridAttachmentSchema).optional(),
+});
+
+export const SendgridSendEmailOutputSchema = z.object({
+ messageId: z.string(),
+ status: z.enum(['sent', 'scheduled']),
+});
+
+// ----- sendgridCreateContact -----
+
+export const SendgridCreateContactInputSchema = z.object({
+ email: z.string().email(),
+ firstName: z.string().optional(),
+ lastName: z.string().optional(),
+ customFields: z.record(z.string(), z.unknown()).optional(),
+ listIds: z.array(z.string()).optional(),
+});
+
+export const SendgridCreateContactOutputSchema = z.object({
+ contactJobId: z.string(),
+ email: z.string(),
+ status: z.literal('queued'),
+});
+
+// ----- sendgridGetContacts -----
+
+export const SendgridGetContactsInputSchema = z.object({
+ listId: z.string().optional(),
+ limit: z.number().int().min(1).max(50).optional(),
+ offset: z.number().int().min(0).optional(),
+});
+
+export const SendgridContactSchema = z.object({
+ id: z.string(),
+ email: z.string(),
+ firstName: z.string().nullable(),
+ lastName: z.string().nullable(),
+ listIds: z.array(z.string()),
+});
+
+export const SendgridGetContactsOutputSchema = z.object({
+ contacts: z.array(SendgridContactSchema),
+ total: z.number().int().min(0),
+});
+
+// ----- Inferred types -----
+
+export type SendgridContent = z.infer;
+export type SendgridAttachment = z.infer;
+export type SendgridContact = z.infer;
+
+export type SendgridSendEmailInput = z.infer;
+export type SendgridSendEmailOutput = z.infer;
+
+export type SendgridCreateContactInput = z.infer;
+export type SendgridCreateContactOutput = z.infer;
+
+export type SendgridGetContactsInput = z.infer;
+export type SendgridGetContactsOutput = z.infer;
diff --git a/packages/nodes/src/integrations/sendgrid/sendgrid-create-contact.ts b/packages/nodes/src/integrations/sendgrid/sendgrid-create-contact.ts
new file mode 100644
index 0000000..d04b34e
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/sendgrid-create-contact.ts
@@ -0,0 +1,113 @@
+import { defineNode } from '@jam-nodes/core';
+import { fetchWithRetry } from '../../utils/http.js';
+import {
+ SendgridCreateContactInputSchema,
+ SendgridCreateContactOutputSchema,
+ type SendgridCreateContactInput,
+ type SendgridCreateContactOutput,
+} from './schemas.js';
+
+export {
+ SendgridCreateContactInputSchema,
+ SendgridCreateContactOutputSchema,
+ type SendgridCreateContactInput,
+ type SendgridCreateContactOutput,
+} from './schemas.js';
+
+const SENDGRID_API_BASE = 'https://api.sendgrid.com/v3';
+
+type SendgridContactUpsertBody = {
+ list_ids?: string[];
+ contacts: Array<{
+ email: string;
+ first_name?: string;
+ last_name?: string;
+ custom_fields?: Record;
+ }>;
+};
+
+type SendgridContactUpsertResponse = {
+ job_id?: string;
+};
+
+export const sendgridCreateContactNode = defineNode({
+ type: 'sendgrid_create_contact',
+ name: 'SendGrid Create Contact',
+ description:
+ 'Upsert a contact in SendGrid Marketing Contacts (returns a job_id for async status polling)',
+ category: 'integration',
+ inputSchema: SendgridCreateContactInputSchema,
+ outputSchema: SendgridCreateContactOutputSchema,
+ estimatedDuration: 3,
+ capabilities: {
+ supportsRerun: true,
+ },
+ executor: async (input: SendgridCreateContactInput, context) => {
+ try {
+ const apiKey = context.credentials?.sendgrid?.apiKey;
+ if (!apiKey) {
+ return {
+ success: false,
+ error:
+ 'SendGrid API key not configured. Please provide context.credentials.sendgrid.apiKey.',
+ };
+ }
+
+ const contact: SendgridContactUpsertBody['contacts'][number] = {
+ email: input.email,
+ };
+ if (input.firstName !== undefined) contact.first_name = input.firstName;
+ if (input.lastName !== undefined) contact.last_name = input.lastName;
+ if (input.customFields !== undefined) contact.custom_fields = input.customFields;
+
+ const body: SendgridContactUpsertBody = {
+ contacts: [contact],
+ };
+ if (input.listIds && input.listIds.length > 0) {
+ body.list_ids = input.listIds;
+ }
+
+ const response = await fetchWithRetry(
+ `${SENDGRID_API_BASE}/marketing/contacts`,
+ {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ },
+ { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }
+ );
+
+ if (response.status < 200 || response.status >= 300) {
+ const errorBody = await response.text().catch(() => '');
+ return {
+ success: false,
+ error: `SendGrid create contact failed: ${response.status} ${errorBody}`.trim(),
+ };
+ }
+
+ const data = (await response.json().catch(() => ({}))) as SendgridContactUpsertResponse;
+ if (!data.job_id) {
+ return {
+ success: false,
+ error: 'SendGrid upsert response missing job_id',
+ };
+ }
+
+ const output: SendgridCreateContactOutput = {
+ contactJobId: data.job_id,
+ email: input.email,
+ status: 'queued',
+ };
+
+ return { success: true, output };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to upsert SendGrid contact',
+ };
+ }
+ },
+});
diff --git a/packages/nodes/src/integrations/sendgrid/sendgrid-get-contacts.ts b/packages/nodes/src/integrations/sendgrid/sendgrid-get-contacts.ts
new file mode 100644
index 0000000..f6abcb1
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/sendgrid-get-contacts.ts
@@ -0,0 +1,116 @@
+import { defineNode } from '@jam-nodes/core';
+import { fetchWithRetry } from '../../utils/http.js';
+import {
+ SendgridGetContactsInputSchema,
+ SendgridGetContactsOutputSchema,
+ type SendgridGetContactsInput,
+ type SendgridGetContactsOutput,
+ type SendgridContact,
+} from './schemas.js';
+
+export {
+ SendgridGetContactsInputSchema,
+ SendgridGetContactsOutputSchema,
+ type SendgridGetContactsInput,
+ type SendgridGetContactsOutput,
+} from './schemas.js';
+
+const SENDGRID_API_BASE = 'https://api.sendgrid.com/v3';
+
+type SendgridSearchResult = {
+ id?: string;
+ email?: string;
+ first_name?: string | null;
+ last_name?: string | null;
+ list_ids?: string[];
+};
+
+type SendgridSearchResponse = {
+ result?: SendgridSearchResult[];
+ contact_count?: number;
+};
+
+function escapeSgql(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+}
+
+function buildQuery(listId: string | undefined): string {
+ if (!listId) return '';
+ return `CONTAINS(list_ids, '${escapeSgql(listId)}')`;
+}
+
+export const sendgridGetContactsNode = defineNode({
+ type: 'sendgrid_get_contacts',
+ name: 'SendGrid Get Contacts',
+ description:
+ 'Search SendGrid Marketing Contacts (capped at 50 per call by the SendGrid API)',
+ category: 'integration',
+ inputSchema: SendgridGetContactsInputSchema,
+ outputSchema: SendgridGetContactsOutputSchema,
+ estimatedDuration: 3,
+ capabilities: {
+ supportsRerun: true,
+ },
+ executor: async (input: SendgridGetContactsInput, context) => {
+ try {
+ const apiKey = context.credentials?.sendgrid?.apiKey;
+ if (!apiKey) {
+ return {
+ success: false,
+ error:
+ 'SendGrid API key not configured. Please provide context.credentials.sendgrid.apiKey.',
+ };
+ }
+
+ const limit = input.limit ?? 50;
+ const offset = input.offset ?? 0;
+ const query = buildQuery(input.listId);
+
+ const response = await fetchWithRetry(
+ `${SENDGRID_API_BASE}/marketing/contacts/search`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query }),
+ },
+ { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }
+ );
+
+ if (response.status < 200 || response.status >= 300) {
+ const errorBody = await response.text().catch(() => '');
+ return {
+ success: false,
+ error: `SendGrid get contacts failed: ${response.status} ${errorBody}`.trim(),
+ };
+ }
+
+ const data = (await response.json().catch(() => ({}))) as SendgridSearchResponse;
+ const rawResults = data.result ?? [];
+
+ const mapped: SendgridContact[] = rawResults.map((item) => ({
+ id: item.id ?? '',
+ email: item.email ?? '',
+ firstName: item.first_name ?? null,
+ lastName: item.last_name ?? null,
+ listIds: item.list_ids ?? [],
+ }));
+
+ const paged = mapped.slice(offset, offset + limit);
+
+ const output: SendgridGetContactsOutput = {
+ contacts: paged,
+ total: data.contact_count ?? mapped.length,
+ };
+
+ return { success: true, output };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch SendGrid contacts',
+ };
+ }
+ },
+});
diff --git a/packages/nodes/src/integrations/sendgrid/sendgrid-send-email.ts b/packages/nodes/src/integrations/sendgrid/sendgrid-send-email.ts
new file mode 100644
index 0000000..a3cac26
--- /dev/null
+++ b/packages/nodes/src/integrations/sendgrid/sendgrid-send-email.ts
@@ -0,0 +1,152 @@
+import { defineNode } from '@jam-nodes/core';
+import { fetchWithRetry } from '../../utils/http.js';
+import {
+ SendgridSendEmailInputSchema,
+ SendgridSendEmailOutputSchema,
+ type SendgridSendEmailInput,
+ type SendgridSendEmailOutput,
+ type SendgridAttachment,
+} from './schemas.js';
+
+export {
+ SendgridSendEmailInputSchema,
+ SendgridSendEmailOutputSchema,
+ type SendgridSendEmailInput,
+ type SendgridSendEmailOutput,
+} from './schemas.js';
+
+const SENDGRID_API_BASE = 'https://api.sendgrid.com/v3';
+
+type SendgridPersonalization = {
+ to: Array<{ email: string }>;
+ cc?: Array<{ email: string }>;
+ bcc?: Array<{ email: string }>;
+ dynamic_template_data?: Record;
+};
+
+type SendgridMailSendBody = {
+ personalizations: SendgridPersonalization[];
+ from: { email: string };
+ subject: string;
+ content?: Array<{ type: string; value: string }>;
+ template_id?: string;
+ reply_to?: { email: string };
+ send_at?: number;
+ attachments?: Array<{
+ content: string;
+ filename: string;
+ type: string;
+ disposition?: string;
+ content_id?: string;
+ }>;
+};
+
+function toEmailObjects(to: string | string[]): Array<{ email: string }> {
+ const emails = Array.isArray(to) ? to : [to];
+ return emails.map((email) => ({ email }));
+}
+
+function mapAttachments(attachments: SendgridAttachment[]): SendgridMailSendBody['attachments'] {
+ return attachments.map((att) => ({
+ content: att.content,
+ filename: att.filename,
+ type: att.type,
+ ...(att.disposition !== undefined ? { disposition: att.disposition } : {}),
+ ...(att.contentId !== undefined ? { content_id: att.contentId } : {}),
+ }));
+}
+
+export const sendgridSendEmailNode = defineNode({
+ type: 'sendgrid_send_email',
+ name: 'SendGrid Send Email',
+ description:
+ 'Send a transactional email via SendGrid with template and attachment support',
+ category: 'integration',
+ inputSchema: SendgridSendEmailInputSchema,
+ outputSchema: SendgridSendEmailOutputSchema,
+ estimatedDuration: 5,
+ capabilities: {
+ supportsRerun: true,
+ },
+ executor: async (input: SendgridSendEmailInput, context) => {
+ try {
+ const apiKey = context.credentials?.sendgrid?.apiKey;
+ if (!apiKey) {
+ return {
+ success: false,
+ error:
+ 'SendGrid API key not configured. Please provide context.credentials.sendgrid.apiKey.',
+ };
+ }
+
+ const personalization: SendgridPersonalization = {
+ to: toEmailObjects(input.to),
+ };
+ if (input.cc && input.cc.length > 0) {
+ personalization.cc = input.cc.map((email) => ({ email }));
+ }
+ if (input.bcc && input.bcc.length > 0) {
+ personalization.bcc = input.bcc.map((email) => ({ email }));
+ }
+ if (input.templateId && input.dynamicTemplateData) {
+ personalization.dynamic_template_data = input.dynamicTemplateData;
+ }
+
+ const body: SendgridMailSendBody = {
+ personalizations: [personalization],
+ from: { email: input.from },
+ subject: input.subject,
+ };
+
+ if (input.templateId) {
+ body.template_id = input.templateId;
+ } else {
+ body.content = [{ type: input.content.type, value: input.content.value }];
+ }
+
+ if (input.replyTo) {
+ body.reply_to = { email: input.replyTo };
+ }
+ if (input.sendAt !== undefined) {
+ body.send_at = input.sendAt;
+ }
+ if (input.attachments && input.attachments.length > 0) {
+ body.attachments = mapAttachments(input.attachments);
+ }
+
+ const response = await fetchWithRetry(
+ `${SENDGRID_API_BASE}/mail/send`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ },
+ { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }
+ );
+
+ if (response.status < 200 || response.status >= 300) {
+ const errorBody = await response.text().catch(() => '');
+ return {
+ success: false,
+ error: `SendGrid send failed: ${response.status} ${errorBody}`.trim(),
+ };
+ }
+
+ const messageId = response.headers.get('x-message-id') ?? '';
+ const output: SendgridSendEmailOutput = {
+ messageId,
+ status: input.sendAt !== undefined ? 'scheduled' : 'sent',
+ };
+
+ return { success: true, output };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to send SendGrid email',
+ };
+ }
+ },
+});
From 024feb6ab4c07fa1fdd5c089aad451d41ce04d5b Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Sat, 11 Apr 2026 10:44:08 -0500
Subject: [PATCH 2/2] fix(sendgrid): address review findings (#18)
- Add Zod superRefine: dynamicTemplateData requires templateId
- Block CRLF characters in subject (defense-in-depth header injection guard)
- Trim trailing whitespace on all email fields before .email() validation
- Cover attachment disposition/content_id mapping in tests
- Cover SGQL backslash escaping in tests (single quote, backslash, and \\' edge case)
Test count 48 -> 54 (243 -> 249 across the nodes package).
---
.../sendgrid/__tests__/sendgrid.test.ts | 84 +++++++++++++++++++
.../src/integrations/sendgrid/schemas.ts | 44 ++++++----
2 files changed, 112 insertions(+), 16 deletions(-)
diff --git a/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts b/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts
index c7eb209..4697012 100644
--- a/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts
+++ b/packages/nodes/src/integrations/sendgrid/__tests__/sendgrid.test.ts
@@ -99,6 +99,40 @@ describe('sendgrid schemas', () => {
expect(result.success).toBe(false);
});
+ it('rejects send-email with CRLF in subject', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'a@x.com',
+ from: 'b@x.com',
+ subject: 'Hello\r\nBcc: attacker@evil.com',
+ content: { type: 'text/plain', value: 'hi' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects send-email with dynamicTemplateData but no templateId', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: 'a@x.com',
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/plain', value: 'hi' },
+ dynamicTemplateData: { name: 'Ada' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('trims trailing whitespace from email fields', () => {
+ const result = SendgridSendEmailInputSchema.safeParse({
+ to: ' trimmed@x.com ',
+ from: 'b@x.com',
+ subject: 's',
+ content: { type: 'text/plain', value: 'hi' },
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.to).toBe('trimmed@x.com');
+ }
+ });
+
it('accepts send-email with template', () => {
const result = SendgridSendEmailInputSchema.safeParse({
to: 'a@x.com',
@@ -273,6 +307,32 @@ describe('sendgrid_send_email', () => {
expect(body.attachments[0].filename).toBe('hello.txt');
expect(body.attachments[0].content).toBe('SGVsbG8=');
expect(body.attachments[0].type).toBe('text/plain');
+ expect(body.attachments[0].disposition).toBeUndefined();
+ expect(body.attachments[0].content_id).toBeUndefined();
+ });
+
+ it('maps attachment disposition and contentId to snake_case', async () => {
+ const fetchMock = mockAccepted();
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridSendEmailNode.executor(
+ {
+ ...basicInput,
+ attachments: [
+ {
+ content: 'SGVsbG8=',
+ filename: 'logo.png',
+ type: 'image/png',
+ disposition: 'inline',
+ contentId: 'logo-cid',
+ },
+ ],
+ },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.attachments[0].disposition).toBe('inline');
+ expect(body.attachments[0].content_id).toBe('logo-cid');
});
it('sets status to scheduled when sendAt provided', async () => {
@@ -537,6 +597,30 @@ describe('sendgrid_get_contacts', () => {
expect(body.query).toBe("CONTAINS(list_ids, 'o\\'brien')");
});
+ it('escapes backslashes before quotes in listId', async () => {
+ const fetchMock = mockResult({ result: [], contact_count: 0 });
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridGetContactsNode.executor(
+ { listId: "path\\with\\backslash", limit: 50, offset: 0 },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.query).toBe("CONTAINS(list_ids, 'path\\\\with\\\\backslash')");
+ });
+
+ it('escapes backslash followed by quote without double-escaping', async () => {
+ const fetchMock = mockResult({ result: [], contact_count: 0 });
+ vi.stubGlobal('fetch', fetchMock);
+ await sendgridGetContactsNode.executor(
+ { listId: "a\\'b", limit: 50, offset: 0 },
+ makeCtx()
+ );
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(String(init.body));
+ expect(body.query).toBe("CONTAINS(list_ids, 'a\\\\\\'b')");
+ });
+
it('applies limit client-side', async () => {
vi.stubGlobal(
'fetch',
diff --git a/packages/nodes/src/integrations/sendgrid/schemas.ts b/packages/nodes/src/integrations/sendgrid/schemas.ts
index d2e9e91..a376335 100644
--- a/packages/nodes/src/integrations/sendgrid/schemas.ts
+++ b/packages/nodes/src/integrations/sendgrid/schemas.ts
@@ -17,22 +17,34 @@ export const SendgridAttachmentSchema = z.object({
// ----- sendgridSendEmail -----
-export const SendgridSendEmailInputSchema = z.object({
- to: z.union([
- z.string().email(),
- z.array(z.string().email()).min(1, 'At least one recipient is required'),
- ]),
- from: z.string().email(),
- subject: z.string().min(1, 'Subject is required'),
- content: SendgridContentSchema,
- cc: z.array(z.string().email()).optional(),
- bcc: z.array(z.string().email()).optional(),
- replyTo: z.string().email().optional(),
- templateId: z.string().optional(),
- dynamicTemplateData: z.record(z.string(), z.unknown()).optional(),
- sendAt: z.number().int().positive().optional(),
- attachments: z.array(SendgridAttachmentSchema).optional(),
-});
+const trimmedEmail = z.string().trim().email();
+
+export const SendgridSendEmailInputSchema = z
+ .object({
+ to: z.union([trimmedEmail, z.array(trimmedEmail).min(1, 'At least one recipient is required')]),
+ from: trimmedEmail,
+ subject: z
+ .string()
+ .min(1, 'Subject is required')
+ .regex(/^[^\r\n]*$/, 'Subject must not contain CR or LF characters'),
+ content: SendgridContentSchema,
+ cc: z.array(trimmedEmail).optional(),
+ bcc: z.array(trimmedEmail).optional(),
+ replyTo: trimmedEmail.optional(),
+ templateId: z.string().optional(),
+ dynamicTemplateData: z.record(z.string(), z.unknown()).optional(),
+ sendAt: z.number().int().positive().optional(),
+ attachments: z.array(SendgridAttachmentSchema).optional(),
+ })
+ .superRefine((value, ctx) => {
+ if (value.dynamicTemplateData !== undefined && value.templateId === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['dynamicTemplateData'],
+ message: 'dynamicTemplateData requires templateId to be set',
+ });
+ }
+ });
export const SendgridSendEmailOutputSchema = z.object({
messageId: z.string(),