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');