diff --git a/README.md b/README.md index 9a16512..4bc56fa 100644 --- a/README.md +++ b/README.md @@ -529,3 +529,25 @@ npm install && npm test ## License Released under the MIT license. See file [LICENSE](LICENSE) for more details. + +## Sending email (preview) + +The `EmailClient` is a small surface for sending a single transactional email +using a unified API key (prefix `sdk_live_`). It is currently in preview and +is gated by the `sdk_email_send_preview` workspace flag — keys that have not +been enrolled will receive a 403 response. + +```ts +import { EmailClient } from "customerio-node"; + +const cio = new EmailClient({ apiKey: process.env.CIO_API_KEY! }); + +const { delivery_id } = await cio.send({ + to: "user@example.com", + from: "verified@yourdomain.com", + subject: "Hello", + body: "

Hi there

", +}); +``` + +The existing `APIClient` and `TrackClient` surfaces are unchanged. diff --git a/examples/email-quickstart.ts b/examples/email-quickstart.ts new file mode 100644 index 0000000..b57070a --- /dev/null +++ b/examples/email-quickstart.ts @@ -0,0 +1,27 @@ +// One-screen demo. Runs end-to-end against the real Edge endpoint. +// +// export CIO_API_KEY="sdk_live_xxxxxxxxxxxx" +// npx tsx examples/email-quickstart.ts + +import { EmailClient } from '../lib/email'; + +const apiKey = process.env.CIO_API_KEY; +if (!apiKey) { + console.error('Set CIO_API_KEY first.'); + process.exit(1); +} + +const cio = new EmailClient({ apiKey }); + +(async () => { + const { delivery_id } = await cio.send({ + to: 'josh.simmons+win@customer.io', + from: 'win@customer.io', + subject: 'Hello from the SDK', + body: `

It works. Sent at ${new Date().toISOString()}

