Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>Hi there</p>",
});
```

The existing `APIClient` and `TrackClient` surfaces are unchanged.
27 changes: 27 additions & 0 deletions examples/email-quickstart.ts
Original file line number Diff line number Diff line change
@@ -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: `<p>It works. Sent at ${new Date().toISOString()}</p>`,
});
console.log(`✓ Sent. delivery_id=${delivery_id}`);
})().catch((err) => {
console.error(err);
process.exit(1);
});
33 changes: 33 additions & 0 deletions lib/email-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type SendEmailInput = {
to: string;
from: string;
subject: string;
body: string;
reply_to?: string;
headers?: Record<string, string>;
};

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<string, unknown>;
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');
}
}
}
}
43 changes: 43 additions & 0 deletions lib/email.ts
Original file line number Diff line number Diff line change
@@ -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<SendEmailResponse> {
validateSendEmailInput(input);
return this.request.post(`${this.apiRoot}/send/email`, input) as Promise<SendEmailResponse>;
}
}
10 changes: 8 additions & 2 deletions lib/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion lib/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = '4.4.0';
export const version = '4.5.0';
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
161 changes: 161 additions & 0 deletions test/email.ts
Original file line number Diff line number Diff line change
@@ -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<TestContext>;

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: '<p>hi</p>',
});
t.truthy(
(stub as SinonStub).calledWith(`${RegionUS.apiUrl}/send/email`, {
to: 'a@example.com',
from: 'b@example.com',
subject: 's',
body: '<p>hi</p>',
}),
);
});

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' },
}),
);
});
14 changes: 14 additions & 0 deletions test/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number>)['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');
Expand Down
Loading