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(),