`, + }); + console.log(`✓ Sent. delivery_id=${delivery_id}`); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/lib/email-input.ts b/lib/email-input.ts new file mode 100644 index 0000000..f76db53 --- /dev/null +++ b/lib/email-input.ts @@ -0,0 +1,33 @@ +export type SendEmailInput = { + to: string; + from: string; + subject: string; + body: string; + reply_to?: string; + headers?: Record; +}; + +export function validateSendEmailInput(input: unknown): asserts input is SendEmailInput { + if (typeof input !== 'object' || input === null) { + throw new Error('SendEmailInput must be an object'); + } + const o = input as Record; + for (const f of ['to', 'from', 'subject', 'body'] as const) { + if (typeof o[f] !== 'string' || (o[f] as string).length === 0) { + throw new Error(`SendEmailInput.${f} is required and must be a non-empty string`); + } + } + if (o.reply_to !== undefined && typeof o.reply_to !== 'string') { + throw new Error('SendEmailInput.reply_to must be a string when provided'); + } + if (o.headers !== undefined) { + if (typeof o.headers !== 'object' || o.headers === null) { + throw new Error('SendEmailInput.headers must be an object when provided'); + } + for (const [, v] of Object.entries(o.headers)) { + if (typeof v !== 'string') { + throw new Error('SendEmailInput.headers must map string->string'); + } + } + } +} diff --git a/lib/email.ts b/lib/email.ts new file mode 100644 index 0000000..da6b29c --- /dev/null +++ b/lib/email.ts @@ -0,0 +1,43 @@ +import Request, { BearerAuth } from './request'; +import { Region, RegionUS } from './regions'; +import { SendEmailInput, validateSendEmailInput } from './email-input'; + +// The server-side gate keys off this exact substring in the User-Agent header. +// Keep in sync with serve.SDKPreviewUserAgentMarker in customerio/edge. +export const SDK_PREVIEW_USER_AGENT_MARKER = 'CioEmailSDK-Preview'; + +export type EmailClientOptions = { + apiKey: BearerAuth; + region?: Region; + url?: string; +}; + +export type SendEmailResponse = { + delivery_id: string; +}; + +export class EmailClient { + apiKey: BearerAuth; + region: Region; + apiRoot: string; + request: Request; + + constructor(opts: EmailClientOptions) { + if (!opts.apiKey) { + throw new Error('EmailClient: apiKey is required'); + } + if (opts.region && !(opts.region instanceof Region)) { + throw new Error('region must be one of Regions.US or Regions.EU'); + } + + this.apiKey = opts.apiKey; + this.region = opts.region || RegionUS; + this.apiRoot = opts.url ? opts.url : this.region.apiUrl; + this.request = new Request(this.apiKey, undefined, SDK_PREVIEW_USER_AGENT_MARKER); + } + + send(input: SendEmailInput): Promise { + validateSendEmailInput(input); + return this.request.post(`${this.apiRoot}/send/email`, input) as Promise; + } +} diff --git a/lib/request.ts b/lib/request.ts index 3d06ca8..7676107 100644 --- a/lib/request.ts +++ b/lib/request.ts @@ -34,8 +34,9 @@ export default class CIORequest { appKey?: BearerAuth; auth: string; defaults: RequestOptions; + userAgentSuffix?: string; - constructor(auth: RequestAuth, defaults?: RequestOptions) { + constructor(auth: RequestAuth, defaults?: RequestOptions, userAgentSuffix?: string) { if (typeof auth === 'object') { this.apikey = auth.apikey; this.siteid = auth.siteid; @@ -52,15 +53,20 @@ export default class CIORequest { }, defaults, ); + + this.userAgentSuffix = userAgentSuffix; } options(uri: string, method: RequestOptions['method'], data?: RequestData): RequestHandlerOptions { const body = data ? JSON.stringify(data) : null; + const userAgent = this.userAgentSuffix + ? `Customer.io Node Client/${version} ${this.userAgentSuffix}` + : `Customer.io Node Client/${version}`; const headers = { Authorization: this.auth, 'Content-Type': 'application/json', 'Content-Length': body ? Buffer.byteLength(body, 'utf8') : 0, - 'User-Agent': `Customer.io Node Client/${version}`, + 'User-Agent': userAgent, }; return { method, uri, headers, body }; diff --git a/lib/version.ts b/lib/version.ts index ef8cba1..5d50793 100644 --- a/lib/version.ts +++ b/lib/version.ts @@ -1 +1 @@ -export const version = '4.4.0'; +export const version = '4.5.0'; diff --git a/package.json b/package.json index f59ee10..974d243 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "customerio-node", "description": "A node client for the Customer.io event API. http://customer.io", - "version": "4.4.0", + "version": "4.5.0", "author": "Customer.io (https://customer.io)", "contributors": [ "Alvin Crespo (https://github.com/alvincrespo)", diff --git a/test/email.ts b/test/email.ts new file mode 100644 index 0000000..7c8e590 --- /dev/null +++ b/test/email.ts @@ -0,0 +1,161 @@ +import avaTest, { TestFn } from 'ava'; +import sinon, { SinonStub } from 'sinon'; +import { EmailClient } from '../lib/email'; +import { RegionUS, RegionEU } from '../lib/regions'; + +type TestContext = { client: EmailClient }; + +const test = avaTest as TestFn; + +test.beforeEach((t) => { + t.context.client = new EmailClient({ apiKey: 'sdk_live_test' }); +}); + +test('constructor sets necessary variables', (t) => { + t.is(t.context.client.apiKey, 'sdk_live_test'); + t.truthy(t.context.client.request); + t.is(t.context.client.apiRoot, RegionUS.apiUrl); +}); + +test('constructor sets correct URL for different regions', (t) => { + [RegionUS, RegionEU].forEach((region) => { + const client = new EmailClient({ apiKey: 'sdk_live_test', region }); + t.is(client.apiRoot, region.apiUrl); + }); +}); + +test('constructor sets correct URL for a custom URL', (t) => { + const client = new EmailClient({ apiKey: 'sdk_live_test', url: 'https://example.com' }); + t.is(client.apiRoot, 'https://example.com'); +}); + +test('constructor throws when apiKey is missing', (t) => { + t.throws(() => new EmailClient({ apiKey: '' }), { message: /apiKey is required/ }); +}); + +test('constructor throws on invalid region', (t) => { + t.throws(() => new EmailClient({ apiKey: 'sdk_live_test', region: 'au' as any }), { + message: 'region must be one of Regions.US or Regions.EU', + }); +}); + +test('send: POSTs to /send/email with the input as body', (t) => { + const stub = sinon.stub(t.context.client.request, 'post'); + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: '

hi

', + }); + t.truthy( + (stub as SinonStub).calledWith(`${RegionUS.apiUrl}/send/email`, { + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: '

hi

', + }), + ); +}); + +test('send: throws on missing to', (t) => { + t.throws(() => t.context.client.send({ to: '', from: 'b@example.com', subject: 's', body: 'b' } as any), { + message: /SendEmailInput.to is required/, + }); +}); + +test('send: throws on missing from', (t) => { + t.throws(() => t.context.client.send({ to: 'a@example.com', from: '', subject: 's', body: 'b' } as any), { + message: /SendEmailInput.from is required/, + }); +}); + +test('send: throws on missing subject', (t) => { + t.throws( + () => + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: '', + body: 'b', + } as any), + { message: /SendEmailInput.subject is required/ }, + ); +}); + +test('send: throws on missing body', (t) => { + t.throws( + () => + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: '', + } as any), + { message: /SendEmailInput.body is required/ }, + ); +}); + +test('send: throws on non-string reply_to', (t) => { + t.throws( + () => + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: 'b', + reply_to: 123 as any, + }), + { message: /reply_to must be a string/ }, + ); +}); + +test('send: throws on non-string headers values', (t) => { + t.throws( + () => + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: 'b', + headers: { 'X-Test': 123 as any }, + }), + { message: /headers must map string->string/ }, + ); +}); + +test('send: throws when input is not an object', (t) => { + t.throws(() => t.context.client.send(null as any), { + message: /SendEmailInput must be an object/, + }); + t.throws(() => t.context.client.send('a string' as any), { + message: /SendEmailInput must be an object/, + }); +}); + +test('send: throws when headers is not an object', (t) => { + t.throws( + () => + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: 'b', + headers: 'not an object' as any, + }), + { message: /headers must be an object when provided/ }, + ); +}); + +test('send: accepts valid optional fields', (t) => { + sinon.stub(t.context.client.request, 'post'); + t.notThrows(() => + t.context.client.send({ + to: 'a@example.com', + from: 'b@example.com', + subject: 's', + body: 'b', + reply_to: 'replyto@example.com', + headers: { 'X-Test': 'ok' }, + }), + ); +}); diff --git a/test/request.ts b/test/request.ts index 0fb26fe..774ab2b 100644 --- a/test/request.ts +++ b/test/request.ts @@ -101,6 +101,20 @@ test.serial('constructor sets default timeout correctly for app api', (t) => { t.deepEqual(req.defaults, { timeout: 10000 }); }); +test.serial('constructor accepts an optional userAgentSuffix', (t) => { + const req = new Request(appKey, undefined, 'CioEmailSDK-Preview'); + t.is(req.userAgentSuffix, 'CioEmailSDK-Preview'); +}); + +test.serial('#options User-Agent includes userAgentSuffix when set', (t) => { + const req = new Request(appKey, undefined, 'CioEmailSDK-Preview'); + const opts = req.options(uri, 'POST'); + t.is( + (opts.headers as Record)['User-Agent'], + `Customer.io Node Client/${PACKAGE_VERSION} CioEmailSDK-Preview`, + ); +}); + test.serial('#options returns a correctly formatted object', (t) => { const expectedOptions = Object.assign({}, baseOptions, { method: 'POST', body: null }); const resultOptions = t.context.req.options(uri, 'POST');