diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 90dd40fd8..0d724453a 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -196,6 +196,7 @@ export const BaseProviders = [ 'retailed', 'salesforce', 'securitytrails', + 'sendgrid', 'sentry', 'serpapi', 'sharepoint', @@ -431,6 +432,7 @@ export const ProviderDisplayNames = { retailed: 'Retailed', salesforce: 'Salesforce', securitytrails: 'SecurityTrails', + sendgrid: 'SendGrid', sentry: 'Sentry', serpapi: 'Serpapi', sharepoint: 'SharePoint', @@ -672,6 +674,7 @@ export type AllProviders = | 'retailed' | 'salesforce' | 'securitytrails' + | 'sendgrid' | 'sentry' | 'serpapi' | 'sharepoint' diff --git a/packages/sendgrid/api.test.ts b/packages/sendgrid/api.test.ts new file mode 100644 index 000000000..b70ce4d09 --- /dev/null +++ b/packages/sendgrid/api.test.ts @@ -0,0 +1,353 @@ +import { makeSendGridRequest, SendGridAPIError } from './client'; +import { contacts, lists, mail, senders, suppressions } from './endpoints'; +import { runCatalogOp } from './endpoints/bind'; +import { SENDGRID_OPS } from './endpoints/catalog'; +import { + SendGridEndpointInputSchemas, + SendGridEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; + +const TEST_API_KEY = process.env.SENDGRID_API_KEY; +const describeLive = TEST_API_KEY ? describe : describe.skip; + +const fixtures: Record> = { + empty: {}, + mailSend: { + personalizations: [{ to: [{ email: 'recipient@example.com' }] }], + from: { email: 'sender@example.com' }, + subject: 'Test Email', + content: [{ type: 'text/plain', value: 'Hello' }], + }, + batchId: { batch_id: 'batch-1' }, + scheduledSend: { batch_id: 'batch-1', status: 'pause' }, + scheduledSendUpdate: { batch_id: 'batch-1', status: 'cancel' }, + contactsAddOrUpdate: { + contacts: [{ email: 'user@example.com', first_name: 'Jane' }], + }, + id: { id: 'abc' }, + contactSearch: { query: "email LIKE '%@example.com'" }, + contactSearchEmails: { emails: ['user@example.com'] }, + idsQuery: { ids: 'id-1,id-2' }, + page: { page_size: 20, page_token: 'token123' }, + contactImport: { file_type: 'csv', field_mappings: ['email'] }, + contactExport: { file_type: 'csv' }, + listsCreate: { name: 'List 2' }, + idSample: { id: 'list-1', contact_sample: true }, + idName: { id: 'list-1', name: 'Renamed' }, + idDeleteContacts: { id: 'list-1', delete_contacts: false }, + idContactIds: { id: 'list-1', contact_ids: 'c1,c2' }, + segment: { name: 'Active', query_dsl: '{}' }, + segmentsList: {}, + idContactsSample: { id: 'seg-1' }, + segmentUpdate: { id: 'seg-1', name: 'Updated' }, + segmentRefresh: { id: 'seg-1', user_time_zone: 'America/Chicago' }, + fieldCreate: { name: 'plan', field_type: 'Text' }, + sendersGetAll: { limit: 10 }, + verifiedSender: { + nickname: 'Primary', + from_email: 'from@example.com', + }, + verifiedSenderUpdate: { id: 10, nickname: 'Updated' }, + idNum: { id: 10 }, + senderIdentity: { nickname: 'Marketing' }, + templateCreate: { name: 'Welcome' }, + templatesList: { generations: 'dynamic' }, + templateVersion: { id: 'tmpl-1', name: 'v1', subject: 'Hi' }, + templateVersionId: { id: 'tmpl-1', version_id: 'ver-1' }, + templateVersionUpdate: { + id: 'tmpl-1', + version_id: 'ver-1', + subject: 'Hello', + }, + suppressionList: { limit: 10, offset: 0 }, + email: { email: 'b@example.com' }, + deleteSuppressions: { emails: ['b@example.com'] }, + recipientEmails: { recipient_emails: ['b@example.com'] }, + asmIdQuery: {}, + asmGroup: { name: 'Alerts', description: 'Product alerts' }, + asmGroupUpdate: { id: 1, name: 'Alerts' }, + asmGroupEmails: { id: 1, recipient_emails: ['b@example.com'] }, + idNumEmail: { id: 1, email: 'b@example.com' }, + stats: { start_date: '2026-01-01' }, + statsCategories: { start_date: '2026-01-01', categories: 'welcome' }, + apiKeyCreate: { name: 'dev' }, + limit: { limit: 10 }, + apiKeyUpdate: { id: 'key-1', name: 'dev-2' }, +}; + +describe('SendGrid Endpoints Execution & Error Policies', () => { + const mockCtx: Record = { + key: 'SG.test_api_key_123', + authType: 'api_key', + $getAccountId: async () => 'acc-123', + db: {}, + database: {}, + }; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + function mockResponse( + status: number, + data: unknown, + headers: Record = {}, + ) { + const bodyText = typeof data === 'string' ? data : JSON.stringify(data); + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 401 ? 'Unauthorized' : 'OK', + headers: { + get: (name: string) => { + const key = name.toLowerCase(); + if (key === 'content-type') return 'application/json'; + for (const [header, value] of Object.entries(headers)) { + if (header.toLowerCase() === key) return value; + } + return null; + }, + }, + json: async () => (typeof data === 'object' ? data : { message: data }), + text: async () => bodyText, + }; + } + + it('covers 100 official v3 ops', () => { + expect(SENDGRID_OPS).toHaveLength(100); + expect(new Set(SENDGRID_OPS.map((op) => op.nested)).size).toBe(100); + }); + + it.each(SENDGRID_OPS)('$nested has schemas and a fixture', (op) => { + const inputSchema = + SendGridEndpointInputSchemas[ + op.key as keyof typeof SendGridEndpointInputSchemas + ]; + const outputSchema = + SendGridEndpointOutputSchemas[ + op.key as keyof typeof SendGridEndpointOutputSchemas + ]; + expect(inputSchema).toBeDefined(); + expect(outputSchema).toBeDefined(); + expect(fixtures[op.inKind]).toBeDefined(); + inputSchema.parse(fixtures[op.inKind]); + }); + + it.each(SENDGRID_OPS)('$nested $method /v3/$path', async (op) => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse( + 200, + op.wrapArray + ? [ + { + email: 'b@example.com', + created: 1, + reason: 'x', + status: '5.0.0', + }, + ] + : { ok: true }, + op.responseHeader ? { [op.responseHeader]: 'msg-1.filter' } : {}, + ), + ); + + await runCatalogOp(mockCtx as never, op, fixtures[op.inKind]!); + + const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; + const called = (global.fetch as jest.Mock).mock.calls[0][1] as { + method: string; + }; + expect(called.method).toBe(op.method); + expect(calledUrl).toContain('https://api.sendgrid.com/v3/'); + expect(calledUrl).toContain(op.path.split('/{')[0]!); + }); + + it('executes Mail.send and returns X-Message-Id', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(202, '', { 'X-Message-Id': 'msg-1.filter' }), + ); + + const res = await mail.send(mockCtx as never, fixtures.mailSend as never); + + expect(res.x_message_id).toBe('msg-1.filter'); + const [, init] = (global.fetch as jest.Mock).mock.calls[0] as [ + string, + { method?: string; headers?: Headers | Record }, + ]; + expect(init.method).toBe('POST'); + const auth = + init.headers instanceof Headers + ? init.headers.get('Authorization') + : init.headers?.Authorization; + expect(auth).toBe('Bearer SG.test_api_key_123'); + }); + + it('executes Contacts.addOrUpdate endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(202, { job_id: 'job-123' }), + ); + + const res = await contacts.addOrUpdate( + mockCtx as never, + fixtures.contactsAddOrUpdate as never, + ); + + expect(res.job_id).toBe('job-123'); + }); + + it('executes Lists.getAll with pagination query parameters', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(200, { + result: [{ id: 'l1', name: 'List 1', contact_count: 5 }], + }), + ); + + const res = await lists.getAll(mockCtx as never, { + page_size: 20, + page_token: 'token123', + }); + + expect(res.result).toHaveLength(1); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.sendgrid.com/v3/marketing/lists?page_size=20&page_token=token123', + expect.anything(), + ); + }); + + it('executes Lists.create endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(201, { id: 'l2', name: 'List 2', contact_count: 0 }), + ); + + const res = await lists.create(mockCtx as never, { name: 'List 2' }); + + expect(res.id).toBe('l2'); + }); + + it('executes Suppressions.getBounces endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(200, [ + { + created: 100, + email: 'b@example.com', + reason: 'Hard bounce', + status: '5.1.1', + }, + ]), + ); + + const res = await suppressions.getBounces(mockCtx as never, { + start_time: 0, + limit: 10, + offset: 0, + }); + + expect(res.bounces).toHaveLength(1); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('start_time=0'), + expect.anything(), + ); + expect(res.bounces[0]!.email).toBe('b@example.com'); + }); + + it('executes Senders.getAll endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(200, { + results: [ + { + id: 10, + nickname: 'Primary', + from_email: 's@example.com', + verified: true, + }, + ], + }), + ); + + const res = await senders.getAll(mockCtx as never, { limit: 10 }); + + expect(res.results[0]!.verified).toBe(true); + }); + + it('preserves HTTP status on SendGridAPIError (401)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + mockResponse(401, { errors: [{ message: 'Unauthorized' }] }), + ); + + await expect( + makeSendGridRequest('mail/send', 'SG.key', { method: 'POST' }), + ).rejects.toMatchObject({ + name: 'SendGridAPIError', + status: 401, + }); + }); + + it('classifies 429 as RATE_LIMIT_ERROR and honors Retry-After ms', async () => { + const error = new SendGridAPIError( + 'Too Many Requests', + undefined, + 429, + undefined, + 45000, + ); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true); + const policy = await errorHandlers.RATE_LIMIT_ERROR.handler(error); + expect(policy.maxRetries).toBe(3); + expect(policy.headersRetryAfterMs).toBe(45000); + }); + + it('does not retry 429 in the HTTP client', async () => { + (global.fetch as jest.Mock).mockResolvedValue( + mockResponse( + 429, + { errors: [{ message: 'Too Many Requests' }] }, + { 'Retry-After': '45' }, + ), + ); + + await expect( + makeSendGridRequest('mail/send', 'SG.key', { method: 'POST' }), + ).rejects.toMatchObject({ + name: 'SendGridAPIError', + status: 429, + retryAfter: 45000, + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describeLive('SendGrid live API', () => { + it('senders.getAll matches VerifiedSenderResponse', async () => { + const result = await makeSendGridRequest<{ results: unknown[] }>( + 'verified_senders', + TEST_API_KEY!, + ); + SendGridEndpointOutputSchemas.sendersGetAll.parse(result); + }); + + it('suppressions.getBounces returns bounce records', async () => { + const result = await makeSendGridRequest( + 'suppression/bounces', + TEST_API_KEY!, + { query: { limit: 1 } }, + ); + const bounces = Array.isArray(result) ? result : []; + SendGridEndpointOutputSchemas.suppressionsGetBounces.parse({ bounces }); + }); + + it('lists.getAll matches marketing lists payload', async () => { + const result = await makeSendGridRequest( + 'marketing/lists', + TEST_API_KEY!, + { + query: { page_size: 1 }, + }, + ); + SendGridEndpointOutputSchemas.listsGetAll.parse(result); + }); +}); diff --git a/packages/sendgrid/client.ts b/packages/sendgrid/client.ts new file mode 100644 index 000000000..309f5b231 --- /dev/null +++ b/packages/sendgrid/client.ts @@ -0,0 +1,109 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class SendGridAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + public readonly status?: number, + public readonly body?: unknown, + public readonly retryAfter?: number, + ) { + super(message); + this.name = 'SendGridAPIError'; + } +} + +const SENDGRID_API_BASE = 'https://api.sendgrid.com/v3'; + +/** Parse Retry-After on 429; do not retry here. Endpoint policy owns retries. */ +const SENDGRID_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 0, + initialRetryDelay: 0, + backoffMultiplier: 1, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +export async function makeSendGridRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: unknown; + query?: Record; + responseHeader?: string; + } = {}, +): Promise { + const { method = 'GET', body, query, responseHeader } = options; + const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + + const config: OpenAPIConfig = { + BASE: SENDGRID_API_BASE, + VERSION: 'v3', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: cleanEndpoint, + body: + method === 'POST' || + method === 'PUT' || + method === 'PATCH' || + method === 'DELETE' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: + method === 'GET' || method === 'DELETE' || method === 'PATCH' + ? query + : undefined, + responseHeader, + }; + + try { + return await request(config, requestOptions, { + rateLimitConfig: SENDGRID_RATE_LIMIT_CONFIG, + }); + } catch (error) { + if (error instanceof ApiError) { + const bodyObj = + typeof error.body === 'object' && error.body !== null + ? (error.body as Record) + : undefined; + const firstError = + Array.isArray(bodyObj?.errors) && + typeof bodyObj.errors[0] === 'object' && + bodyObj.errors[0] !== null + ? (bodyObj.errors[0] as Record) + : undefined; + const msg = + typeof firstError?.message === 'string' + ? firstError.message + : error.message; + throw new SendGridAPIError( + msg, + typeof firstError?.field === 'string' ? firstError.field : undefined, + error.status, + error.body, + error.retryAfter, + ); + } + if (error instanceof Error) { + throw new SendGridAPIError(error.message); + } + throw new SendGridAPIError('Unknown error'); + } +} diff --git a/packages/sendgrid/endpoints.test.ts b/packages/sendgrid/endpoints.test.ts new file mode 100644 index 000000000..723b44d8d --- /dev/null +++ b/packages/sendgrid/endpoints.test.ts @@ -0,0 +1,152 @@ +import { + SendGridEndpointInputSchemas, + SendGridEndpointOutputSchemas, +} from './endpoints/types'; + +describe('SendGrid Endpoint Schemas', () => { + it('validates mail.send input and output schemas', () => { + const validInput = { + personalizations: [ + { + to: [{ email: 'recipient@example.com', name: 'Recipient' }], + subject: 'Test Subject', + }, + ], + from: { email: 'sender@example.com', name: 'Sender' }, + subject: 'Global Subject', + content: [{ type: 'text/plain', value: 'Hello World' }], + }; + const parsedInput = SendGridEndpointInputSchemas.mailSend.parse(validInput); + expect(parsedInput.from.email).toBe('sender@example.com'); + expect(parsedInput.personalizations[0]!.to[0]!.email).toBe( + 'recipient@example.com', + ); + + const validOutput = { x_message_id: 'filter0001' }; + const parsedOutput = + SendGridEndpointOutputSchemas.mailSend.parse(validOutput); + expect(parsedOutput.x_message_id).toBe('filter0001'); + }); + + it('rejects mail.send without content or subject unless template_id is set', () => { + expect(() => + SendGridEndpointInputSchemas.mailSend.parse({ + personalizations: [{ to: [{ email: 'recipient@example.com' }] }], + from: { email: 'sender@example.com' }, + content: [], + }), + ).toThrow(); + + expect( + SendGridEndpointInputSchemas.mailSend.parse({ + personalizations: [{ to: [{ email: 'recipient@example.com' }] }], + from: { email: 'sender@example.com' }, + template_id: 'd-123', + }), + ).toMatchObject({ template_id: 'd-123' }); + }); + + it('validates contacts.addOrUpdate input and output schemas', () => { + const validInput = { + contacts: [ + { email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe' }, + ], + list_ids: ['list-123'], + }; + const parsedInput = + SendGridEndpointInputSchemas.contactsAddOrUpdate.parse(validInput); + expect(parsedInput.contacts[0]!.email).toBe('john.doe@example.com'); + + const validOutput = { job_id: 'job-456' }; + const parsedOutput = + SendGridEndpointOutputSchemas.contactsAddOrUpdate.parse(validOutput); + expect(parsedOutput.job_id).toBe('job-456'); + }); + + it('rejects contacts.addOrUpdate without an identifier', () => { + expect(() => + SendGridEndpointInputSchemas.contactsAddOrUpdate.parse({ + contacts: [{ first_name: 'Jane' }], + }), + ).toThrow(); + }); + + it('validates lists.getAll input and output schemas', () => { + const validInput = { page_size: 10, page_token: 'token-abc' }; + const parsedInput = + SendGridEndpointInputSchemas.listsGetAll.parse(validInput); + expect(parsedInput.page_size).toBe(10); + + const validOutput = { + result: [{ id: 'list-1', name: 'Main List', contact_count: 50 }], + }; + const parsedOutput = + SendGridEndpointOutputSchemas.listsGetAll.parse(validOutput); + expect(parsedOutput.result[0]!.name).toBe('Main List'); + }); + + it('validates lists.create input and output schemas', () => { + const validInput = { name: 'New Subscribers' }; + const parsedInput = + SendGridEndpointInputSchemas.listsCreate.parse(validInput); + expect(parsedInput.name).toBe('New Subscribers'); + + const validOutput = { + id: 'list-999', + name: 'New Subscribers', + contact_count: 0, + }; + const parsedOutput = + SendGridEndpointOutputSchemas.listsCreate.parse(validOutput); + expect(parsedOutput.id).toBe('list-999'); + }); + + it('validates suppressions.getBounces input and output schemas', () => { + const validInput = { + start_time: 1600000000, + end_time: 1700000000, + limit: 50, + offset: 0, + }; + const parsedInput = + SendGridEndpointInputSchemas.suppressionsGetBounces.parse(validInput); + expect(parsedInput.start_time).toBe(1600000000); + + const validOutput = { + bounces: [ + { + created: 1650000000, + email: 'bounced@example.com', + reason: '550 User unknown', + status: '5.1.1', + }, + ], + }; + const parsedOutput = + SendGridEndpointOutputSchemas.suppressionsGetBounces.parse(validOutput); + expect(parsedOutput.bounces[0]!.email).toBe('bounced@example.com'); + }); + + it('validates senders.getAll input and output schemas', () => { + const validInput = { limit: 10 }; + const parsedInput = + SendGridEndpointInputSchemas.sendersGetAll.parse(validInput); + expect(parsedInput.limit).toBe(10); + + const validOutput = { + results: [ + { + id: 1, + nickname: 'Support', + from_email: 'support@example.com', + from_name: 'Support', + verified: true, + locked: false, + }, + ], + }; + const parsedOutput = + SendGridEndpointOutputSchemas.sendersGetAll.parse(validOutput); + expect(parsedOutput.results[0]!.verified).toBe(true); + }); +}); diff --git a/packages/sendgrid/endpoints/bind.ts b/packages/sendgrid/endpoints/bind.ts new file mode 100644 index 000000000..65677afe7 --- /dev/null +++ b/packages/sendgrid/endpoints/bind.ts @@ -0,0 +1,73 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeSendGridRequest } from '../client'; +import type { OpSpec } from './catalog'; +import { enc, q } from './run'; + +type Ctx = Parameters[0] & { key: string }; + +export async function runCatalogOp( + ctx: Ctx, + spec: OpSpec, + input: Record, +): Promise { + let path = spec.path; + for (const key of spec.pathKeys ?? []) { + path = path.replaceAll(`{${key}}`, enc(String(input[key]))); + } + + const query = spec.queryKeys ? q(input, spec.queryKeys) : undefined; + + let body: unknown; + if (spec.body === true) { + body = input; + } else if (spec.body === 'omit') { + const next = { ...input }; + for (const key of [...(spec.pathKeys ?? []), ...(spec.queryKeys ?? [])]) { + delete next[key]; + } + body = next; + } + + const result = await makeSendGridRequest(path, ctx.key, { + method: spec.method, + body, + query, + responseHeader: spec.responseHeader, + }); + + let mapped: unknown = result; + if (spec.mapHeader === 'x_message_id') { + mapped = { + x_message_id: typeof result === 'string' ? result : undefined, + }; + } else if (spec.wrapArray) { + const items = Array.isArray(result) ? result : []; + mapped = { [spec.wrapArray]: items }; + } else if (result === undefined || result === null) { + mapped = {}; + } + + if (spec.upsertList) { + const row = mapped as { id?: string }; + const listsDb = ( + ctx as { + db?: { + lists?: { + upsertByEntityId: (id: string, data: never) => Promise; + }; + }; + } + ).db?.lists; + if (row.id && listsDb) { + await listsDb.upsertByEntityId(row.id, mapped as never); + } + } + + await logEventFromContext( + ctx, + `sendgrid.${spec.nested}`, + { method: spec.method }, + 'completed', + ); + return mapped; +} diff --git a/packages/sendgrid/endpoints/catalog.ts b/packages/sendgrid/endpoints/catalog.ts new file mode 100644 index 000000000..91cd58fea --- /dev/null +++ b/packages/sendgrid/endpoints/catalog.ts @@ -0,0 +1,1159 @@ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export type OpSpec = { + key: string; + nested: string; + risk: 'read' | 'write'; + description: string; + method: HttpMethod; + path: string; + pathKeys?: string[]; + queryKeys?: string[]; + body?: true | 'omit'; + responseHeader?: string; + mapHeader?: 'x_message_id'; + wrapArray?: string; + upsertList?: boolean; + inKind: string; + outKind: string; +}; + +export const SENDGRID_OPS: OpSpec[] = [ + { + key: 'mailSend', + nested: 'mail.send', + risk: 'write', + description: 'Send an email via SendGrid Mail Send API v3', + method: 'POST', + path: 'mail/send', + body: true, + responseHeader: 'X-Message-Id', + mapHeader: 'x_message_id', + inKind: 'mailSend', + outKind: 'mailSend', + }, + { + key: 'mailCreateBatchId', + nested: 'mail.createBatchId', + risk: 'write', + description: 'Create a batch ID for scheduled mail', + method: 'POST', + path: 'mail/batch', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'mailValidateBatchId', + nested: 'mail.validateBatchId', + risk: 'read', + description: 'Validate a mail batch ID', + method: 'GET', + path: 'mail/batch/{batch_id}', + pathKeys: ['batch_id'], + inKind: 'batchId', + outKind: 'obj', + }, + { + key: 'mailCancelScheduledSend', + nested: 'mail.cancelScheduledSend', + risk: 'write', + description: 'Cancel or pause a scheduled send', + method: 'POST', + path: 'user/scheduled_sends', + body: true, + inKind: 'scheduledSend', + outKind: 'obj', + }, + { + key: 'mailListScheduledSends', + nested: 'mail.listScheduledSends', + risk: 'read', + description: 'Retrieve all scheduled sends', + method: 'GET', + path: 'user/scheduled_sends', + inKind: 'empty', + outKind: 'arr', + }, + { + key: 'mailGetScheduledSend', + nested: 'mail.getScheduledSend', + risk: 'read', + description: 'Retrieve a scheduled send by batch ID', + method: 'GET', + path: 'user/scheduled_sends/{batch_id}', + pathKeys: ['batch_id'], + inKind: 'batchId', + outKind: 'arr', + }, + { + key: 'mailUpdateScheduledSend', + nested: 'mail.updateScheduledSend', + risk: 'write', + description: 'Update a scheduled send status', + method: 'PATCH', + path: 'user/scheduled_sends/{batch_id}', + pathKeys: ['batch_id'], + body: 'omit', + inKind: 'scheduledSendUpdate', + outKind: 'empty', + }, + { + key: 'mailDeleteScheduledSend', + nested: 'mail.deleteScheduledSend', + risk: 'write', + description: 'Delete a cancellation/pause for a scheduled send', + method: 'DELETE', + path: 'user/scheduled_sends/{batch_id}', + pathKeys: ['batch_id'], + inKind: 'batchId', + outKind: 'empty', + }, + { + key: 'contactsAddOrUpdate', + nested: 'contacts.addOrUpdate', + risk: 'write', + description: 'Add or update contacts in SendGrid Marketing', + method: 'PUT', + path: 'marketing/contacts', + body: true, + inKind: 'contactsAddOrUpdate', + outKind: 'job', + }, + { + key: 'contactsGet', + nested: 'contacts.get', + risk: 'read', + description: 'Get a marketing contact by ID', + method: 'GET', + path: 'marketing/contacts/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'obj', + }, + { + key: 'contactsSearch', + nested: 'contacts.search', + risk: 'read', + description: 'Search marketing contacts with SGQL', + method: 'POST', + path: 'marketing/contacts/search', + body: true, + inKind: 'contactSearch', + outKind: 'obj', + }, + { + key: 'contactsSearchEmails', + nested: 'contacts.searchEmails', + risk: 'read', + description: 'Search marketing contacts by email', + method: 'POST', + path: 'marketing/contacts/search/emails', + body: true, + inKind: 'contactSearchEmails', + outKind: 'obj', + }, + { + key: 'contactsRemove', + nested: 'contacts.remove', + risk: 'write', + description: 'Delete marketing contacts by ID', + method: 'DELETE', + path: 'marketing/contacts', + queryKeys: ['ids'], + inKind: 'idsQuery', + outKind: 'job', + }, + { + key: 'contactsGetCount', + nested: 'contacts.getCount', + risk: 'read', + description: 'Get marketing contact count', + method: 'GET', + path: 'marketing/contacts/count', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'contactsGetSample', + nested: 'contacts.getSample', + risk: 'read', + description: 'Get a sample of marketing contacts', + method: 'GET', + path: 'marketing/contacts', + queryKeys: ['page_size', 'page_token'], + inKind: 'page', + outKind: 'obj', + }, + { + key: 'contactsImport', + nested: 'contacts.import', + risk: 'write', + description: 'Create a marketing contacts import job', + method: 'PUT', + path: 'marketing/contacts/imports', + body: true, + inKind: 'contactImport', + outKind: 'obj', + }, + { + key: 'contactsImportStatus', + nested: 'contacts.importStatus', + risk: 'read', + description: 'Get a marketing contacts import job', + method: 'GET', + path: 'marketing/contacts/imports/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'obj', + }, + { + key: 'contactsExport', + nested: 'contacts.export', + risk: 'write', + description: 'Create a marketing contacts export job', + method: 'POST', + path: 'marketing/contacts/exports', + body: true, + inKind: 'contactExport', + outKind: 'obj', + }, + { + key: 'contactsExportStatus', + nested: 'contacts.exportStatus', + risk: 'read', + description: 'Get a marketing contacts export job', + method: 'GET', + path: 'marketing/contacts/exports/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'obj', + }, + { + key: 'contactsListExports', + nested: 'contacts.listExports', + risk: 'read', + description: 'List marketing contacts export jobs', + method: 'GET', + path: 'marketing/contacts/exports', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'listsGetAll', + nested: 'lists.getAll', + risk: 'read', + description: 'Retrieve all marketing contact lists', + method: 'GET', + path: 'marketing/lists', + queryKeys: ['page_size', 'page_token'], + inKind: 'page', + outKind: 'listsGetAll', + }, + { + key: 'listsCreate', + nested: 'lists.create', + risk: 'write', + description: 'Create a new marketing contact list', + method: 'POST', + path: 'marketing/lists', + body: true, + upsertList: true, + inKind: 'listsCreate', + outKind: 'list', + }, + { + key: 'listsGet', + nested: 'lists.get', + risk: 'read', + description: 'Get a marketing list by ID', + method: 'GET', + path: 'marketing/lists/{id}', + pathKeys: ['id'], + queryKeys: ['contact_sample'], + inKind: 'idSample', + outKind: 'obj', + }, + { + key: 'listsUpdate', + nested: 'lists.update', + risk: 'write', + description: 'Update a marketing list', + method: 'PATCH', + path: 'marketing/lists/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'idName', + outKind: 'list', + }, + { + key: 'listsRemove', + nested: 'lists.remove', + risk: 'write', + description: 'Delete a marketing list', + method: 'DELETE', + path: 'marketing/lists/{id}', + pathKeys: ['id'], + queryKeys: ['delete_contacts'], + inKind: 'idDeleteContacts', + outKind: 'empty', + }, + { + key: 'listsGetContactCount', + nested: 'lists.getContactCount', + risk: 'read', + description: 'Get contact count for a marketing list', + method: 'GET', + path: 'marketing/lists/{id}/contacts/count', + pathKeys: ['id'], + inKind: 'id', + outKind: 'obj', + }, + { + key: 'listsRemoveContacts', + nested: 'lists.removeContacts', + risk: 'write', + description: 'Remove contacts from a marketing list', + method: 'DELETE', + path: 'marketing/lists/{id}/contacts', + pathKeys: ['id'], + queryKeys: ['contact_ids'], + inKind: 'idContactIds', + outKind: 'job', + }, + { + key: 'segmentsCreate', + nested: 'segments.create', + risk: 'write', + description: 'Create a Marketing Campaigns segment 2.0', + method: 'POST', + path: 'marketing/segments/2.0', + body: true, + inKind: 'segment', + outKind: 'obj', + }, + { + key: 'segmentsGetAll', + nested: 'segments.getAll', + risk: 'read', + description: 'Get all Marketing Campaigns segments 2.0', + method: 'GET', + path: 'marketing/segments/2.0', + queryKeys: ['parent_list_ids', 'no_parent_list_id'], + inKind: 'segmentsList', + outKind: 'obj', + }, + { + key: 'segmentsGet', + nested: 'segments.get', + risk: 'read', + description: 'Get a Marketing Campaigns segment 2.0', + method: 'GET', + path: 'marketing/segments/2.0/{id}', + pathKeys: ['id'], + queryKeys: ['contacts_sample'], + inKind: 'idContactsSample', + outKind: 'obj', + }, + { + key: 'segmentsUpdate', + nested: 'segments.update', + risk: 'write', + description: 'Update a Marketing Campaigns segment 2.0', + method: 'PATCH', + path: 'marketing/segments/2.0/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'segmentUpdate', + outKind: 'obj', + }, + { + key: 'segmentsRemove', + nested: 'segments.remove', + risk: 'write', + description: 'Delete a Marketing Campaigns segment 2.0', + method: 'DELETE', + path: 'marketing/segments/2.0/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'empty', + }, + { + key: 'segmentsRefresh', + nested: 'segments.refresh', + risk: 'write', + description: 'Manually refresh a Marketing Campaigns segment 2.0', + method: 'POST', + path: 'marketing/segments/2.0/refresh/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'segmentRefresh', + outKind: 'obj', + }, + { + key: 'fieldsGetAll', + nested: 'fields.getAll', + risk: 'read', + description: 'Get all marketing field definitions', + method: 'GET', + path: 'marketing/field_definitions', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'fieldsCreate', + nested: 'fields.create', + risk: 'write', + description: 'Create a custom field definition', + method: 'POST', + path: 'marketing/field_definitions', + body: true, + inKind: 'fieldCreate', + outKind: 'obj', + }, + { + key: 'fieldsUpdate', + nested: 'fields.update', + risk: 'write', + description: 'Update a custom field definition', + method: 'PATCH', + path: 'marketing/field_definitions/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'idName', + outKind: 'obj', + }, + { + key: 'fieldsRemove', + nested: 'fields.remove', + risk: 'write', + description: 'Delete a custom field definition', + method: 'DELETE', + path: 'marketing/field_definitions/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'empty', + }, + { + key: 'sendersGetAll', + nested: 'senders.getAll', + risk: 'read', + description: 'Retrieve verified senders', + method: 'GET', + path: 'verified_senders', + queryKeys: ['limit', 'lastSeenID', 'id'], + inKind: 'sendersGetAll', + outKind: 'sendersGetAll', + }, + { + key: 'sendersCreate', + nested: 'senders.create', + risk: 'write', + description: 'Create a verified sender', + method: 'POST', + path: 'verified_senders', + body: true, + inKind: 'verifiedSender', + outKind: 'obj', + }, + { + key: 'sendersUpdate', + nested: 'senders.update', + risk: 'write', + description: 'Update a verified sender', + method: 'PATCH', + path: 'verified_senders/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'verifiedSenderUpdate', + outKind: 'obj', + }, + { + key: 'sendersRemove', + nested: 'senders.remove', + risk: 'write', + description: 'Delete a verified sender', + method: 'DELETE', + path: 'verified_senders/{id}', + pathKeys: ['id'], + inKind: 'idNum', + outKind: 'empty', + }, + { + key: 'sendersResend', + nested: 'senders.resend', + risk: 'write', + description: 'Resend verified sender verification', + method: 'POST', + path: 'verified_senders/resend/{id}', + pathKeys: ['id'], + inKind: 'idNum', + outKind: 'empty', + }, + { + key: 'sendersListIdentities', + nested: 'senders.listIdentities', + risk: 'read', + description: 'Get Marketing Campaigns sender identities', + method: 'GET', + path: 'senders', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'sendersCreateIdentity', + nested: 'senders.createIdentity', + risk: 'write', + description: 'Create a Marketing Campaigns sender identity', + method: 'POST', + path: 'senders', + body: true, + inKind: 'senderIdentity', + outKind: 'obj', + }, + { + key: 'sendersGetIdentity', + nested: 'senders.getIdentity', + risk: 'read', + description: 'Get a Marketing Campaigns sender identity', + method: 'GET', + path: 'senders/{id}', + pathKeys: ['id'], + inKind: 'idNum', + outKind: 'obj', + }, + { + key: 'templatesCreate', + nested: 'templates.create', + risk: 'write', + description: 'Create a transactional template', + method: 'POST', + path: 'templates', + body: true, + inKind: 'templateCreate', + outKind: 'obj', + }, + { + key: 'templatesGetAll', + nested: 'templates.getAll', + risk: 'read', + description: 'Get all transactional templates', + method: 'GET', + path: 'templates', + queryKeys: ['generations', 'page_size'], + inKind: 'templatesList', + outKind: 'obj', + }, + { + key: 'templatesGet', + nested: 'templates.get', + risk: 'read', + description: 'Get a transactional template', + method: 'GET', + path: 'templates/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'obj', + }, + { + key: 'templatesUpdate', + nested: 'templates.update', + risk: 'write', + description: 'Update a transactional template', + method: 'PATCH', + path: 'templates/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'idName', + outKind: 'obj', + }, + { + key: 'templatesRemove', + nested: 'templates.remove', + risk: 'write', + description: 'Delete a transactional template', + method: 'DELETE', + path: 'templates/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'empty', + }, + { + key: 'templatesCreateVersion', + nested: 'templates.createVersion', + risk: 'write', + description: 'Create a transactional template version', + method: 'POST', + path: 'templates/{id}/versions', + pathKeys: ['id'], + body: 'omit', + inKind: 'templateVersion', + outKind: 'obj', + }, + { + key: 'templatesGetVersion', + nested: 'templates.getVersion', + risk: 'read', + description: 'Get a transactional template version', + method: 'GET', + path: 'templates/{id}/versions/{version_id}', + pathKeys: ['id', 'version_id'], + inKind: 'templateVersionId', + outKind: 'obj', + }, + { + key: 'templatesUpdateVersion', + nested: 'templates.updateVersion', + risk: 'write', + description: 'Update a transactional template version', + method: 'PATCH', + path: 'templates/{id}/versions/{version_id}', + pathKeys: ['id', 'version_id'], + body: 'omit', + inKind: 'templateVersionUpdate', + outKind: 'obj', + }, + { + key: 'templatesRemoveVersion', + nested: 'templates.removeVersion', + risk: 'write', + description: 'Delete a transactional template version', + method: 'DELETE', + path: 'templates/{id}/versions/{version_id}', + pathKeys: ['id', 'version_id'], + inKind: 'templateVersionId', + outKind: 'empty', + }, + { + key: 'templatesActivateVersion', + nested: 'templates.activateVersion', + risk: 'write', + description: 'Activate a transactional template version', + method: 'POST', + path: 'templates/{id}/versions/{version_id}/activate', + pathKeys: ['id', 'version_id'], + inKind: 'templateVersionId', + outKind: 'obj', + }, + { + key: 'suppressionsGetBounces', + nested: 'suppressions.getBounces', + risk: 'read', + description: 'Retrieve email bounce suppressions', + method: 'GET', + path: 'suppression/bounces', + queryKeys: ['start_time', 'end_time', 'limit', 'offset'], + wrapArray: 'bounces', + inKind: 'suppressionList', + outKind: 'bounces', + }, + { + key: 'suppressionsGetBounce', + nested: 'suppressions.getBounce', + risk: 'read', + description: 'Retrieve a bounce by email', + method: 'GET', + path: 'suppression/bounces/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'arr', + }, + { + key: 'suppressionsDeleteBounce', + nested: 'suppressions.deleteBounce', + risk: 'write', + description: 'Delete a bounce by email', + method: 'DELETE', + path: 'suppression/bounces/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'empty', + }, + { + key: 'suppressionsDeleteBounces', + nested: 'suppressions.deleteBounces', + risk: 'write', + description: 'Delete bounce suppressions', + method: 'DELETE', + path: 'suppression/bounces', + body: true, + inKind: 'deleteSuppressions', + outKind: 'empty', + }, + { + key: 'suppressionsGetBlocks', + nested: 'suppressions.getBlocks', + risk: 'read', + description: 'Retrieve blocked emails', + method: 'GET', + path: 'suppression/blocks', + queryKeys: ['start_time', 'end_time', 'limit', 'offset'], + wrapArray: 'results', + inKind: 'suppressionList', + outKind: 'results', + }, + { + key: 'suppressionsGetBlock', + nested: 'suppressions.getBlock', + risk: 'read', + description: 'Retrieve a block by email', + method: 'GET', + path: 'suppression/blocks/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'arr', + }, + { + key: 'suppressionsDeleteBlock', + nested: 'suppressions.deleteBlock', + risk: 'write', + description: 'Delete a block by email', + method: 'DELETE', + path: 'suppression/blocks/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'empty', + }, + { + key: 'suppressionsDeleteBlocks', + nested: 'suppressions.deleteBlocks', + risk: 'write', + description: 'Delete blocked emails', + method: 'DELETE', + path: 'suppression/blocks', + body: true, + inKind: 'deleteSuppressions', + outKind: 'empty', + }, + { + key: 'suppressionsGetSpamReports', + nested: 'suppressions.getSpamReports', + risk: 'read', + description: 'Retrieve spam reports', + method: 'GET', + path: 'suppression/spam_reports', + queryKeys: ['start_time', 'end_time', 'limit', 'offset'], + wrapArray: 'results', + inKind: 'suppressionList', + outKind: 'results', + }, + { + key: 'suppressionsGetSpamReport', + nested: 'suppressions.getSpamReport', + risk: 'read', + description: 'Retrieve a spam report by email', + method: 'GET', + path: 'suppression/spam_reports/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'arr', + }, + { + key: 'suppressionsDeleteSpamReport', + nested: 'suppressions.deleteSpamReport', + risk: 'write', + description: 'Delete a spam report by email', + method: 'DELETE', + path: 'suppression/spam_reports/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'empty', + }, + { + key: 'suppressionsDeleteSpamReports', + nested: 'suppressions.deleteSpamReports', + risk: 'write', + description: 'Delete spam reports', + method: 'DELETE', + path: 'suppression/spam_reports', + body: true, + inKind: 'deleteSuppressions', + outKind: 'empty', + }, + { + key: 'suppressionsGetInvalidEmails', + nested: 'suppressions.getInvalidEmails', + risk: 'read', + description: 'Retrieve invalid emails', + method: 'GET', + path: 'suppression/invalid_emails', + queryKeys: ['start_time', 'end_time', 'limit', 'offset'], + wrapArray: 'results', + inKind: 'suppressionList', + outKind: 'results', + }, + { + key: 'suppressionsGetInvalidEmail', + nested: 'suppressions.getInvalidEmail', + risk: 'read', + description: 'Retrieve an invalid email', + method: 'GET', + path: 'suppression/invalid_emails/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'arr', + }, + { + key: 'suppressionsDeleteInvalidEmail', + nested: 'suppressions.deleteInvalidEmail', + risk: 'write', + description: 'Delete an invalid email', + method: 'DELETE', + path: 'suppression/invalid_emails/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'empty', + }, + { + key: 'suppressionsDeleteInvalidEmails', + nested: 'suppressions.deleteInvalidEmails', + risk: 'write', + description: 'Delete invalid emails', + method: 'DELETE', + path: 'suppression/invalid_emails', + body: true, + inKind: 'deleteSuppressions', + outKind: 'empty', + }, + { + key: 'suppressionsGetGlobalUnsubscribes', + nested: 'suppressions.getGlobalUnsubscribes', + risk: 'read', + description: 'Retrieve global unsubscribes', + method: 'GET', + path: 'suppression/unsubscribes', + queryKeys: ['start_time', 'end_time', 'limit', 'offset'], + wrapArray: 'results', + inKind: 'suppressionList', + outKind: 'results', + }, + { + key: 'suppressionsAddGlobalUnsubscribes', + nested: 'suppressions.addGlobalUnsubscribes', + risk: 'write', + description: 'Add emails to the global unsubscribe list', + method: 'POST', + path: 'asm/suppressions/global', + body: true, + inKind: 'recipientEmails', + outKind: 'obj', + }, + { + key: 'suppressionsGetGlobalUnsubscribe', + nested: 'suppressions.getGlobalUnsubscribe', + risk: 'read', + description: 'Retrieve a global unsubscribe by email', + method: 'GET', + path: 'asm/suppressions/global/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'obj', + }, + { + key: 'suppressionsDeleteGlobalUnsubscribe', + nested: 'suppressions.deleteGlobalUnsubscribe', + risk: 'write', + description: 'Delete a global unsubscribe by email', + method: 'DELETE', + path: 'asm/suppressions/global/{email}', + pathKeys: ['email'], + inKind: 'email', + outKind: 'empty', + }, + { + key: 'asmGetGroups', + nested: 'asm.getGroups', + risk: 'read', + description: 'Retrieve unsubscribe groups', + method: 'GET', + path: 'asm/groups', + queryKeys: ['id'], + inKind: 'asmIdQuery', + outKind: 'arr', + }, + { + key: 'asmCreateGroup', + nested: 'asm.createGroup', + risk: 'write', + description: 'Create an unsubscribe group', + method: 'POST', + path: 'asm/groups', + body: true, + inKind: 'asmGroup', + outKind: 'obj', + }, + { + key: 'asmGetGroup', + nested: 'asm.getGroup', + risk: 'read', + description: 'Retrieve an unsubscribe group', + method: 'GET', + path: 'asm/groups/{id}', + pathKeys: ['id'], + inKind: 'idNum', + outKind: 'obj', + }, + { + key: 'asmUpdateGroup', + nested: 'asm.updateGroup', + risk: 'write', + description: 'Update an unsubscribe group', + method: 'PATCH', + path: 'asm/groups/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'asmGroupUpdate', + outKind: 'obj', + }, + { + key: 'asmDeleteGroup', + nested: 'asm.deleteGroup', + risk: 'write', + description: 'Delete an unsubscribe group', + method: 'DELETE', + path: 'asm/groups/{id}', + pathKeys: ['id'], + inKind: 'idNum', + outKind: 'empty', + }, + { + key: 'asmAddGroupSuppressions', + nested: 'asm.addGroupSuppressions', + risk: 'write', + description: 'Add suppressions to an unsubscribe group', + method: 'POST', + path: 'asm/groups/{id}/suppressions', + pathKeys: ['id'], + body: 'omit', + inKind: 'asmGroupEmails', + outKind: 'obj', + }, + { + key: 'asmGetGroupSuppressions', + nested: 'asm.getGroupSuppressions', + risk: 'read', + description: 'Retrieve suppressions for an unsubscribe group', + method: 'GET', + path: 'asm/groups/{id}/suppressions', + pathKeys: ['id'], + inKind: 'idNum', + outKind: 'arr', + }, + { + key: 'asmDeleteGroupSuppression', + nested: 'asm.deleteGroupSuppression', + risk: 'write', + description: 'Delete a suppression from an unsubscribe group', + method: 'DELETE', + path: 'asm/groups/{id}/suppressions/{email}', + pathKeys: ['id', 'email'], + inKind: 'idNumEmail', + outKind: 'empty', + }, + { + key: 'statsGetGlobal', + nested: 'stats.getGlobal', + risk: 'read', + description: 'Retrieve global email statistics', + method: 'GET', + path: 'stats', + queryKeys: ['start_date', 'end_date', 'aggregated_by', 'limit', 'offset'], + inKind: 'stats', + outKind: 'arr', + }, + { + key: 'statsGetCategory', + nested: 'stats.getCategory', + risk: 'read', + description: 'Retrieve category statistics', + method: 'GET', + path: 'categories/stats', + queryKeys: [ + 'start_date', + 'end_date', + 'aggregated_by', + 'categories', + 'limit', + 'offset', + ], + inKind: 'statsCategories', + outKind: 'arr', + }, + { + key: 'statsGetMailboxProvider', + nested: 'stats.getMailboxProvider', + risk: 'read', + description: 'Retrieve mailbox provider statistics', + method: 'GET', + path: 'mailbox_providers/stats', + queryKeys: [ + 'start_date', + 'end_date', + 'aggregated_by', + 'mailbox_providers', + 'limit', + 'offset', + ], + inKind: 'stats', + outKind: 'arr', + }, + { + key: 'statsGetGeo', + nested: 'stats.getGeo', + risk: 'read', + description: 'Retrieve geographic statistics', + method: 'GET', + path: 'geo/stats', + queryKeys: [ + 'start_date', + 'end_date', + 'aggregated_by', + 'country', + 'limit', + 'offset', + ], + inKind: 'stats', + outKind: 'arr', + }, + { + key: 'statsGetDevice', + nested: 'stats.getDevice', + risk: 'read', + description: 'Retrieve device statistics', + method: 'GET', + path: 'devices/stats', + queryKeys: ['start_date', 'end_date', 'aggregated_by', 'limit', 'offset'], + inKind: 'stats', + outKind: 'arr', + }, + { + key: 'statsGetClient', + nested: 'stats.getClient', + risk: 'read', + description: 'Retrieve email client statistics', + method: 'GET', + path: 'clients/stats', + queryKeys: ['start_date', 'end_date', 'aggregated_by'], + inKind: 'stats', + outKind: 'arr', + }, + { + key: 'userGetProfile', + nested: 'user.getProfile', + risk: 'read', + description: 'Retrieve the user profile', + method: 'GET', + path: 'user/profile', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'userGetAccount', + nested: 'user.getAccount', + risk: 'read', + description: 'Retrieve the user account', + method: 'GET', + path: 'user/account', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'userGetCredits', + nested: 'user.getCredits', + risk: 'read', + description: 'Retrieve remaining email credits', + method: 'GET', + path: 'user/credits', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'userGetUsername', + nested: 'user.getUsername', + risk: 'read', + description: 'Retrieve the account username', + method: 'GET', + path: 'user/username', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'userGetEmail', + nested: 'user.getEmail', + risk: 'read', + description: 'Retrieve the account email address', + method: 'GET', + path: 'user/email', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'userGetScopes', + nested: 'user.getScopes', + risk: 'read', + description: 'Retrieve API key scopes for the current key', + method: 'GET', + path: 'scopes', + inKind: 'empty', + outKind: 'obj', + }, + { + key: 'apiKeysCreate', + nested: 'apiKeys.create', + risk: 'write', + description: 'Create an API key', + method: 'POST', + path: 'api_keys', + body: true, + inKind: 'apiKeyCreate', + outKind: 'obj', + }, + { + key: 'apiKeysGetAll', + nested: 'apiKeys.getAll', + risk: 'read', + description: 'Retrieve all API keys', + method: 'GET', + path: 'api_keys', + queryKeys: ['limit'], + inKind: 'limit', + outKind: 'obj', + }, + { + key: 'apiKeysGet', + nested: 'apiKeys.get', + risk: 'read', + description: 'Retrieve an API key', + method: 'GET', + path: 'api_keys/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'obj', + }, + { + key: 'apiKeysUpdate', + nested: 'apiKeys.update', + risk: 'write', + description: 'Update an API key name or scopes', + method: 'PUT', + path: 'api_keys/{id}', + pathKeys: ['id'], + body: 'omit', + inKind: 'apiKeyUpdate', + outKind: 'obj', + }, + { + key: 'apiKeysRemove', + nested: 'apiKeys.remove', + risk: 'write', + description: 'Delete an API key', + method: 'DELETE', + path: 'api_keys/{id}', + pathKeys: ['id'], + inKind: 'id', + outKind: 'empty', + }, +]; + +export const SENDGRID_OPS_BY_KEY = Object.fromEntries( + SENDGRID_OPS.map((op) => [op.key, op]), +) as Record; diff --git a/packages/sendgrid/endpoints/handlers.ts b/packages/sendgrid/endpoints/handlers.ts new file mode 100644 index 000000000..e12901c9d --- /dev/null +++ b/packages/sendgrid/endpoints/handlers.ts @@ -0,0 +1,152 @@ +import type { SendGridEndpoints } from '..'; +import { runCatalogOp } from './bind'; +import { SENDGRID_OPS_BY_KEY } from './catalog'; + +function bind(key: K): SendGridEndpoints[K] { + const spec = SENDGRID_OPS_BY_KEY[key as string]; + if (!spec) { + throw new Error(`Unknown SendGrid op: ${String(key)}`); + } + return (async (ctx, input) => + runCatalogOp( + ctx, + spec, + input as Record, + )) as SendGridEndpoints[K]; +} + +export const mail = { + send: bind('mailSend'), + createBatchId: bind('mailCreateBatchId'), + validateBatchId: bind('mailValidateBatchId'), + cancelScheduledSend: bind('mailCancelScheduledSend'), + listScheduledSends: bind('mailListScheduledSends'), + getScheduledSend: bind('mailGetScheduledSend'), + updateScheduledSend: bind('mailUpdateScheduledSend'), + deleteScheduledSend: bind('mailDeleteScheduledSend'), +}; + +export const contacts = { + addOrUpdate: bind('contactsAddOrUpdate'), + get: bind('contactsGet'), + search: bind('contactsSearch'), + searchEmails: bind('contactsSearchEmails'), + remove: bind('contactsRemove'), + getCount: bind('contactsGetCount'), + getSample: bind('contactsGetSample'), + import: bind('contactsImport'), + importStatus: bind('contactsImportStatus'), + export: bind('contactsExport'), + exportStatus: bind('contactsExportStatus'), + listExports: bind('contactsListExports'), +}; + +export const lists = { + getAll: bind('listsGetAll'), + create: bind('listsCreate'), + get: bind('listsGet'), + update: bind('listsUpdate'), + remove: bind('listsRemove'), + getContactCount: bind('listsGetContactCount'), + removeContacts: bind('listsRemoveContacts'), +}; + +export const segments = { + create: bind('segmentsCreate'), + getAll: bind('segmentsGetAll'), + get: bind('segmentsGet'), + update: bind('segmentsUpdate'), + remove: bind('segmentsRemove'), + refresh: bind('segmentsRefresh'), +}; + +export const fields = { + getAll: bind('fieldsGetAll'), + create: bind('fieldsCreate'), + update: bind('fieldsUpdate'), + remove: bind('fieldsRemove'), +}; + +export const senders = { + getAll: bind('sendersGetAll'), + create: bind('sendersCreate'), + update: bind('sendersUpdate'), + remove: bind('sendersRemove'), + resend: bind('sendersResend'), + listIdentities: bind('sendersListIdentities'), + createIdentity: bind('sendersCreateIdentity'), + getIdentity: bind('sendersGetIdentity'), +}; + +export const templates = { + create: bind('templatesCreate'), + getAll: bind('templatesGetAll'), + get: bind('templatesGet'), + update: bind('templatesUpdate'), + remove: bind('templatesRemove'), + createVersion: bind('templatesCreateVersion'), + getVersion: bind('templatesGetVersion'), + updateVersion: bind('templatesUpdateVersion'), + removeVersion: bind('templatesRemoveVersion'), + activateVersion: bind('templatesActivateVersion'), +}; + +export const suppressions = { + getBounces: bind('suppressionsGetBounces'), + getBounce: bind('suppressionsGetBounce'), + deleteBounce: bind('suppressionsDeleteBounce'), + deleteBounces: bind('suppressionsDeleteBounces'), + getBlocks: bind('suppressionsGetBlocks'), + getBlock: bind('suppressionsGetBlock'), + deleteBlock: bind('suppressionsDeleteBlock'), + deleteBlocks: bind('suppressionsDeleteBlocks'), + getSpamReports: bind('suppressionsGetSpamReports'), + getSpamReport: bind('suppressionsGetSpamReport'), + deleteSpamReport: bind('suppressionsDeleteSpamReport'), + deleteSpamReports: bind('suppressionsDeleteSpamReports'), + getInvalidEmails: bind('suppressionsGetInvalidEmails'), + getInvalidEmail: bind('suppressionsGetInvalidEmail'), + deleteInvalidEmail: bind('suppressionsDeleteInvalidEmail'), + deleteInvalidEmails: bind('suppressionsDeleteInvalidEmails'), + getGlobalUnsubscribes: bind('suppressionsGetGlobalUnsubscribes'), + addGlobalUnsubscribes: bind('suppressionsAddGlobalUnsubscribes'), + getGlobalUnsubscribe: bind('suppressionsGetGlobalUnsubscribe'), + deleteGlobalUnsubscribe: bind('suppressionsDeleteGlobalUnsubscribe'), +}; + +export const asm = { + getGroups: bind('asmGetGroups'), + createGroup: bind('asmCreateGroup'), + getGroup: bind('asmGetGroup'), + updateGroup: bind('asmUpdateGroup'), + deleteGroup: bind('asmDeleteGroup'), + addGroupSuppressions: bind('asmAddGroupSuppressions'), + getGroupSuppressions: bind('asmGetGroupSuppressions'), + deleteGroupSuppression: bind('asmDeleteGroupSuppression'), +}; + +export const stats = { + getGlobal: bind('statsGetGlobal'), + getCategory: bind('statsGetCategory'), + getMailboxProvider: bind('statsGetMailboxProvider'), + getGeo: bind('statsGetGeo'), + getDevice: bind('statsGetDevice'), + getClient: bind('statsGetClient'), +}; + +export const user = { + getProfile: bind('userGetProfile'), + getAccount: bind('userGetAccount'), + getCredits: bind('userGetCredits'), + getUsername: bind('userGetUsername'), + getEmail: bind('userGetEmail'), + getScopes: bind('userGetScopes'), +}; + +export const apiKeys = { + create: bind('apiKeysCreate'), + getAll: bind('apiKeysGetAll'), + get: bind('apiKeysGet'), + update: bind('apiKeysUpdate'), + remove: bind('apiKeysRemove'), +}; diff --git a/packages/sendgrid/endpoints/index.ts b/packages/sendgrid/endpoints/index.ts new file mode 100644 index 000000000..d428432b8 --- /dev/null +++ b/packages/sendgrid/endpoints/index.ts @@ -0,0 +1,14 @@ +export { + apiKeys, + asm, + contacts, + fields, + lists, + mail, + segments, + senders, + stats, + suppressions, + templates, + user, +} from './handlers'; diff --git a/packages/sendgrid/endpoints/run.ts b/packages/sendgrid/endpoints/run.ts new file mode 100644 index 000000000..eb7deb706 --- /dev/null +++ b/packages/sendgrid/endpoints/run.ts @@ -0,0 +1,17 @@ +export function q( + input: Record, + keys: string[], +): Record { + const query: Record = {}; + for (const key of keys) { + const value = input[key]; + if (value !== undefined) { + query[key] = value as string | number | boolean; + } + } + return query; +} + +export function enc(value: string): string { + return encodeURIComponent(value); +} diff --git a/packages/sendgrid/endpoints/types.ts b/packages/sendgrid/endpoints/types.ts new file mode 100644 index 000000000..318e63595 --- /dev/null +++ b/packages/sendgrid/endpoints/types.ts @@ -0,0 +1,554 @@ +import { z } from 'zod'; +import { + SendGridBounce, + SendGridContact, + SendGridList, + SendGridVerifiedSender, +} from '../schema/database'; + +const EmailRecipientSchema = z.object({ + email: z.string().email(), + name: z.string().optional(), +}); + +const PersonalizationSchema = z.object({ + to: z.array(EmailRecipientSchema).min(1), + cc: z.array(EmailRecipientSchema).optional(), + bcc: z.array(EmailRecipientSchema).optional(), + subject: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + substitutions: z.record(z.string(), z.string()).optional(), + dynamic_template_data: z.record(z.string(), z.unknown()).optional(), + custom_args: z.record(z.string(), z.string()).optional(), + send_at: z.number().int().optional(), +}); + +const ContentSchema = z.object({ + type: z.string().min(1), + value: z.string().min(1), +}); + +/** Official: POST /v3/mail/send */ +const MailSendInputSchema = z + .object({ + personalizations: z.array(PersonalizationSchema).min(1), + from: EmailRecipientSchema, + subject: z.string().optional(), + content: z.array(ContentSchema).optional(), + reply_to: EmailRecipientSchema.optional(), + template_id: z.string().optional(), + categories: z.array(z.string()).optional(), + send_at: z.number().int().optional(), + batch_id: z.string().optional(), + ip_pool_name: z.string().optional(), + asm: z + .object({ + group_id: z.number().int(), + groups_to_display: z.array(z.number().int()).optional(), + }) + .optional(), + }) + .superRefine((data, ctx) => { + const templated = + typeof data.template_id === 'string' && data.template_id.length > 0; + if (templated) { + return; + } + if (!data.content || data.content.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'content is required unless template_id is set', + }); + } + const topSubject = + typeof data.subject === 'string' && data.subject.length > 0; + if ( + !topSubject && + data.personalizations.some( + (p) => !(typeof p.subject === 'string' && p.subject.length > 0), + ) + ) { + ctx.addIssue({ + code: 'custom', + path: ['subject'], + message: + 'subject is required at the top level or on every personalization unless template_id is set', + }); + } + }); + +const EmptySchema = z.object({}); +const ObjSchema = z.object({}).catchall(z.unknown()); +const ArrSchema = z.array(ObjSchema); +const JobSchema = z.object({ job_id: z.string() }).catchall(z.unknown()); +const IdSchema = z.object({ id: z.string() }); +const IdNumSchema = z.object({ id: z.union([z.string(), z.number()]) }); +const EmailSchema = z.object({ email: z.string().email() }); +const BatchIdSchema = z.object({ batch_id: z.string() }); +const PageSchema = z.object({ + page_size: z.number().int().positive().max(1000).optional(), + page_token: z.string().optional(), +}); +const SuppressionListSchema = z.object({ + start_time: z.number().int().optional(), + end_time: z.number().int().optional(), + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), +}); +const StatsSchema = z.object({ + start_date: z.string(), + end_date: z.string().optional(), + aggregated_by: z.string().optional(), + limit: z.number().int().optional(), + offset: z.number().int().optional(), + mailbox_providers: z.string().optional(), + country: z.string().optional(), +}); + +const inputKinds = { + empty: EmptySchema, + mailSend: MailSendInputSchema, + batchId: BatchIdSchema, + scheduledSend: z.object({ + batch_id: z.string(), + status: z.enum(['pause', 'cancel']), + }), + scheduledSendUpdate: z.object({ + batch_id: z.string(), + status: z.enum(['pause', 'cancel']), + }), + contactsAddOrUpdate: z.object({ + list_ids: z.array(z.string()).optional(), + contacts: z + .array( + SendGridContact.refine( + (contact) => + Boolean( + contact.email || + contact.phone_number_id || + contact.external_id || + contact.anonymous_id, + ), + { + message: + 'at least one of email, phone_number_id, external_id, or anonymous_id is required', + }, + ), + ) + .min(1), + }), + id: IdSchema, + contactSearch: z.object({ query: z.string() }).catchall(z.unknown()), + contactSearchEmails: z.object({ emails: z.array(z.string().email()).min(1) }), + idsQuery: z.object({ ids: z.string() }), + page: PageSchema, + contactImport: z.object({ + file_type: z.literal('csv'), + field_mappings: z.array(z.string().nullable()).min(1), + list_ids: z.array(z.string()).optional(), + }), + contactExport: z + .object({ + list_ids: z.array(z.string()).optional(), + segment_ids: z.array(z.string()).optional(), + file_type: z.enum(['csv', 'json']).optional(), + max_file_size: z.number().int().optional(), + }) + .catchall(z.unknown()), + listsCreate: z.object({ name: z.string().min(1) }), + idSample: z.object({ + id: z.string(), + contact_sample: z.boolean().optional(), + }), + idName: z.object({ id: z.string(), name: z.string().min(1) }), + idDeleteContacts: z.object({ + id: z.string(), + delete_contacts: z.boolean().optional(), + }), + idContactIds: z.object({ id: z.string(), contact_ids: z.string() }), + segment: z + .object({ + name: z.string(), + query_dsl: z.string().optional(), + parent_list_id: z.string().optional(), + }) + .catchall(z.unknown()), + segmentsList: z.object({ + parent_list_ids: z.string().optional(), + no_parent_list_id: z.boolean().optional(), + }), + idContactsSample: z.object({ + id: z.string(), + contacts_sample: z.boolean().optional(), + }), + segmentUpdate: z + .object({ + id: z.string(), + name: z.string().optional(), + query_dsl: z.string().optional(), + }) + .catchall(z.unknown()), + segmentRefresh: z.object({ + id: z.string(), + user_time_zone: z.string(), + }), + fieldCreate: z.object({ + name: z.string(), + field_type: z.string(), + }), + sendersGetAll: z.object({ + limit: z.number().int().positive().optional(), + lastSeenID: z.number().int().optional(), + id: z.number().int().optional(), + }), + verifiedSender: z + .object({ + nickname: z.string(), + from_email: z.string().email(), + from_name: z.string().optional(), + reply_to: z.string().email().optional(), + address: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + zip: z.string().optional(), + country: z.string().optional(), + }) + .catchall(z.unknown()), + verifiedSenderUpdate: z + .object({ + id: z.union([z.string(), z.number()]), + nickname: z.string().optional(), + from_email: z.string().email().optional(), + }) + .catchall(z.unknown()), + idNum: IdNumSchema, + senderIdentity: z.object({}).catchall(z.unknown()), + templateCreate: z.object({ + name: z.string(), + generation: z.string().optional(), + }), + templatesList: z.object({ + generations: z.string().optional(), + page_size: z.number().int().optional(), + }), + templateVersion: z + .object({ + id: z.string(), + name: z.string().optional(), + subject: z.string().optional(), + html_content: z.string().optional(), + plain_content: z.string().optional(), + active: z.number().int().optional(), + }) + .catchall(z.unknown()), + templateVersionId: z.object({ id: z.string(), version_id: z.string() }), + templateVersionUpdate: z + .object({ + id: z.string(), + version_id: z.string(), + name: z.string().optional(), + subject: z.string().optional(), + html_content: z.string().optional(), + plain_content: z.string().optional(), + }) + .catchall(z.unknown()), + suppressionList: SuppressionListSchema, + email: EmailSchema, + deleteSuppressions: z.object({ + delete_all: z.boolean().optional(), + emails: z.array(z.string().email()).optional(), + }), + recipientEmails: z.object({ + recipient_emails: z.array(z.string().email()).min(1), + }), + asmIdQuery: z.object({ id: z.number().int().optional() }), + asmGroup: z.object({ + name: z.string(), + description: z.string(), + is_default: z.boolean().optional(), + }), + asmGroupUpdate: z + .object({ + id: z.union([z.string(), z.number()]), + name: z.string().optional(), + description: z.string().optional(), + is_default: z.boolean().optional(), + }) + .catchall(z.unknown()), + asmGroupEmails: z.object({ + id: z.union([z.string(), z.number()]), + recipient_emails: z.array(z.string().email()).min(1), + }), + idNumEmail: z.object({ + id: z.union([z.string(), z.number()]), + email: z.string().email(), + }), + stats: StatsSchema, + statsCategories: StatsSchema.extend({ + categories: z.string(), + }), + apiKeyCreate: z.object({ + name: z.string(), + scopes: z.array(z.string()).optional(), + }), + limit: z.object({ limit: z.number().int().positive().optional() }), + apiKeyUpdate: z.object({ + id: z.string(), + name: z.string().optional(), + scopes: z.array(z.string()).optional(), + }), +} as const; + +const outputKinds = { + empty: EmptySchema, + obj: ObjSchema, + arr: ArrSchema, + job: JobSchema, + mailSend: z.object({ x_message_id: z.string().optional() }), + list: SendGridList, + listsGetAll: z.object({ + result: z.array(SendGridList), + _metadata: z.record(z.string(), z.unknown()).optional(), + }), + sendersGetAll: z.object({ + results: z.array(SendGridVerifiedSender), + }), + bounces: z.object({ bounces: z.array(SendGridBounce) }), + results: z.object({ results: z.array(ObjSchema) }), +} as const; + +export const SendGridEndpointInputSchemas = { + mailSend: inputKinds.mailSend, + mailCreateBatchId: inputKinds.empty, + mailValidateBatchId: inputKinds.batchId, + mailCancelScheduledSend: inputKinds.scheduledSend, + mailListScheduledSends: inputKinds.empty, + mailGetScheduledSend: inputKinds.batchId, + mailUpdateScheduledSend: inputKinds.scheduledSendUpdate, + mailDeleteScheduledSend: inputKinds.batchId, + contactsAddOrUpdate: inputKinds.contactsAddOrUpdate, + contactsGet: inputKinds.id, + contactsSearch: inputKinds.contactSearch, + contactsSearchEmails: inputKinds.contactSearchEmails, + contactsRemove: inputKinds.idsQuery, + contactsGetCount: inputKinds.empty, + contactsGetSample: inputKinds.page, + contactsImport: inputKinds.contactImport, + contactsImportStatus: inputKinds.id, + contactsExport: inputKinds.contactExport, + contactsExportStatus: inputKinds.id, + contactsListExports: inputKinds.empty, + listsGetAll: inputKinds.page, + listsCreate: inputKinds.listsCreate, + listsGet: inputKinds.idSample, + listsUpdate: inputKinds.idName, + listsRemove: inputKinds.idDeleteContacts, + listsGetContactCount: inputKinds.id, + listsRemoveContacts: inputKinds.idContactIds, + segmentsCreate: inputKinds.segment, + segmentsGetAll: inputKinds.segmentsList, + segmentsGet: inputKinds.idContactsSample, + segmentsUpdate: inputKinds.segmentUpdate, + segmentsRemove: inputKinds.id, + segmentsRefresh: inputKinds.segmentRefresh, + fieldsGetAll: inputKinds.empty, + fieldsCreate: inputKinds.fieldCreate, + fieldsUpdate: inputKinds.idName, + fieldsRemove: inputKinds.id, + sendersGetAll: inputKinds.sendersGetAll, + sendersCreate: inputKinds.verifiedSender, + sendersUpdate: inputKinds.verifiedSenderUpdate, + sendersRemove: inputKinds.idNum, + sendersResend: inputKinds.idNum, + sendersListIdentities: inputKinds.empty, + sendersCreateIdentity: inputKinds.senderIdentity, + sendersGetIdentity: inputKinds.idNum, + templatesCreate: inputKinds.templateCreate, + templatesGetAll: inputKinds.templatesList, + templatesGet: inputKinds.id, + templatesUpdate: inputKinds.idName, + templatesRemove: inputKinds.id, + templatesCreateVersion: inputKinds.templateVersion, + templatesGetVersion: inputKinds.templateVersionId, + templatesUpdateVersion: inputKinds.templateVersionUpdate, + templatesRemoveVersion: inputKinds.templateVersionId, + templatesActivateVersion: inputKinds.templateVersionId, + suppressionsGetBounces: inputKinds.suppressionList, + suppressionsGetBounce: inputKinds.email, + suppressionsDeleteBounce: inputKinds.email, + suppressionsDeleteBounces: inputKinds.deleteSuppressions, + suppressionsGetBlocks: inputKinds.suppressionList, + suppressionsGetBlock: inputKinds.email, + suppressionsDeleteBlock: inputKinds.email, + suppressionsDeleteBlocks: inputKinds.deleteSuppressions, + suppressionsGetSpamReports: inputKinds.suppressionList, + suppressionsGetSpamReport: inputKinds.email, + suppressionsDeleteSpamReport: inputKinds.email, + suppressionsDeleteSpamReports: inputKinds.deleteSuppressions, + suppressionsGetInvalidEmails: inputKinds.suppressionList, + suppressionsGetInvalidEmail: inputKinds.email, + suppressionsDeleteInvalidEmail: inputKinds.email, + suppressionsDeleteInvalidEmails: inputKinds.deleteSuppressions, + suppressionsGetGlobalUnsubscribes: inputKinds.suppressionList, + suppressionsAddGlobalUnsubscribes: inputKinds.recipientEmails, + suppressionsGetGlobalUnsubscribe: inputKinds.email, + suppressionsDeleteGlobalUnsubscribe: inputKinds.email, + asmGetGroups: inputKinds.asmIdQuery, + asmCreateGroup: inputKinds.asmGroup, + asmGetGroup: inputKinds.idNum, + asmUpdateGroup: inputKinds.asmGroupUpdate, + asmDeleteGroup: inputKinds.idNum, + asmAddGroupSuppressions: inputKinds.asmGroupEmails, + asmGetGroupSuppressions: inputKinds.idNum, + asmDeleteGroupSuppression: inputKinds.idNumEmail, + statsGetGlobal: inputKinds.stats, + statsGetCategory: inputKinds.statsCategories, + statsGetMailboxProvider: inputKinds.stats, + statsGetGeo: inputKinds.stats, + statsGetDevice: inputKinds.stats, + statsGetClient: inputKinds.stats, + userGetProfile: inputKinds.empty, + userGetAccount: inputKinds.empty, + userGetCredits: inputKinds.empty, + userGetUsername: inputKinds.empty, + userGetEmail: inputKinds.empty, + userGetScopes: inputKinds.empty, + apiKeysCreate: inputKinds.apiKeyCreate, + apiKeysGetAll: inputKinds.limit, + apiKeysGet: inputKinds.id, + apiKeysUpdate: inputKinds.apiKeyUpdate, + apiKeysRemove: inputKinds.id, +} as const; + +export const SendGridEndpointOutputSchemas = { + mailSend: outputKinds.mailSend, + mailCreateBatchId: outputKinds.obj, + mailValidateBatchId: outputKinds.obj, + mailCancelScheduledSend: outputKinds.obj, + mailListScheduledSends: outputKinds.arr, + mailGetScheduledSend: outputKinds.arr, + mailUpdateScheduledSend: outputKinds.empty, + mailDeleteScheduledSend: outputKinds.empty, + contactsAddOrUpdate: outputKinds.job, + contactsGet: outputKinds.obj, + contactsSearch: outputKinds.obj, + contactsSearchEmails: outputKinds.obj, + contactsRemove: outputKinds.job, + contactsGetCount: outputKinds.obj, + contactsGetSample: outputKinds.obj, + contactsImport: outputKinds.obj, + contactsImportStatus: outputKinds.obj, + contactsExport: outputKinds.obj, + contactsExportStatus: outputKinds.obj, + contactsListExports: outputKinds.obj, + listsGetAll: outputKinds.listsGetAll, + listsCreate: outputKinds.list, + listsGet: outputKinds.obj, + listsUpdate: outputKinds.list, + listsRemove: outputKinds.empty, + listsGetContactCount: outputKinds.obj, + listsRemoveContacts: outputKinds.job, + segmentsCreate: outputKinds.obj, + segmentsGetAll: outputKinds.obj, + segmentsGet: outputKinds.obj, + segmentsUpdate: outputKinds.obj, + segmentsRemove: outputKinds.empty, + segmentsRefresh: outputKinds.obj, + fieldsGetAll: outputKinds.obj, + fieldsCreate: outputKinds.obj, + fieldsUpdate: outputKinds.obj, + fieldsRemove: outputKinds.empty, + sendersGetAll: outputKinds.sendersGetAll, + sendersCreate: outputKinds.obj, + sendersUpdate: outputKinds.obj, + sendersRemove: outputKinds.empty, + sendersResend: outputKinds.empty, + sendersListIdentities: outputKinds.obj, + sendersCreateIdentity: outputKinds.obj, + sendersGetIdentity: outputKinds.obj, + templatesCreate: outputKinds.obj, + templatesGetAll: outputKinds.obj, + templatesGet: outputKinds.obj, + templatesUpdate: outputKinds.obj, + templatesRemove: outputKinds.empty, + templatesCreateVersion: outputKinds.obj, + templatesGetVersion: outputKinds.obj, + templatesUpdateVersion: outputKinds.obj, + templatesRemoveVersion: outputKinds.empty, + templatesActivateVersion: outputKinds.obj, + suppressionsGetBounces: outputKinds.bounces, + suppressionsGetBounce: outputKinds.arr, + suppressionsDeleteBounce: outputKinds.empty, + suppressionsDeleteBounces: outputKinds.empty, + suppressionsGetBlocks: outputKinds.results, + suppressionsGetBlock: outputKinds.arr, + suppressionsDeleteBlock: outputKinds.empty, + suppressionsDeleteBlocks: outputKinds.empty, + suppressionsGetSpamReports: outputKinds.results, + suppressionsGetSpamReport: outputKinds.arr, + suppressionsDeleteSpamReport: outputKinds.empty, + suppressionsDeleteSpamReports: outputKinds.empty, + suppressionsGetInvalidEmails: outputKinds.results, + suppressionsGetInvalidEmail: outputKinds.arr, + suppressionsDeleteInvalidEmail: outputKinds.empty, + suppressionsDeleteInvalidEmails: outputKinds.empty, + suppressionsGetGlobalUnsubscribes: outputKinds.results, + suppressionsAddGlobalUnsubscribes: outputKinds.obj, + suppressionsGetGlobalUnsubscribe: outputKinds.obj, + suppressionsDeleteGlobalUnsubscribe: outputKinds.empty, + asmGetGroups: outputKinds.arr, + asmCreateGroup: outputKinds.obj, + asmGetGroup: outputKinds.obj, + asmUpdateGroup: outputKinds.obj, + asmDeleteGroup: outputKinds.empty, + asmAddGroupSuppressions: outputKinds.obj, + asmGetGroupSuppressions: outputKinds.arr, + asmDeleteGroupSuppression: outputKinds.empty, + statsGetGlobal: outputKinds.arr, + statsGetCategory: outputKinds.arr, + statsGetMailboxProvider: outputKinds.arr, + statsGetGeo: outputKinds.arr, + statsGetDevice: outputKinds.arr, + statsGetClient: outputKinds.arr, + userGetProfile: outputKinds.obj, + userGetAccount: outputKinds.obj, + userGetCredits: outputKinds.obj, + userGetUsername: outputKinds.obj, + userGetEmail: outputKinds.obj, + userGetScopes: outputKinds.obj, + apiKeysCreate: outputKinds.obj, + apiKeysGetAll: outputKinds.obj, + apiKeysGet: outputKinds.obj, + apiKeysUpdate: outputKinds.obj, + apiKeysRemove: outputKinds.empty, +} as const; + +export type SendGridEndpointInputs = { + [K in keyof typeof SendGridEndpointInputSchemas]: z.infer< + (typeof SendGridEndpointInputSchemas)[K] + >; +}; + +export type SendGridEndpointOutputs = { + [K in keyof typeof SendGridEndpointOutputSchemas]: z.infer< + (typeof SendGridEndpointOutputSchemas)[K] + >; +}; + +export type MailSendInput = SendGridEndpointInputs['mailSend']; +export type MailSendOutput = SendGridEndpointOutputs['mailSend']; +export type ContactsAddOrUpdateInput = + SendGridEndpointInputs['contactsAddOrUpdate']; +export type ContactsAddOrUpdateOutput = + SendGridEndpointOutputs['contactsAddOrUpdate']; +export type ListsGetAllInput = SendGridEndpointInputs['listsGetAll']; +export type ListsGetAllOutput = SendGridEndpointOutputs['listsGetAll']; +export type ListsCreateInput = SendGridEndpointInputs['listsCreate']; +export type ListsCreateOutput = SendGridEndpointOutputs['listsCreate']; +export type SuppressionsGetBouncesInput = + SendGridEndpointInputs['suppressionsGetBounces']; +export type SuppressionsGetBouncesOutput = + SendGridEndpointOutputs['suppressionsGetBounces']; +export type SendersGetAllInput = SendGridEndpointInputs['sendersGetAll']; +export type SendersGetAllOutput = SendGridEndpointOutputs['sendersGetAll']; diff --git a/packages/sendgrid/error-handlers.ts b/packages/sendgrid/error-handlers.ts new file mode 100644 index 000000000..02415133f --- /dev/null +++ b/packages/sendgrid/error-handlers.ts @@ -0,0 +1,73 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { SendGridAPIError } from './client'; + +function statusOf(error: Error): number | undefined { + if (error instanceof ApiError) return error.status; + if (error instanceof SendGridAPIError) return error.status; + return undefined; +} + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error) => { + if (statusOf(error) === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate limit') || msg.includes('too many requests'); + }, + handler: async (error) => { + const retryAfterMs = + (error instanceof ApiError ? error.retryAfter : undefined) ?? + (error instanceof SendGridAPIError ? error.retryAfter : undefined); + return { + maxRetries: 3, + headersRetryAfterMs: retryAfterMs, + retryStrategy: retryAfterMs + ? undefined + : ('exponential_backoff' as const), + }; + }, + }, + AUTH_ERROR: { + match: (error) => { + if (statusOf(error) === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid api key'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + PERMISSION_ERROR: { + match: (error) => { + if (statusOf(error) === 403) return true; + const msg = error.message.toLowerCase(); + return msg.includes('forbidden') || msg.includes('access forbidden'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + NOT_FOUND_ERROR: { + match: (error) => { + if (statusOf(error) === 404) return true; + return error.message.toLowerCase().includes('not found'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + SERVER_ERROR: { + match: (error) => { + const status = statusOf(error); + if (status !== undefined && status >= 500) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('internal server error') || + msg.includes('service unavailable') + ); + }, + handler: async () => ({ + maxRetries: 2, + retryStrategy: 'exponential_backoff', + }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/sendgrid/index.ts b/packages/sendgrid/index.ts new file mode 100644 index 000000000..827a41c85 --- /dev/null +++ b/packages/sendgrid/index.ts @@ -0,0 +1,973 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + apiKeys, + asm, + contacts, + fields, + lists, + mail, + segments, + senders, + stats, + suppressions, + templates, + user, +} from './endpoints/handlers'; +import type { + SendGridEndpointInputs, + SendGridEndpointOutputs, +} from './endpoints/types'; +import { + SendGridEndpointInputSchemas, + SendGridEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { SendGridSchema } from './schema'; + +export type SendGridPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalSendGridPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type SendGridContext = CorsairPluginContext< + typeof SendGridSchema, + SendGridPluginOptions +>; + +export type SendGridKeyBuilderContext = + KeyBuilderContext; + +export type SendGridBoundEndpoints = BindEndpoints< + typeof sendGridEndpointsNested +>; + +type SendGridEndpoint = + CorsairEndpoint< + SendGridContext, + SendGridEndpointInputs[K], + SendGridEndpointOutputs[K] + >; + +export type SendGridEndpoints = { + [K in keyof SendGridEndpointInputs]: SendGridEndpoint; +}; + +const sendGridEndpointsNested = { + mail, + contacts, + lists, + segments, + fields, + senders, + templates, + suppressions, + asm, + stats, + user, + apiKeys, +} as const; + +export const sendGridEndpointSchemas = { + 'mail.send': { + input: SendGridEndpointInputSchemas.mailSend, + output: SendGridEndpointOutputSchemas.mailSend, + }, + 'mail.createBatchId': { + input: SendGridEndpointInputSchemas.mailCreateBatchId, + output: SendGridEndpointOutputSchemas.mailCreateBatchId, + }, + 'mail.validateBatchId': { + input: SendGridEndpointInputSchemas.mailValidateBatchId, + output: SendGridEndpointOutputSchemas.mailValidateBatchId, + }, + 'mail.cancelScheduledSend': { + input: SendGridEndpointInputSchemas.mailCancelScheduledSend, + output: SendGridEndpointOutputSchemas.mailCancelScheduledSend, + }, + 'mail.listScheduledSends': { + input: SendGridEndpointInputSchemas.mailListScheduledSends, + output: SendGridEndpointOutputSchemas.mailListScheduledSends, + }, + 'mail.getScheduledSend': { + input: SendGridEndpointInputSchemas.mailGetScheduledSend, + output: SendGridEndpointOutputSchemas.mailGetScheduledSend, + }, + 'mail.updateScheduledSend': { + input: SendGridEndpointInputSchemas.mailUpdateScheduledSend, + output: SendGridEndpointOutputSchemas.mailUpdateScheduledSend, + }, + 'mail.deleteScheduledSend': { + input: SendGridEndpointInputSchemas.mailDeleteScheduledSend, + output: SendGridEndpointOutputSchemas.mailDeleteScheduledSend, + }, + 'contacts.addOrUpdate': { + input: SendGridEndpointInputSchemas.contactsAddOrUpdate, + output: SendGridEndpointOutputSchemas.contactsAddOrUpdate, + }, + 'contacts.get': { + input: SendGridEndpointInputSchemas.contactsGet, + output: SendGridEndpointOutputSchemas.contactsGet, + }, + 'contacts.search': { + input: SendGridEndpointInputSchemas.contactsSearch, + output: SendGridEndpointOutputSchemas.contactsSearch, + }, + 'contacts.searchEmails': { + input: SendGridEndpointInputSchemas.contactsSearchEmails, + output: SendGridEndpointOutputSchemas.contactsSearchEmails, + }, + 'contacts.remove': { + input: SendGridEndpointInputSchemas.contactsRemove, + output: SendGridEndpointOutputSchemas.contactsRemove, + }, + 'contacts.getCount': { + input: SendGridEndpointInputSchemas.contactsGetCount, + output: SendGridEndpointOutputSchemas.contactsGetCount, + }, + 'contacts.getSample': { + input: SendGridEndpointInputSchemas.contactsGetSample, + output: SendGridEndpointOutputSchemas.contactsGetSample, + }, + 'contacts.import': { + input: SendGridEndpointInputSchemas.contactsImport, + output: SendGridEndpointOutputSchemas.contactsImport, + }, + 'contacts.importStatus': { + input: SendGridEndpointInputSchemas.contactsImportStatus, + output: SendGridEndpointOutputSchemas.contactsImportStatus, + }, + 'contacts.export': { + input: SendGridEndpointInputSchemas.contactsExport, + output: SendGridEndpointOutputSchemas.contactsExport, + }, + 'contacts.exportStatus': { + input: SendGridEndpointInputSchemas.contactsExportStatus, + output: SendGridEndpointOutputSchemas.contactsExportStatus, + }, + 'contacts.listExports': { + input: SendGridEndpointInputSchemas.contactsListExports, + output: SendGridEndpointOutputSchemas.contactsListExports, + }, + 'lists.getAll': { + input: SendGridEndpointInputSchemas.listsGetAll, + output: SendGridEndpointOutputSchemas.listsGetAll, + }, + 'lists.create': { + input: SendGridEndpointInputSchemas.listsCreate, + output: SendGridEndpointOutputSchemas.listsCreate, + }, + 'lists.get': { + input: SendGridEndpointInputSchemas.listsGet, + output: SendGridEndpointOutputSchemas.listsGet, + }, + 'lists.update': { + input: SendGridEndpointInputSchemas.listsUpdate, + output: SendGridEndpointOutputSchemas.listsUpdate, + }, + 'lists.remove': { + input: SendGridEndpointInputSchemas.listsRemove, + output: SendGridEndpointOutputSchemas.listsRemove, + }, + 'lists.getContactCount': { + input: SendGridEndpointInputSchemas.listsGetContactCount, + output: SendGridEndpointOutputSchemas.listsGetContactCount, + }, + 'lists.removeContacts': { + input: SendGridEndpointInputSchemas.listsRemoveContacts, + output: SendGridEndpointOutputSchemas.listsRemoveContacts, + }, + 'segments.create': { + input: SendGridEndpointInputSchemas.segmentsCreate, + output: SendGridEndpointOutputSchemas.segmentsCreate, + }, + 'segments.getAll': { + input: SendGridEndpointInputSchemas.segmentsGetAll, + output: SendGridEndpointOutputSchemas.segmentsGetAll, + }, + 'segments.get': { + input: SendGridEndpointInputSchemas.segmentsGet, + output: SendGridEndpointOutputSchemas.segmentsGet, + }, + 'segments.update': { + input: SendGridEndpointInputSchemas.segmentsUpdate, + output: SendGridEndpointOutputSchemas.segmentsUpdate, + }, + 'segments.remove': { + input: SendGridEndpointInputSchemas.segmentsRemove, + output: SendGridEndpointOutputSchemas.segmentsRemove, + }, + 'segments.refresh': { + input: SendGridEndpointInputSchemas.segmentsRefresh, + output: SendGridEndpointOutputSchemas.segmentsRefresh, + }, + 'fields.getAll': { + input: SendGridEndpointInputSchemas.fieldsGetAll, + output: SendGridEndpointOutputSchemas.fieldsGetAll, + }, + 'fields.create': { + input: SendGridEndpointInputSchemas.fieldsCreate, + output: SendGridEndpointOutputSchemas.fieldsCreate, + }, + 'fields.update': { + input: SendGridEndpointInputSchemas.fieldsUpdate, + output: SendGridEndpointOutputSchemas.fieldsUpdate, + }, + 'fields.remove': { + input: SendGridEndpointInputSchemas.fieldsRemove, + output: SendGridEndpointOutputSchemas.fieldsRemove, + }, + 'senders.getAll': { + input: SendGridEndpointInputSchemas.sendersGetAll, + output: SendGridEndpointOutputSchemas.sendersGetAll, + }, + 'senders.create': { + input: SendGridEndpointInputSchemas.sendersCreate, + output: SendGridEndpointOutputSchemas.sendersCreate, + }, + 'senders.update': { + input: SendGridEndpointInputSchemas.sendersUpdate, + output: SendGridEndpointOutputSchemas.sendersUpdate, + }, + 'senders.remove': { + input: SendGridEndpointInputSchemas.sendersRemove, + output: SendGridEndpointOutputSchemas.sendersRemove, + }, + 'senders.resend': { + input: SendGridEndpointInputSchemas.sendersResend, + output: SendGridEndpointOutputSchemas.sendersResend, + }, + 'senders.listIdentities': { + input: SendGridEndpointInputSchemas.sendersListIdentities, + output: SendGridEndpointOutputSchemas.sendersListIdentities, + }, + 'senders.createIdentity': { + input: SendGridEndpointInputSchemas.sendersCreateIdentity, + output: SendGridEndpointOutputSchemas.sendersCreateIdentity, + }, + 'senders.getIdentity': { + input: SendGridEndpointInputSchemas.sendersGetIdentity, + output: SendGridEndpointOutputSchemas.sendersGetIdentity, + }, + 'templates.create': { + input: SendGridEndpointInputSchemas.templatesCreate, + output: SendGridEndpointOutputSchemas.templatesCreate, + }, + 'templates.getAll': { + input: SendGridEndpointInputSchemas.templatesGetAll, + output: SendGridEndpointOutputSchemas.templatesGetAll, + }, + 'templates.get': { + input: SendGridEndpointInputSchemas.templatesGet, + output: SendGridEndpointOutputSchemas.templatesGet, + }, + 'templates.update': { + input: SendGridEndpointInputSchemas.templatesUpdate, + output: SendGridEndpointOutputSchemas.templatesUpdate, + }, + 'templates.remove': { + input: SendGridEndpointInputSchemas.templatesRemove, + output: SendGridEndpointOutputSchemas.templatesRemove, + }, + 'templates.createVersion': { + input: SendGridEndpointInputSchemas.templatesCreateVersion, + output: SendGridEndpointOutputSchemas.templatesCreateVersion, + }, + 'templates.getVersion': { + input: SendGridEndpointInputSchemas.templatesGetVersion, + output: SendGridEndpointOutputSchemas.templatesGetVersion, + }, + 'templates.updateVersion': { + input: SendGridEndpointInputSchemas.templatesUpdateVersion, + output: SendGridEndpointOutputSchemas.templatesUpdateVersion, + }, + 'templates.removeVersion': { + input: SendGridEndpointInputSchemas.templatesRemoveVersion, + output: SendGridEndpointOutputSchemas.templatesRemoveVersion, + }, + 'templates.activateVersion': { + input: SendGridEndpointInputSchemas.templatesActivateVersion, + output: SendGridEndpointOutputSchemas.templatesActivateVersion, + }, + 'suppressions.getBounces': { + input: SendGridEndpointInputSchemas.suppressionsGetBounces, + output: SendGridEndpointOutputSchemas.suppressionsGetBounces, + }, + 'suppressions.getBounce': { + input: SendGridEndpointInputSchemas.suppressionsGetBounce, + output: SendGridEndpointOutputSchemas.suppressionsGetBounce, + }, + 'suppressions.deleteBounce': { + input: SendGridEndpointInputSchemas.suppressionsDeleteBounce, + output: SendGridEndpointOutputSchemas.suppressionsDeleteBounce, + }, + 'suppressions.deleteBounces': { + input: SendGridEndpointInputSchemas.suppressionsDeleteBounces, + output: SendGridEndpointOutputSchemas.suppressionsDeleteBounces, + }, + 'suppressions.getBlocks': { + input: SendGridEndpointInputSchemas.suppressionsGetBlocks, + output: SendGridEndpointOutputSchemas.suppressionsGetBlocks, + }, + 'suppressions.getBlock': { + input: SendGridEndpointInputSchemas.suppressionsGetBlock, + output: SendGridEndpointOutputSchemas.suppressionsGetBlock, + }, + 'suppressions.deleteBlock': { + input: SendGridEndpointInputSchemas.suppressionsDeleteBlock, + output: SendGridEndpointOutputSchemas.suppressionsDeleteBlock, + }, + 'suppressions.deleteBlocks': { + input: SendGridEndpointInputSchemas.suppressionsDeleteBlocks, + output: SendGridEndpointOutputSchemas.suppressionsDeleteBlocks, + }, + 'suppressions.getSpamReports': { + input: SendGridEndpointInputSchemas.suppressionsGetSpamReports, + output: SendGridEndpointOutputSchemas.suppressionsGetSpamReports, + }, + 'suppressions.getSpamReport': { + input: SendGridEndpointInputSchemas.suppressionsGetSpamReport, + output: SendGridEndpointOutputSchemas.suppressionsGetSpamReport, + }, + 'suppressions.deleteSpamReport': { + input: SendGridEndpointInputSchemas.suppressionsDeleteSpamReport, + output: SendGridEndpointOutputSchemas.suppressionsDeleteSpamReport, + }, + 'suppressions.deleteSpamReports': { + input: SendGridEndpointInputSchemas.suppressionsDeleteSpamReports, + output: SendGridEndpointOutputSchemas.suppressionsDeleteSpamReports, + }, + 'suppressions.getInvalidEmails': { + input: SendGridEndpointInputSchemas.suppressionsGetInvalidEmails, + output: SendGridEndpointOutputSchemas.suppressionsGetInvalidEmails, + }, + 'suppressions.getInvalidEmail': { + input: SendGridEndpointInputSchemas.suppressionsGetInvalidEmail, + output: SendGridEndpointOutputSchemas.suppressionsGetInvalidEmail, + }, + 'suppressions.deleteInvalidEmail': { + input: SendGridEndpointInputSchemas.suppressionsDeleteInvalidEmail, + output: SendGridEndpointOutputSchemas.suppressionsDeleteInvalidEmail, + }, + 'suppressions.deleteInvalidEmails': { + input: SendGridEndpointInputSchemas.suppressionsDeleteInvalidEmails, + output: SendGridEndpointOutputSchemas.suppressionsDeleteInvalidEmails, + }, + 'suppressions.getGlobalUnsubscribes': { + input: SendGridEndpointInputSchemas.suppressionsGetGlobalUnsubscribes, + output: SendGridEndpointOutputSchemas.suppressionsGetGlobalUnsubscribes, + }, + 'suppressions.addGlobalUnsubscribes': { + input: SendGridEndpointInputSchemas.suppressionsAddGlobalUnsubscribes, + output: SendGridEndpointOutputSchemas.suppressionsAddGlobalUnsubscribes, + }, + 'suppressions.getGlobalUnsubscribe': { + input: SendGridEndpointInputSchemas.suppressionsGetGlobalUnsubscribe, + output: SendGridEndpointOutputSchemas.suppressionsGetGlobalUnsubscribe, + }, + 'suppressions.deleteGlobalUnsubscribe': { + input: SendGridEndpointInputSchemas.suppressionsDeleteGlobalUnsubscribe, + output: SendGridEndpointOutputSchemas.suppressionsDeleteGlobalUnsubscribe, + }, + 'asm.getGroups': { + input: SendGridEndpointInputSchemas.asmGetGroups, + output: SendGridEndpointOutputSchemas.asmGetGroups, + }, + 'asm.createGroup': { + input: SendGridEndpointInputSchemas.asmCreateGroup, + output: SendGridEndpointOutputSchemas.asmCreateGroup, + }, + 'asm.getGroup': { + input: SendGridEndpointInputSchemas.asmGetGroup, + output: SendGridEndpointOutputSchemas.asmGetGroup, + }, + 'asm.updateGroup': { + input: SendGridEndpointInputSchemas.asmUpdateGroup, + output: SendGridEndpointOutputSchemas.asmUpdateGroup, + }, + 'asm.deleteGroup': { + input: SendGridEndpointInputSchemas.asmDeleteGroup, + output: SendGridEndpointOutputSchemas.asmDeleteGroup, + }, + 'asm.addGroupSuppressions': { + input: SendGridEndpointInputSchemas.asmAddGroupSuppressions, + output: SendGridEndpointOutputSchemas.asmAddGroupSuppressions, + }, + 'asm.getGroupSuppressions': { + input: SendGridEndpointInputSchemas.asmGetGroupSuppressions, + output: SendGridEndpointOutputSchemas.asmGetGroupSuppressions, + }, + 'asm.deleteGroupSuppression': { + input: SendGridEndpointInputSchemas.asmDeleteGroupSuppression, + output: SendGridEndpointOutputSchemas.asmDeleteGroupSuppression, + }, + 'stats.getGlobal': { + input: SendGridEndpointInputSchemas.statsGetGlobal, + output: SendGridEndpointOutputSchemas.statsGetGlobal, + }, + 'stats.getCategory': { + input: SendGridEndpointInputSchemas.statsGetCategory, + output: SendGridEndpointOutputSchemas.statsGetCategory, + }, + 'stats.getMailboxProvider': { + input: SendGridEndpointInputSchemas.statsGetMailboxProvider, + output: SendGridEndpointOutputSchemas.statsGetMailboxProvider, + }, + 'stats.getGeo': { + input: SendGridEndpointInputSchemas.statsGetGeo, + output: SendGridEndpointOutputSchemas.statsGetGeo, + }, + 'stats.getDevice': { + input: SendGridEndpointInputSchemas.statsGetDevice, + output: SendGridEndpointOutputSchemas.statsGetDevice, + }, + 'stats.getClient': { + input: SendGridEndpointInputSchemas.statsGetClient, + output: SendGridEndpointOutputSchemas.statsGetClient, + }, + 'user.getProfile': { + input: SendGridEndpointInputSchemas.userGetProfile, + output: SendGridEndpointOutputSchemas.userGetProfile, + }, + 'user.getAccount': { + input: SendGridEndpointInputSchemas.userGetAccount, + output: SendGridEndpointOutputSchemas.userGetAccount, + }, + 'user.getCredits': { + input: SendGridEndpointInputSchemas.userGetCredits, + output: SendGridEndpointOutputSchemas.userGetCredits, + }, + 'user.getUsername': { + input: SendGridEndpointInputSchemas.userGetUsername, + output: SendGridEndpointOutputSchemas.userGetUsername, + }, + 'user.getEmail': { + input: SendGridEndpointInputSchemas.userGetEmail, + output: SendGridEndpointOutputSchemas.userGetEmail, + }, + 'user.getScopes': { + input: SendGridEndpointInputSchemas.userGetScopes, + output: SendGridEndpointOutputSchemas.userGetScopes, + }, + 'apiKeys.create': { + input: SendGridEndpointInputSchemas.apiKeysCreate, + output: SendGridEndpointOutputSchemas.apiKeysCreate, + }, + 'apiKeys.getAll': { + input: SendGridEndpointInputSchemas.apiKeysGetAll, + output: SendGridEndpointOutputSchemas.apiKeysGetAll, + }, + 'apiKeys.get': { + input: SendGridEndpointInputSchemas.apiKeysGet, + output: SendGridEndpointOutputSchemas.apiKeysGet, + }, + 'apiKeys.update': { + input: SendGridEndpointInputSchemas.apiKeysUpdate, + output: SendGridEndpointOutputSchemas.apiKeysUpdate, + }, + 'apiKeys.remove': { + input: SendGridEndpointInputSchemas.apiKeysRemove, + output: SendGridEndpointOutputSchemas.apiKeysRemove, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof sendGridEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const sendGridEndpointMeta = { + 'mail.send': { + riskLevel: 'write', + description: 'Send an email via SendGrid Mail Send API v3', + }, + 'mail.createBatchId': { + riskLevel: 'write', + description: 'Create a batch ID for scheduled mail', + }, + 'mail.validateBatchId': { + riskLevel: 'read', + description: 'Validate a mail batch ID', + }, + 'mail.cancelScheduledSend': { + riskLevel: 'write', + description: 'Cancel or pause a scheduled send', + }, + 'mail.listScheduledSends': { + riskLevel: 'read', + description: 'Retrieve all scheduled sends', + }, + 'mail.getScheduledSend': { + riskLevel: 'read', + description: 'Retrieve a scheduled send by batch ID', + }, + 'mail.updateScheduledSend': { + riskLevel: 'write', + description: 'Update a scheduled send status', + }, + 'mail.deleteScheduledSend': { + riskLevel: 'write', + description: 'Delete a cancellation/pause for a scheduled send', + }, + 'contacts.addOrUpdate': { + riskLevel: 'write', + description: 'Add or update contacts in SendGrid Marketing', + }, + 'contacts.get': { + riskLevel: 'read', + description: 'Get a marketing contact by ID', + }, + 'contacts.search': { + riskLevel: 'read', + description: 'Search marketing contacts with SGQL', + }, + 'contacts.searchEmails': { + riskLevel: 'read', + description: 'Search marketing contacts by email', + }, + 'contacts.remove': { + riskLevel: 'write', + description: 'Delete marketing contacts by ID', + }, + 'contacts.getCount': { + riskLevel: 'read', + description: 'Get marketing contact count', + }, + 'contacts.getSample': { + riskLevel: 'read', + description: 'Get a sample of marketing contacts', + }, + 'contacts.import': { + riskLevel: 'write', + description: 'Create a marketing contacts import job', + }, + 'contacts.importStatus': { + riskLevel: 'read', + description: 'Get a marketing contacts import job', + }, + 'contacts.export': { + riskLevel: 'write', + description: 'Create a marketing contacts export job', + }, + 'contacts.exportStatus': { + riskLevel: 'read', + description: 'Get a marketing contacts export job', + }, + 'contacts.listExports': { + riskLevel: 'read', + description: 'List marketing contacts export jobs', + }, + 'lists.getAll': { + riskLevel: 'read', + description: 'Retrieve all marketing contact lists', + }, + 'lists.create': { + riskLevel: 'write', + description: 'Create a new marketing contact list', + }, + 'lists.get': { + riskLevel: 'read', + description: 'Get a marketing list by ID', + }, + 'lists.update': { + riskLevel: 'write', + description: 'Update a marketing list', + }, + 'lists.remove': { + riskLevel: 'write', + description: 'Delete a marketing list', + }, + 'lists.getContactCount': { + riskLevel: 'read', + description: 'Get contact count for a marketing list', + }, + 'lists.removeContacts': { + riskLevel: 'write', + description: 'Remove contacts from a marketing list', + }, + 'segments.create': { + riskLevel: 'write', + description: 'Create a Marketing Campaigns segment 2.0', + }, + 'segments.getAll': { + riskLevel: 'read', + description: 'Get all Marketing Campaigns segments 2.0', + }, + 'segments.get': { + riskLevel: 'read', + description: 'Get a Marketing Campaigns segment 2.0', + }, + 'segments.update': { + riskLevel: 'write', + description: 'Update a Marketing Campaigns segment 2.0', + }, + 'segments.remove': { + riskLevel: 'write', + description: 'Delete a Marketing Campaigns segment 2.0', + }, + 'segments.refresh': { + riskLevel: 'write', + description: 'Manually refresh a Marketing Campaigns segment 2.0', + }, + 'fields.getAll': { + riskLevel: 'read', + description: 'Get all marketing field definitions', + }, + 'fields.create': { + riskLevel: 'write', + description: 'Create a custom field definition', + }, + 'fields.update': { + riskLevel: 'write', + description: 'Update a custom field definition', + }, + 'fields.remove': { + riskLevel: 'write', + description: 'Delete a custom field definition', + }, + 'senders.getAll': { + riskLevel: 'read', + description: 'Retrieve verified senders', + }, + 'senders.create': { + riskLevel: 'write', + description: 'Create a verified sender', + }, + 'senders.update': { + riskLevel: 'write', + description: 'Update a verified sender', + }, + 'senders.remove': { + riskLevel: 'write', + description: 'Delete a verified sender', + }, + 'senders.resend': { + riskLevel: 'write', + description: 'Resend verified sender verification', + }, + 'senders.listIdentities': { + riskLevel: 'read', + description: 'Get Marketing Campaigns sender identities', + }, + 'senders.createIdentity': { + riskLevel: 'write', + description: 'Create a Marketing Campaigns sender identity', + }, + 'senders.getIdentity': { + riskLevel: 'read', + description: 'Get a Marketing Campaigns sender identity', + }, + 'templates.create': { + riskLevel: 'write', + description: 'Create a transactional template', + }, + 'templates.getAll': { + riskLevel: 'read', + description: 'Get all transactional templates', + }, + 'templates.get': { + riskLevel: 'read', + description: 'Get a transactional template', + }, + 'templates.update': { + riskLevel: 'write', + description: 'Update a transactional template', + }, + 'templates.remove': { + riskLevel: 'write', + description: 'Delete a transactional template', + }, + 'templates.createVersion': { + riskLevel: 'write', + description: 'Create a transactional template version', + }, + 'templates.getVersion': { + riskLevel: 'read', + description: 'Get a transactional template version', + }, + 'templates.updateVersion': { + riskLevel: 'write', + description: 'Update a transactional template version', + }, + 'templates.removeVersion': { + riskLevel: 'write', + description: 'Delete a transactional template version', + }, + 'templates.activateVersion': { + riskLevel: 'write', + description: 'Activate a transactional template version', + }, + 'suppressions.getBounces': { + riskLevel: 'read', + description: 'Retrieve email bounce suppressions', + }, + 'suppressions.getBounce': { + riskLevel: 'read', + description: 'Retrieve a bounce by email', + }, + 'suppressions.deleteBounce': { + riskLevel: 'write', + description: 'Delete a bounce by email', + }, + 'suppressions.deleteBounces': { + riskLevel: 'write', + description: 'Delete bounce suppressions', + }, + 'suppressions.getBlocks': { + riskLevel: 'read', + description: 'Retrieve blocked emails', + }, + 'suppressions.getBlock': { + riskLevel: 'read', + description: 'Retrieve a block by email', + }, + 'suppressions.deleteBlock': { + riskLevel: 'write', + description: 'Delete a block by email', + }, + 'suppressions.deleteBlocks': { + riskLevel: 'write', + description: 'Delete blocked emails', + }, + 'suppressions.getSpamReports': { + riskLevel: 'read', + description: 'Retrieve spam reports', + }, + 'suppressions.getSpamReport': { + riskLevel: 'read', + description: 'Retrieve a spam report by email', + }, + 'suppressions.deleteSpamReport': { + riskLevel: 'write', + description: 'Delete a spam report by email', + }, + 'suppressions.deleteSpamReports': { + riskLevel: 'write', + description: 'Delete spam reports', + }, + 'suppressions.getInvalidEmails': { + riskLevel: 'read', + description: 'Retrieve invalid emails', + }, + 'suppressions.getInvalidEmail': { + riskLevel: 'read', + description: 'Retrieve an invalid email', + }, + 'suppressions.deleteInvalidEmail': { + riskLevel: 'write', + description: 'Delete an invalid email', + }, + 'suppressions.deleteInvalidEmails': { + riskLevel: 'write', + description: 'Delete invalid emails', + }, + 'suppressions.getGlobalUnsubscribes': { + riskLevel: 'read', + description: 'Retrieve global unsubscribes', + }, + 'suppressions.addGlobalUnsubscribes': { + riskLevel: 'write', + description: 'Add emails to the global unsubscribe list', + }, + 'suppressions.getGlobalUnsubscribe': { + riskLevel: 'read', + description: 'Retrieve a global unsubscribe by email', + }, + 'suppressions.deleteGlobalUnsubscribe': { + riskLevel: 'write', + description: 'Delete a global unsubscribe by email', + }, + 'asm.getGroups': { + riskLevel: 'read', + description: 'Retrieve unsubscribe groups', + }, + 'asm.createGroup': { + riskLevel: 'write', + description: 'Create an unsubscribe group', + }, + 'asm.getGroup': { + riskLevel: 'read', + description: 'Retrieve an unsubscribe group', + }, + 'asm.updateGroup': { + riskLevel: 'write', + description: 'Update an unsubscribe group', + }, + 'asm.deleteGroup': { + riskLevel: 'write', + description: 'Delete an unsubscribe group', + }, + 'asm.addGroupSuppressions': { + riskLevel: 'write', + description: 'Add suppressions to an unsubscribe group', + }, + 'asm.getGroupSuppressions': { + riskLevel: 'read', + description: 'Retrieve suppressions for an unsubscribe group', + }, + 'asm.deleteGroupSuppression': { + riskLevel: 'write', + description: 'Delete a suppression from an unsubscribe group', + }, + 'stats.getGlobal': { + riskLevel: 'read', + description: 'Retrieve global email statistics', + }, + 'stats.getCategory': { + riskLevel: 'read', + description: 'Retrieve category statistics', + }, + 'stats.getMailboxProvider': { + riskLevel: 'read', + description: 'Retrieve mailbox provider statistics', + }, + 'stats.getGeo': { + riskLevel: 'read', + description: 'Retrieve geographic statistics', + }, + 'stats.getDevice': { + riskLevel: 'read', + description: 'Retrieve device statistics', + }, + 'stats.getClient': { + riskLevel: 'read', + description: 'Retrieve email client statistics', + }, + 'user.getProfile': { + riskLevel: 'read', + description: 'Retrieve the user profile', + }, + 'user.getAccount': { + riskLevel: 'read', + description: 'Retrieve the user account', + }, + 'user.getCredits': { + riskLevel: 'read', + description: 'Retrieve remaining email credits', + }, + 'user.getUsername': { + riskLevel: 'read', + description: 'Retrieve the account username', + }, + 'user.getEmail': { + riskLevel: 'read', + description: 'Retrieve the account email address', + }, + 'user.getScopes': { + riskLevel: 'read', + description: 'Retrieve API key scopes for the current key', + }, + 'apiKeys.create': { + riskLevel: 'write', + description: 'Create an API key', + }, + 'apiKeys.getAll': { + riskLevel: 'read', + description: 'Retrieve all API keys', + }, + 'apiKeys.get': { + riskLevel: 'read', + description: 'Retrieve an API key', + }, + 'apiKeys.update': { + riskLevel: 'write', + description: 'Update an API key name or scopes', + }, + 'apiKeys.remove': { + riskLevel: 'write', + description: 'Delete an API key', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const sendGridAuthConfig = { + api_key: { + account: ['one'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseSendGridPlugin = CorsairPlugin< + 'sendgrid', + typeof SendGridSchema, + typeof sendGridEndpointsNested, + {}, + T, + typeof defaultAuthType +>; + +export type InternalSendGridPlugin = BaseSendGridPlugin; + +export type ExternalSendGridPlugin = + BaseSendGridPlugin; + +export function sendgrid( + incomingOptions: SendGridPluginOptions & T = {} as SendGridPluginOptions & T, +): ExternalSendGridPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'sendgrid', + authConfig: sendGridAuthConfig, + schema: SendGridSchema, + options: options, + hooks: options.hooks, + webhookHooks: undefined, + endpoints: sendGridEndpointsNested, + webhooks: {}, + endpointMeta: sendGridEndpointMeta, + endpointSchemas: sendGridEndpointSchemas, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: SendGridKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + throw new AuthMissingError('sendgrid', 'api_key'); + } + return res; + } + + throw new AuthMissingError('sendgrid', 'api_key'); + }, + } satisfies InternalSendGridPlugin; +} + +export type { + ContactsAddOrUpdateInput, + ContactsAddOrUpdateOutput, + ListsCreateInput, + ListsCreateOutput, + ListsGetAllInput, + ListsGetAllOutput, + MailSendInput, + MailSendOutput, + SendersGetAllInput, + SendersGetAllOutput, + SendGridEndpointInputs, + SendGridEndpointOutputs, + SuppressionsGetBouncesInput, + SuppressionsGetBouncesOutput, +} from './endpoints/types'; diff --git a/packages/sendgrid/integration.test.ts b/packages/sendgrid/integration.test.ts new file mode 100644 index 000000000..3d5654f02 --- /dev/null +++ b/packages/sendgrid/integration.test.ts @@ -0,0 +1,35 @@ +import { sendgrid } from './index'; + +describe('SendGrid Plugin Integration', () => { + it('instantiates plugin with default options', () => { + const plugin = sendgrid({ key: 'SG.test_key' }); + expect(plugin.id).toBe('sendgrid'); + expect(plugin.options?.key).toBe('SG.test_key'); + }); + + it('defines endpoints tree correctly', () => { + const plugin = sendgrid({ key: 'SG.test_key' }); + expect(plugin.endpoints?.mail?.send).toBeDefined(); + expect(plugin.endpoints?.contacts?.addOrUpdate).toBeDefined(); + expect(plugin.endpoints?.lists?.getAll).toBeDefined(); + expect(plugin.endpoints?.lists?.create).toBeDefined(); + expect(plugin.endpoints?.suppressions?.getBounces).toBeDefined(); + expect(plugin.endpoints?.senders?.getAll).toBeDefined(); + expect(plugin.endpoints?.segments?.refresh).toBeDefined(); + expect(plugin.endpoints?.apiKeys?.create).toBeDefined(); + expect(Object.keys(plugin.endpointSchemas ?? {})).toHaveLength(100); + }); + + it('registers no webhooks', () => { + const plugin = sendgrid({ key: 'SG.test_key' }); + expect(plugin.webhooks).toEqual({}); + expect(plugin.pluginWebhookMatcher).toBeUndefined(); + }); + + it('registers endpoint metadata correctly', () => { + const plugin = sendgrid({ key: 'SG.test_key' }); + expect(plugin.endpointMeta?.['mail.send']?.riskLevel).toBe('write'); + expect(plugin.endpointMeta?.['lists.getAll']?.riskLevel).toBe('read'); + expect(plugin.endpointMeta?.['senders.getAll']?.riskLevel).toBe('read'); + }); +}); diff --git a/packages/sendgrid/jest.config.cjs b/packages/sendgrid/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/sendgrid/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/sendgrid/package.json b/packages/sendgrid/package.json new file mode 100644 index 000000000..b515169bf --- /dev/null +++ b/packages/sendgrid/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/sendgrid", + "version": "0.1.0", + "description": "SendGrid v3 plugin for Corsair (100 REST endpoints)", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "sendgrid", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/sendgrid/schema.test.ts b/packages/sendgrid/schema.test.ts new file mode 100644 index 000000000..d5d916a7c --- /dev/null +++ b/packages/sendgrid/schema.test.ts @@ -0,0 +1,68 @@ +import { SendGridSchema } from './schema'; +import { + SendGridBounce, + SendGridContact, + SendGridList, + SendGridVerifiedSender, +} from './schema/database'; + +describe('SendGrid schema', () => { + it('declares a semver version', () => { + expect(SendGridSchema.version).toBeDefined(); + expect(SendGridSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares official entity maps', () => { + expect(Object.keys(SendGridSchema.entities).sort()).toEqual( + ['bounces', 'contacts', 'lists', 'senders'].sort(), + ); + }); + + it('parses official VerifiedSenderResponse example', () => { + const sender = SendGridVerifiedSender.parse({ + id: 1234, + nickname: 'Example Orders', + from_email: 'orders@example.com', + from_name: 'Example Orders', + reply_to: 'orders@example.com', + reply_to_name: 'Example Orders', + address: '1234 Fake St.', + address2: 'PO Box 1234', + state: 'CA', + city: 'San Francisco', + country: 'USA', + zip: '94105', + verified: true, + locked: false, + }); + expect(sender.from_email).toBe('orders@example.com'); + }); + + it('parses official bounce suppression record', () => { + const bounce = SendGridBounce.parse({ + created: 1251606766, + email: 'test@example.com', + reason: '500 unknown recipient', + status: '5.0.0', + }); + expect(bounce.email).toBe('test@example.com'); + }); + + it('parses official marketing list and contact request', () => { + const list = SendGridList.parse({ + id: 'e1', + name: 'Newsletter', + contact_count: 12, + }); + expect(list.contact_count).toBe(12); + + const contact = SendGridContact.parse({ + email: 'alex@example.com', + first_name: 'Alex', + last_name: 'Bloggs', + city: 'Port Douglas', + country: 'AU', + }); + expect(contact.email).toBe('alex@example.com'); + }); +}); diff --git a/packages/sendgrid/schema/database.ts b/packages/sendgrid/schema/database.ts new file mode 100644 index 000000000..32c99e573 --- /dev/null +++ b/packages/sendgrid/schema/database.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +/** + * Marketing Campaigns contact (ContactRequest / stored contact fields). + * Official: PUT /v3/marketing/contacts + * https://www.twilio.com/docs/sendgrid/api-reference/contacts/add-or-update-a-contact + */ +export const SendGridContact = z + .object({ + id: z.string().optional(), + email: z.string().email().optional(), + phone_number_id: z.string().optional(), + external_id: z.string().optional(), + anonymous_id: z.string().optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + address_line_1: z.string().optional(), + address_line_2: z.string().optional(), + alternate_emails: z.array(z.string()).optional(), + city: z.string().optional(), + country: z.string().optional(), + postal_code: z.string().optional(), + state_province_region: z.string().optional(), + custom_fields: z + .record(z.string(), z.union([z.string(), z.number()])) + .optional(), + }) + .catchall(z.unknown()); + +export type SendGridContact = z.infer; + +/** + * Marketing Campaigns list. + * Official: GET/POST /v3/marketing/lists + * https://www.twilio.com/docs/sendgrid/api-reference/lists/get-all-lists + */ +export const SendGridList = z + .object({ + id: z.string(), + name: z.string(), + contact_count: z.number(), + _metadata: z.record(z.string(), z.unknown()).optional(), + }) + .catchall(z.unknown()); + +export type SendGridList = z.infer; + +/** + * Bounce suppression record. + * Official: GET /v3/suppression/bounces + * https://www.twilio.com/docs/sendgrid/api-reference/bounces-api/retrieve-all-bounces + */ +export const SendGridBounce = z + .object({ + created: z.number(), + email: z.string(), + reason: z.string(), + status: z.string(), + }) + .catchall(z.unknown()); + +export type SendGridBounce = z.infer; + +/** + * Verified sender identity. + * Official: GET /v3/verified_senders (VerifiedSenderResponse) + * https://www.twilio.com/docs/sendgrid/api-reference/sender-verification/get-all-verified-senders + */ +export const SendGridVerifiedSender = z + .object({ + id: z.number(), + nickname: z.string(), + from_email: z.string(), + from_name: z.string().optional(), + reply_to: z.string().optional(), + reply_to_name: z.string().optional(), + address: z.string().optional(), + address2: z.string().optional(), + state: z.string().optional(), + city: z.string().optional(), + zip: z.string().optional(), + country: z.string().optional(), + verified: z.boolean(), + locked: z.boolean().optional(), + }) + .catchall(z.unknown()); + +export type SendGridVerifiedSender = z.infer; diff --git a/packages/sendgrid/schema/index.ts b/packages/sendgrid/schema/index.ts new file mode 100644 index 000000000..74c6a7ae0 --- /dev/null +++ b/packages/sendgrid/schema/index.ts @@ -0,0 +1,23 @@ +import { + SendGridBounce, + SendGridContact, + SendGridList, + SendGridVerifiedSender, +} from './database'; + +export const SendGridSchema = { + version: '1.0.0', + entities: { + contacts: SendGridContact, + lists: SendGridList, + bounces: SendGridBounce, + senders: SendGridVerifiedSender, + }, +} as const; + +export { + SendGridBounce, + SendGridContact, + SendGridList, + SendGridVerifiedSender, +} from './database'; diff --git a/packages/sendgrid/sendgrid_demo.mp4 b/packages/sendgrid/sendgrid_demo.mp4 new file mode 100644 index 000000000..22a681455 Binary files /dev/null and b/packages/sendgrid/sendgrid_demo.mp4 differ diff --git a/packages/sendgrid/tsconfig.json b/packages/sendgrid/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/sendgrid/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/sendgrid/tsup.config.ts b/packages/sendgrid/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/sendgrid/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dad7573d8..85c5c03de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5043,6 +5043,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/sendgrid: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/sentry: devDependencies: '@types/jest':