diff --git a/.gitignore b/.gitignore
index 1e04e7b..4e836c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,3 +85,11 @@ coverage/
# Test files
image.jpg
+
+# Real API tests — local only
+# Real API tests — local only
+src/test_real.ts
+dist/test_real.js
+dist/test_real.d.ts
+test_real.ts
+test-progress.ts
diff --git a/README.md b/README.md
index 4f97188..e2bde1b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# CloudContactAI Node.js Client - [CloudContactAI](https://www.cloudcontactai.com)
-A TypeScript client for the Cloud Contact AI API that allows you to easily send SMS and MMS messages, and handle webhook callbacks.
+A TypeScript client for the Cloud Contact AI API that allows you to easily send SMS and MMS messages, send email campaigns, manage webhooks, and manage contact opt-out preferences.
## Requirements
@@ -116,9 +116,139 @@ async function sendMmsWithImage() {
sendMmsWithImage();
```
+### Email
+
+```typescript
+import { CCAI } from 'ccai-node';
+
+const ccai = new CCAI({
+ clientId: 'YOUR-CLIENT-ID',
+ apiKey: 'API-KEY-TOKEN'
+});
+
+// Send a single email
+const response = await ccai.email.sendSingle(
+ 'John',
+ 'Doe',
+ 'john@example.com',
+ 'Welcome to Our Service',
+ '
Hello ${firstName},
Thank you for signing up!
',
+ 'noreply@yourcompany.com',
+ 'support@yourcompany.com',
+ 'Your Company',
+ 'Welcome Email'
+);
+console.log('Email sent:', response);
+
+// Send an email campaign to multiple recipients
+const accounts = [
+ { firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
+ { firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' }
+];
+
+const campaignResponse = await ccai.email.send(
+ accounts,
+ 'Monthly Newsletter',
+ 'Hello ${firstName}!
Monthly updates...
',
+ 'newsletter@yourcompany.com',
+ 'support@yourcompany.com',
+ 'Your Company Newsletter',
+ 'July 2025 Newsletter'
+);
+console.log('Campaign sent:', campaignResponse);
+```
+
+### Contact
+
+Manage opt-out preferences for contacts.
+
+```typescript
+import { CCAI } from 'ccai-node';
+
+const ccai = new CCAI({
+ clientId: 'YOUR-CLIENT-ID',
+ apiKey: 'API-KEY-TOKEN'
+});
+
+// Opt a contact out of text messages (by phone number)
+const result = await ccai.contact.setDoNotText(true, undefined, '+15551234567');
+console.log('Opted out:', result);
+
+// Opt a contact back in (by phone number)
+await ccai.contact.setDoNotText(false, undefined, '+15551234567');
+
+// Opt out by contactId
+await ccai.contact.setDoNotText(true, 'contact-abc-123');
+```
+
### Webhooks
-CloudContactAI can send webhook notifications when certain events occur, such as when messages are sent or received. The library provides utilities to handle these webhook events in Next.js applications.
+CloudContactAI can send webhook notifications when certain events occur, such as when messages are sent or received. Use the Webhook service to register, manage, and verify webhooks programmatically.
+
+#### Managing Webhooks
+
+```typescript
+import { CCAI, WebhookConfig, WebhookEventType } from 'ccai-node';
+
+const ccai = new CCAI({
+ clientId: 'YOUR-CLIENT-ID',
+ apiKey: 'API-KEY-TOKEN'
+});
+
+// Example 1: Register a new webhook - server generates secret automatically
+const webhookConfig: WebhookConfig = {
+ url: 'https://your-app.com/api/ccai-webhook',
+ // secret is optional - if not provided, server generates one automatically
+};
+const webhook = await ccai.webhook.register(webhookConfig);
+console.log('Webhook registered with ID:', webhook.id);
+console.log('Secret Key:', webhook.secretKey); // Save this securely!
+
+// Example 2: Register with custom secret and event types
+const webhookCustomConfig: WebhookConfig = {
+ url: 'https://your-app.com/api/custom-webhook',
+ secret: 'your-custom-secret', // optional - user-provided secret
+ events: [
+ WebhookEventType.MESSAGE_SENT,
+ WebhookEventType.MESSAGE_RECEIVED
+ ]
+};
+const webhookCustom = await ccai.webhook.register(webhookCustomConfig);
+console.log('Custom secret webhook registered:', webhookCustom.id);
+
+// List all registered webhooks
+const webhooks = await ccai.webhook.list();
+console.log('Registered webhooks:', webhooks.length);
+webhooks.forEach(wh => {
+ console.log(`- ID: ${wh.id}, URL: ${wh.url}`);
+});
+
+// Update a webhook
+const updateConfig: WebhookConfig = {
+ url: 'https://your-app.com/api/new-webhook',
+ events: [WebhookEventType.MESSAGE_SENT]
+};
+const updated = await ccai.webhook.update(webhook.id, updateConfig);
+console.log('Updated webhook URL:', updated.url);
+
+// Delete a webhook
+await ccai.webhook.delete(webhook.id);
+
+// Verify webhook signature (in your incoming request handler)
+const signature = req.headers['x-ccai-signature'] as string;
+const clientId = ccai.clientId;
+const eventHash = req.body.eventHash as string; // From the webhook payload
+const secret = 'your-webhook-secret';
+const isValid = ccai.webhook.verifySignature(signature, clientId, eventHash, secret);
+
+// Parse incoming webhook event
+const event = ccai.webhook.parseEvent(JSON.stringify(req.body));
+console.log('Event type:', event.eventType); // 'message.sent' | 'message.received'
+console.log('Event data:', event.data);
+console.log('To:', event.data.To);
+console.log('From:', event.data.From);
+console.log('Message:', event.data.Message);
+```
#### Webhook Events
@@ -132,36 +262,34 @@ CloudContactAI currently supports the following webhook events:
**Message Sent Event:**
```json
{
- "type": "message.sent",
- "campaign": {
- "id": 123,
- "title": "Default Campaign",
- "message": "",
- "senderPhone": "+11234567894",
- "createdAt": "2025-07-14 22:18:28.273",
- "runAt": ""
- },
- "from": "+11234567894",
- "to": "+11453215437",
- "message": "this is a test message for Jon Doe"
+ "eventType": "message.sent",
+ "eventHash": "abc123def456ghi789",
+ "data": {
+ "To": "+15551234567",
+ "From": "+15551234567",
+ "Message": "Hello John, this is a test message",
+ "TotalPrice": "0.01",
+ "Segments": 1,
+ "CampaignId": "123",
+ "CampaignTitle": "Test Campaign"
+ }
}
```
**Message Received Event:**
```json
{
- "type": "message.received",
- "campaign": {
- "id": 123,
- "title": "Default Campaign",
- "message": "",
- "senderPhone": "+11234567894",
- "createdAt": "2025-07-14 22:18:28.273",
- "runAt": ""
- },
- "from": "+11453215437",
- "to": "+11234567894",
- "message": "this is a reply message from Jon Doe"
+ "eventType": "message.received",
+ "eventHash": "xyz789abc123def456",
+ "data": {
+ "To": "+15551234567",
+ "From": "+15559876543",
+ "Message": "Reply from customer",
+ "TotalPrice": "0.01",
+ "Segments": 1,
+ "CampaignId": "123",
+ "CampaignTitle": "Test Campaign"
+ }
}
```
@@ -170,30 +298,34 @@ CloudContactAI currently supports the following webhook events:
```typescript
// pages/api/ccai-webhook.ts
import type { NextApiRequest, NextApiResponse } from 'next';
-import { createWebhookHandler, WebhookEventType } from 'ccai-node';
+import { createWebhookHandler, WebhookEventType, type WebhookEvent } from 'ccai-node';
export default createWebhookHandler({
// Optional: Secret for verifying webhook signatures
secret: process.env.CCAI_WEBHOOK_SECRET,
- // Handler for outbound messages
- onMessageSent: async (event) => {
+ // Handler for outbound messages (type-safe)
+ onMessageSent: async (event: WebhookEvent) => {
console.log('Message sent event received:');
- console.log(`Campaign: ${event.campaign.title} (ID: ${event.campaign.id})`);
- console.log(`From: ${event.from}`);
- console.log(`To: ${event.to}`);
- console.log(`Message: ${event.message}`);
+ console.log(`Event Type: ${event.eventType}`);
+ console.log(`Event Hash: ${event.eventHash}`);
+ console.log('Event Data:');
+ console.log(`- To: ${event.data.To}`);
+ console.log(`- From: ${event.data.From}`);
+ console.log(`- Message: ${event.data.Message}`);
+ console.log(`- Campaign ID: ${event.data.CampaignId}`);
// Your custom logic here
},
- // Handler for inbound messages
- onMessageReceived: async (event) => {
+ // Handler for inbound messages (type-safe)
+ onMessageReceived: async (event: WebhookEvent) => {
console.log('Message received event received:');
- console.log(`Campaign: ${event.campaign.title} (ID: ${event.campaign.id})`);
- console.log(`From: ${event.from}`);
- console.log(`To: ${event.to}`);
- console.log(`Message: ${event.message}`);
+ console.log(`Event Type: ${event.eventType}`);
+ console.log('Event Data:');
+ console.log(`- From: ${event.data.From}`);
+ console.log(`- To: ${event.data.To}`);
+ console.log(`- Message: ${event.data.Message}`);
// Your custom logic here
},
@@ -205,22 +337,47 @@ export default createWebhookHandler({
#### Simple Webhook Handler
-If you prefer a simpler approach, you can handle webhooks manually:
+If you prefer a simpler approach, you can handle webhooks manually with type safety:
```typescript
// pages/api/simple-webhook.ts
import type { NextApiRequest, NextApiResponse } from 'next';
+import { CCAI, type WebhookEvent, WebhookEventType } from 'ccai-node';
+
+const ccai = new CCAI({
+ clientId: process.env.CCAI_CLIENT_ID!,
+ apiKey: process.env.CCAI_API_KEY!
+});
export default (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
- const payload = req.body;
+ const payload = req.body as WebhookEvent;
console.log('Webhook payload:', payload);
+ console.log('Event Type:', payload.eventType);
+ console.log('Event Hash:', payload.eventHash);
+
+ // Verify signature if you have the secret
+ const signature = req.headers['x-ccai-signature'] as string;
+ const secret = process.env.CCAI_WEBHOOK_SECRET;
+
+ if (secret && !ccai.webhook.verifySignature(
+ signature,
+ process.env.CCAI_CLIENT_ID!,
+ payload.eventHash,
+ secret
+ )) {
+ return res.status(401).json({ error: 'Invalid signature' });
+ }
- // Process the webhook based on its type
- if (payload.type === 'message.sent') {
+ // Process the webhook based on its eventType (type-safe)
+ if (payload.eventType === WebhookEventType.MESSAGE_SENT) {
// Handle outbound message event
- } else if (payload.type === 'message.received') {
+ console.log('Message sent to:', payload.data.To);
+ console.log('Total Price:', payload.data.TotalPrice);
+ } else if (payload.eventType === WebhookEventType.MESSAGE_RECEIVED) {
// Handle inbound message event
+ console.log('Message received from:', payload.data.From);
+ console.log('Message:', payload.data.Message);
}
// Always respond with a 200 status code
@@ -275,10 +432,15 @@ async function sendMessages() {
- `src/` - Source code
- `ccai.ts` - Main CCAI client class
- - `sms/` - SMS-related functionality
+ - `sms/` - SMS and MMS functionality
- `sms.ts` - SMS service class
- `mms.ts` - MMS service class
- - `webhook/` - Webhook-related functionality
+ - `email/` - Email functionality
+ - `email.ts` - Email service class
+ - `contact/` - Contact management
+ - `contact.ts` - Contact service class (opt-out)
+ - `webhook/` - Webhook functionality
+ - `webhook.ts` - Webhook service (register, list, update, delete, verify)
- `types.ts` - Type definitions for webhook events
- `nextjs.ts` - Next.js integration utilities
- `index.ts` - Main exports
@@ -377,12 +539,14 @@ This project includes a `.gitignore` file that excludes:
- TypeScript support with full type definitions
- Promise-based API with async/await support
-- Support for sending SMS to multiple recipients
-- Support for sending MMS with images
-- Upload images to S3 with signed URLs
-- Webhook integration for real-time event notifications
+- Send SMS to single or multiple recipients
+- Send MMS with images (automatic upload to S3)
+- Send Email campaigns with HTML content to single or multiple recipients
+- Manage contact opt-out preferences (setDoNotText)
+- Webhook management: register, list, update, delete
+- Webhook signature verification (HMAC-SHA256)
- Next.js API route handlers for webhook events
-- Support for template variables (firstName, lastName)
+- Template variable substitution (`${firstName}`, `${lastName}`)
- Progress tracking via callbacks
- Comprehensive error handling
- Unit tests with Jest
diff --git a/jest.config.js b/jest.config.js
index c07d9d2..dfd1866 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,7 +8,11 @@ module.exports = {
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/examples/**',
- '!src/**/index.ts'
+ '!src/**/index.ts',
+ '!src/*_send.ts',
+ '!src/test_real.ts',
+ '!src/webhook/types.ts',
+ '!src/webhook/nextjs.ts'
],
coverageThreshold: {
global: {
diff --git a/src/__tests__/ccai.test.ts b/src/__tests__/ccai.test.ts
new file mode 100644
index 0000000..cdfdf19
--- /dev/null
+++ b/src/__tests__/ccai.test.ts
@@ -0,0 +1,154 @@
+import axios from 'axios';
+import { CCAI } from '../ccai';
+
+jest.mock('axios');
+// biome-ignore lint/suspicious/noExplicitAny: test mock requires any cast
+const mockedAxios = axios as any;
+
+describe('CCAI Client', () => {
+ const validConfig = {
+ clientId: 'test-client-123',
+ apiKey: 'test-api-key-456',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should create client with valid config', () => {
+ const ccai = new CCAI(validConfig);
+ expect(ccai.getClientId()).toBe('test-client-123');
+ expect(ccai.getApiKey()).toBe('test-api-key-456');
+ });
+
+ it('should use production URLs by default', () => {
+ const ccai = new CCAI(validConfig);
+ expect(ccai.getBaseUrl()).toContain('core.cloudcontactai.com');
+ expect(ccai.getEmailBaseUrl()).toContain('email-campaigns.cloudcontactai.com');
+ });
+
+ it('should use test environment URLs when useTestEnvironment is true', () => {
+ const ccai = new CCAI({ ...validConfig, useTestEnvironment: true });
+ expect(ccai.getBaseUrl()).toContain('core-test-cloudcontactai.allcode.com');
+ expect(ccai.getEmailBaseUrl()).toContain('email-campaigns-test-cloudcontactai.allcode.com');
+ });
+
+ it('should allow custom baseUrl override', () => {
+ const ccai = new CCAI({ ...validConfig, baseUrl: 'https://custom.api.com' });
+ expect(ccai.getBaseUrl()).toBe('https://custom.api.com');
+ });
+
+ it('should expose sms, mms, email, webhook, contact services', () => {
+ const ccai = new CCAI(validConfig);
+ expect(ccai.sms).toBeDefined();
+ expect(ccai.mms).toBeDefined();
+ expect(ccai.email).toBeDefined();
+ expect(ccai.webhook).toBeDefined();
+ expect(ccai.contact).toBeDefined();
+ });
+
+ it('should allow custom emailBaseUrl override', () => {
+ const ccai = new CCAI({ ...validConfig, emailBaseUrl: 'https://custom-email.com' });
+ expect(ccai.getEmailBaseUrl()).toBe('https://custom-email.com');
+ });
+
+ it('should allow custom filesBaseUrl override', () => {
+ const ccai = new CCAI({ ...validConfig, filesBaseUrl: 'https://custom-files.com' });
+ expect(ccai.getFilesBaseUrl()).toBe('https://custom-files.com');
+ });
+
+ it('should correctly report test environment status', () => {
+ const ccaiProd = new CCAI(validConfig);
+ const ccaiTest = new CCAI({ ...validConfig, useTestEnvironment: true });
+ expect(ccaiProd.isTestEnvironment()).toBe(false);
+ expect(ccaiTest.isTestEnvironment()).toBe(true);
+ });
+ });
+
+ describe('request method', () => {
+ it('should make a successful POST request', async () => {
+ mockedAxios.mockResolvedValueOnce({
+ data: { success: true, id: '123' },
+ });
+
+ const ccai = new CCAI(validConfig);
+ const result = await ccai.request('POST', '/test', { test: 'data' });
+
+ expect(result).toEqual({ success: true, id: '123' });
+ expect(mockedAxios).toHaveBeenCalled();
+ });
+
+ it('should handle axios error with response', async () => {
+ mockedAxios.mockRejectedValueOnce({
+ response: { status: 400, data: { error: 'Bad request' } },
+ });
+
+ const ccai = new CCAI(validConfig);
+ await expect(ccai.request('POST', '/test')).rejects.toThrow('API Error: 400');
+ });
+
+ it('should handle axios error with no response', async () => {
+ mockedAxios.mockRejectedValueOnce({
+ request: {},
+ message: 'Network error',
+ });
+
+ const ccai = new CCAI(validConfig);
+ await expect(ccai.request('POST', '/test')).rejects.toThrow('No response received from API');
+ });
+
+ it('should handle unknown errors', async () => {
+ mockedAxios.mockRejectedValueOnce(new Error('Unknown error'));
+
+ const ccai = new CCAI(validConfig);
+ await expect(ccai.request('POST', '/test')).rejects.toThrow('Unknown error');
+ });
+ });
+
+ describe('customRequest method', () => {
+ it('should make a request to custom base URL with extra headers', async () => {
+ mockedAxios.mockResolvedValueOnce({
+ data: { result: 'ok' },
+ });
+
+ const ccai = new CCAI(validConfig);
+ const result = await ccai.customRequest('GET', '/custom', undefined, 'https://custom.com', {
+ 'X-Custom-Header': 'value',
+ });
+
+ expect(result).toEqual({ result: 'ok' });
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://custom.com/custom',
+ headers: expect.objectContaining({
+ 'X-Custom-Header': 'value',
+ }),
+ })
+ );
+ });
+
+ it('should handle custom request errors with response', async () => {
+ mockedAxios.mockRejectedValueOnce({
+ response: { status: 403, data: { error: 'Forbidden' } },
+ });
+
+ const ccai = new CCAI(validConfig);
+ await expect(
+ ccai.customRequest('POST', '/custom', { data: 'test' }, 'https://custom.com')
+ ).rejects.toThrow('API Error: 403');
+ });
+
+ it('should handle custom request errors with no response', async () => {
+ mockedAxios.mockRejectedValueOnce({
+ request: {},
+ message: 'Timeout',
+ });
+
+ const ccai = new CCAI(validConfig);
+ await expect(ccai.customRequest('POST', '/custom')).rejects.toThrow(
+ 'No response received from API'
+ );
+ });
+ });
+});
diff --git a/src/__tests__/contact.test.ts b/src/__tests__/contact.test.ts
new file mode 100644
index 0000000..9cd5411
--- /dev/null
+++ b/src/__tests__/contact.test.ts
@@ -0,0 +1,82 @@
+import { CCAI } from '../ccai';
+import { Contact } from '../contact/contact';
+
+const mockRequest = jest.fn();
+
+const mockCcai = {
+ getClientId: () => 'client-123',
+ getApiKey: () => 'api-key-456',
+ request: mockRequest,
+} as unknown as CCAI;
+
+const mockResponse = { contactId: 'contact-1', phone: '+15551234567', doNotText: true };
+
+describe('Contact Service', () => {
+ let contact: Contact;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ contact = new Contact(mockCcai);
+ mockRequest.mockResolvedValue(mockResponse);
+ });
+
+ describe('setDoNotText()', () => {
+ it('should opt-out a contact by phone', async () => {
+ const result = await contact.setDoNotText(true, undefined, '+15551234567');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'PUT',
+ '/account/do-not-text',
+ expect.objectContaining({
+ clientId: 'client-123',
+ doNotText: true,
+ phone: '+15551234567',
+ })
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should opt-in a contact by phone', async () => {
+ mockRequest.mockResolvedValue({ ...mockResponse, doNotText: false });
+ const result = await contact.setDoNotText(false, undefined, '+15551234567');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'PUT',
+ '/account/do-not-text',
+ expect.objectContaining({ doNotText: false })
+ );
+ expect(result.doNotText).toBe(false);
+ });
+
+ it('should opt-out a contact by contactId', async () => {
+ await contact.setDoNotText(true, 'contact-abc');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'PUT',
+ '/account/do-not-text',
+ expect.objectContaining({
+ clientId: 'client-123',
+ doNotText: true,
+ contactId: 'contact-abc',
+ })
+ );
+ });
+
+ it('should send request without contactId or phone if neither is provided', async () => {
+ // Contact service does not validate — sends payload with only clientId + doNotText
+ const result = await contact.setDoNotText(true);
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'PUT',
+ '/account/do-not-text',
+ expect.objectContaining({ clientId: 'client-123', doNotText: true })
+ );
+ expect(result).toBeDefined();
+ });
+
+ it('should include clientId in all requests', async () => {
+ await contact.setDoNotText(true, undefined, '+15551234567');
+ const payload = mockRequest.mock.calls[0][2] as Record;
+ expect(payload.clientId).toBe('client-123');
+ });
+ });
+});
diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts
new file mode 100644
index 0000000..4f4df22
--- /dev/null
+++ b/src/__tests__/email.test.ts
@@ -0,0 +1,489 @@
+import { CCAI } from '../ccai';
+import { Email } from '../email/email';
+
+const mockRequest = jest.fn();
+const mockCustomRequest = jest.fn();
+
+const mockCcai = {
+ getClientId: () => 'client-123',
+ getApiKey: () => 'api-key-456',
+ getBaseUrl: () => 'https://core.cloudcontactai.com/api',
+ getEmailBaseUrl: () => 'https://email-campaigns.cloudcontactai.com/api/v1',
+ request: mockRequest,
+ customRequest: mockCustomRequest,
+} as unknown as CCAI;
+
+const mockResponse = { id: 'email-1', campaignId: 'camp-1', status: 'sent' };
+const validAccount = {
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john@example.com',
+ phone: '+15551234567',
+};
+
+describe('Email Service', () => {
+ let email: Email;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ email = new Email(mockCcai);
+ mockCustomRequest.mockResolvedValue(mockResponse);
+ });
+
+ describe('send()', () => {
+ it('should send email campaign to multiple recipients', async () => {
+ const accounts = [
+ validAccount,
+ { firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com', phone: '+15559876543' },
+ ];
+ const result = await email.send(
+ accounts,
+ 'Test Subject',
+ 'Hello!
',
+ 'sender@example.com',
+ 'reply@example.com',
+ 'Test Sender'
+ );
+
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({
+ subject: 'Test Subject',
+ message: 'Hello!
',
+ senderEmail: 'sender@example.com',
+ accounts,
+ }),
+ expect.any(String),
+ expect.objectContaining({ AccountId: 'client-123', ClientId: 'client-123' })
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should throw if accounts array is empty', async () => {
+ await expect(
+ email.send([], 'Subject', 'Hi
', 'sender@test.com', 'reply@test.com', 'Sender')
+ ).rejects.toThrow();
+ });
+
+ it('should throw if subject is missing', async () => {
+ await expect(
+ email.send([validAccount], '', 'Hi
', 'sender@test.com', 'reply@test.com', 'Sender')
+ ).rejects.toThrow();
+ });
+
+ it('should throw if message is missing', async () => {
+ await expect(
+ email.send([validAccount], 'Subject', '', 'sender@test.com', 'reply@test.com', 'Sender')
+ ).rejects.toThrow();
+ });
+
+ it('should throw if senderEmail is missing', async () => {
+ await expect(
+ email.send([validAccount], 'Subject', 'Hi
', '', 'reply@test.com', 'Sender')
+ ).rejects.toThrow();
+ });
+
+ it('should throw if replyEmail is missing', async () => {
+ await expect(
+ email.send([validAccount], 'Subject', 'Hi
', 'sender@test.com', '', 'Sender')
+ ).rejects.toThrow();
+ });
+
+ it('should send email with custom senderName', async () => {
+ await email.send(
+ [validAccount],
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Custom Sender'
+ );
+
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({ senderName: 'Custom Sender' }),
+ expect.any(String),
+ expect.any(Object)
+ );
+ });
+ });
+
+ describe('sendSingle()', () => {
+ it('should send email to a single recipient', async () => {
+ const result = await email.sendSingle(
+ 'John',
+ 'Doe',
+ 'john@example.com',
+ 'Welcome',
+ 'Hello John!
',
+ 'noreply@company.com',
+ 'support@company.com',
+ 'Company',
+ 'Welcome Email'
+ );
+
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({
+ subject: 'Welcome',
+ accounts: [expect.objectContaining({ email: 'john@example.com' })],
+ }),
+ expect.any(String),
+ expect.any(Object)
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should throw if email is missing', async () => {
+ await expect(
+ email.sendSingle(
+ 'John',
+ 'Doe',
+ '',
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Sender',
+ 'Campaign'
+ )
+ ).rejects.toThrow();
+ });
+
+ it('should throw if firstName is missing', async () => {
+ await expect(
+ email.sendSingle(
+ '',
+ 'Doe',
+ 'john@example.com',
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Sender',
+ 'Campaign'
+ )
+ ).rejects.toThrow();
+ });
+ });
+
+ describe('sendCampaign()', () => {
+ it('should send a full campaign object', async () => {
+ const campaign = {
+ subject: 'Newsletter',
+ title: 'Monthly Newsletter',
+ message: 'Updates!
',
+ senderEmail: 'news@company.com',
+ replyEmail: 'support@company.com',
+ senderName: 'Company News',
+ accounts: [validAccount],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ const result = await email.sendCampaign(campaign);
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({ subject: 'Newsletter' }),
+ expect.any(String),
+ expect.any(Object)
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should send campaign with multiple senders', async () => {
+ const campaign = {
+ subject: 'Promotional Email',
+ title: 'Promo Campaign',
+ message: 'Special offer!
',
+ senderEmail: 'promo@company.com',
+ replyEmail: 'support@company.com',
+ senderName: 'Promo Team',
+ accounts: [validAccount],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [{ senderEmail: 'alt@company.com', senderName: 'Alternative' }],
+ };
+
+ const result = await email.sendCampaign(campaign);
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({
+ subject: 'Promotional Email',
+ senders: expect.arrayContaining([
+ expect.objectContaining({ senderEmail: 'alt@company.com' }),
+ ]),
+ }),
+ expect.any(String),
+ expect.any(Object)
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should call onProgress callback with campaign lifecycle events', async () => {
+ const onProgress = jest.fn();
+ const campaign = {
+ subject: 'Test Campaign',
+ title: 'Test',
+ message: 'Test
',
+ senderEmail: 'sender@test.com',
+ replyEmail: 'reply@test.com',
+ senderName: 'Sender',
+ accounts: [validAccount],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ await email.sendCampaign(campaign, { onProgress });
+
+ expect(onProgress).toHaveBeenCalledWith('Preparing to send email campaign');
+ expect(onProgress).toHaveBeenCalledWith('Sending email campaign');
+ expect(onProgress).toHaveBeenCalledWith('Email campaign sent successfully');
+ });
+
+ it('should call onProgress with error message on failure', async () => {
+ const onProgress = jest.fn();
+ mockCustomRequest.mockRejectedValueOnce(new Error('Send failed'));
+
+ const campaign = {
+ subject: 'Test Campaign',
+ title: 'Test',
+ message: 'Test
',
+ senderEmail: 'sender@test.com',
+ replyEmail: 'reply@test.com',
+ senderName: 'Sender',
+ accounts: [validAccount],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ await expect(email.sendCampaign(campaign, { onProgress })).rejects.toThrow();
+ expect(onProgress).toHaveBeenCalledWith('Email campaign sending failed');
+ });
+
+ it('should validate account firstName, lastName, email in campaign', async () => {
+ const campaign = {
+ subject: 'Test',
+ title: 'Test',
+ message: 'Test
',
+ senderEmail: 'sender@test.com',
+ replyEmail: 'reply@test.com',
+ senderName: 'Sender',
+ accounts: [
+ {
+ firstName: '',
+ lastName: 'Doe',
+ email: 'john@example.com',
+ phone: '+1234567890',
+ },
+ ],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ await expect(email.sendCampaign(campaign)).rejects.toThrow('First name is required');
+ });
+
+ it('should throw if campaign title is missing', async () => {
+ const campaign = {
+ subject: 'Test',
+ title: '',
+ message: 'Test
',
+ senderEmail: 'sender@test.com',
+ replyEmail: 'reply@test.com',
+ senderName: 'Sender',
+ accounts: [validAccount],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ await expect(email.sendCampaign(campaign)).rejects.toThrow('Campaign title is required');
+ });
+
+ it('should throw if campaign senderName is missing', async () => {
+ const campaign = {
+ subject: 'Test',
+ title: 'Test',
+ message: 'Test
',
+ senderEmail: 'sender@test.com',
+ replyEmail: 'reply@test.com',
+ senderName: '',
+ accounts: [validAccount],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ await expect(email.sendCampaign(campaign)).rejects.toThrow('Sender name is required');
+ });
+
+ it('should throw if account lastName is missing in campaign', async () => {
+ const campaign = {
+ subject: 'Test',
+ title: 'Test',
+ message: 'Test
',
+ senderEmail: 'sender@test.com',
+ replyEmail: 'reply@test.com',
+ senderName: 'Sender',
+ accounts: [
+ {
+ firstName: 'John',
+ lastName: '',
+ email: 'john@example.com',
+ phone: '+1234567890',
+ },
+ ],
+ campaignType: 'EMAIL' as const,
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ await expect(email.sendCampaign(campaign)).rejects.toThrow('Last name is required');
+ });
+ });
+
+ describe('EmailAccount custom fields', () => {
+ it('should send EmailAccount with customAccountId to API', async () => {
+ const accountWithCustomId = {
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john@example.com',
+ phone: '+15551234567',
+ customAccountId: 'ext-id-12345',
+ };
+
+ await email.send(
+ [accountWithCustomId],
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Sender'
+ );
+
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({
+ accounts: [expect.objectContaining({ customAccountId: 'ext-id-12345' })],
+ }),
+ expect.any(String),
+ expect.any(Object)
+ );
+ });
+
+ it('should send EmailAccount with data field to API', async () => {
+ const accountWithData = {
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john@example.com',
+ phone: '+15551234567',
+ data: { tier: 'gold', locale: 'en-US' },
+ };
+
+ await email.send(
+ [accountWithData],
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Sender'
+ );
+
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({
+ accounts: [expect.objectContaining({ data: { tier: 'gold', locale: 'en-US' } })],
+ }),
+ expect.any(String),
+ expect.any(Object)
+ );
+ });
+
+ it('should map customData to messageData in email accounts (wire format)', async () => {
+ const accountWithCustomData = {
+ firstName: 'Jane',
+ lastName: 'Doe',
+ email: 'jane@example.com',
+ phone: '',
+ customData: '{"source":"email-test"}',
+ };
+
+ await email.send(
+ [accountWithCustomData],
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Sender'
+ );
+
+ expect(mockCustomRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/campaigns',
+ expect.objectContaining({
+ accounts: [expect.objectContaining({ messageData: '{"source":"email-test"}' })],
+ }),
+ expect.any(String),
+ expect.any(Object)
+ );
+
+ // customData must NOT appear in the wire payload
+ const sentPayload = mockCustomRequest.mock.calls[0][2] as {
+ accounts: Record[];
+ };
+ expect(sentPayload.accounts[0]?.customData).toBeUndefined();
+ });
+ });
+
+ describe('EmailResponse fields', () => {
+ it('should return message and responseId from API response', async () => {
+ mockCustomRequest.mockResolvedValueOnce({
+ id: 'email-1',
+ campaignId: 'camp-1',
+ status: 'sent',
+ message: 'Email campaign sent successfully',
+ responseId: 'resp-id-xyz',
+ });
+
+ const result = await email.send(
+ [validAccount],
+ 'Subject',
+ 'Hi
',
+ 'sender@test.com',
+ 'reply@test.com',
+ 'Sender'
+ );
+
+ expect(result.message).toBe('Email campaign sent successfully');
+ expect(result.responseId).toBe('resp-id-xyz');
+ });
+ });
+});
diff --git a/src/__tests__/mms.test.ts b/src/__tests__/mms.test.ts
new file mode 100644
index 0000000..a5a7299
--- /dev/null
+++ b/src/__tests__/mms.test.ts
@@ -0,0 +1,286 @@
+import { CCAI } from '../ccai';
+import { MMS } from '../sms/mms';
+
+jest.mock('axios');
+import axios from 'axios';
+const mockedAxios = axios as jest.Mocked;
+
+const mockRequest = jest.fn();
+
+const mockCcai = {
+ getClientId: () => 'client-123',
+ getApiKey: () => 'api-key-456',
+ getBaseUrl: () => 'https://core.cloudcontactai.com/api',
+ getFilesBaseUrl: () => 'https://files.cloudcontactai.com',
+ request: mockRequest,
+} as unknown as CCAI;
+
+const validAccount = { firstName: 'John', lastName: 'Doe', phone: '+15551234567' };
+const mockSendResponse = { id: 'mms-1', campaignId: 'camp-1', status: 'sent' };
+
+describe('MMS Service', () => {
+ let mms: MMS;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mms = new MMS(mockCcai);
+ mockedAxios.post = jest.fn().mockResolvedValue({ data: mockSendResponse });
+ mockedAxios.put = jest.fn().mockResolvedValue({ status: 200 });
+ mockRequest.mockResolvedValue(mockSendResponse);
+ });
+
+ describe('send()', () => {
+ it('should send MMS with a file key via axios POST', async () => {
+ const result = await mms.send(
+ 'client-123/campaign/image.png',
+ [validAccount],
+ 'Check this out!',
+ 'MMS Campaign'
+ );
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ 'https://core.cloudcontactai.com/api/clients/client-123/campaigns/direct',
+ expect.objectContaining({
+ pictureFileKey: 'client-123/campaign/image.png',
+ accounts: [validAccount],
+ message: 'Check this out!',
+ title: 'MMS Campaign',
+ }),
+ expect.objectContaining({
+ headers: expect.objectContaining({ Authorization: 'Bearer api-key-456' }),
+ })
+ );
+ expect(result).toEqual(mockSendResponse);
+ });
+
+ it('should throw if accounts array is empty', async () => {
+ await expect(mms.send('file-key', [], 'msg', 'title')).rejects.toThrow();
+ });
+
+ it('should throw if pictureFileKey is missing', async () => {
+ await expect(mms.send('', [validAccount], 'msg', 'title')).rejects.toThrow();
+ });
+
+ it('should throw if message is missing', async () => {
+ await expect(mms.send('file-key', [validAccount], '', 'title')).rejects.toThrow();
+ });
+
+ it('should throw if title is missing', async () => {
+ await expect(mms.send('file-key', [validAccount], 'msg', '')).rejects.toThrow();
+ });
+
+ it('should throw if account is missing firstName', async () => {
+ const invalidAccount = { firstName: '', lastName: 'Doe', phone: '+15551234567' };
+ await expect(mms.send('file-key', [invalidAccount], 'msg', 'title')).rejects.toThrow();
+ });
+
+ it('should throw if account is missing lastName', async () => {
+ const invalidAccount = { firstName: 'John', lastName: '', phone: '+15551234567' };
+ await expect(mms.send('file-key', [invalidAccount], 'msg', 'title')).rejects.toThrow();
+ });
+
+ it('should throw if account is missing phone', async () => {
+ const invalidAccount = { firstName: 'John', lastName: 'Doe', phone: '' };
+ await expect(mms.send('file-key', [invalidAccount], 'msg', 'title')).rejects.toThrow();
+ });
+
+ it('should call onProgress callback when provided', async () => {
+ const onProgress = jest.fn();
+ await mms.send('file-key', [validAccount], 'msg', 'title', undefined, { onProgress });
+ expect(onProgress).toHaveBeenCalledWith('Preparing to send MMS');
+ expect(onProgress).toHaveBeenCalledWith('Sending MMS');
+ expect(onProgress).toHaveBeenCalledWith('MMS sent successfully');
+ });
+
+ it('should include ForceNewCampaign header by default', async () => {
+ await mms.send('file-key', [validAccount], 'msg', 'title');
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ expect.objectContaining({
+ headers: expect.objectContaining({ ForceNewCampaign: 'true' }),
+ })
+ );
+ });
+
+ it('should not include ForceNewCampaign header when forceNewCampaign is false', async () => {
+ mockedAxios.post.mockResolvedValueOnce({ data: mockSendResponse });
+ await mms.send('file-key', [validAccount], 'msg', 'title', undefined, {}, false);
+ const calls = mockedAxios.post.mock.calls;
+ const lastCall = calls[calls.length - 1];
+ expect(lastCall).toBeDefined();
+ expect(lastCall?.[2]).not.toHaveProperty('headers.ForceNewCampaign');
+ });
+
+ it('should call onProgress with error message on failure', async () => {
+ const onProgress = jest.fn();
+ mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
+
+ await expect(
+ mms.send('file-key', [validAccount], 'msg', 'title', undefined, { onProgress })
+ ).rejects.toThrow();
+ expect(onProgress).toHaveBeenCalledWith('MMS sending failed');
+ });
+
+ it('should handle axios post error gracefully', async () => {
+ mockedAxios.post.mockRejectedValueOnce(new Error('Request failed'));
+
+ await expect(mms.send('file-key', [validAccount], 'msg', 'title')).rejects.toThrow(
+ 'Failed to send MMS'
+ );
+ });
+
+ it('should map data and customData to wire format (data / messageData)', async () => {
+ const account = {
+ firstName: 'John',
+ lastName: 'Doe',
+ phone: '+15551234567',
+ data: { city: 'Miami', plan: 'premium' },
+ customData: '{"source":"mms-test"}',
+ };
+
+ await mms.send('file-key', [account], 'Hello ${firstName} from ${city}!', 'Test');
+
+ const postedBody = (mockedAxios.post as jest.Mock).mock.calls[0][1];
+ const sentAccount = postedBody.accounts[0];
+ expect(sentAccount.data).toEqual({ city: 'Miami', plan: 'premium' });
+ expect(sentAccount.messageData).toBe('{"source":"mms-test"}');
+ expect(sentAccount.customData).toBeUndefined();
+ });
+ });
+
+ describe('sendSingle()', () => {
+ it('should send MMS to a single recipient', async () => {
+ const result = await mms.sendSingle(
+ 'client-123/campaign/image.png',
+ 'John',
+ 'Doe',
+ '+15551234567',
+ 'Hello!',
+ 'MMS Test'
+ );
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ expect.stringContaining('/clients/client-123/campaigns/direct'),
+ expect.objectContaining({
+ pictureFileKey: 'client-123/campaign/image.png',
+ accounts: [{ firstName: 'John', lastName: 'Doe', phone: '+15551234567' }],
+ }),
+ expect.any(Object)
+ );
+ expect(result).toEqual(mockSendResponse);
+ });
+ });
+
+ describe('getSignedUploadUrl()', () => {
+ it('should get signed upload URL with fileName and fileType', async () => {
+ const mockSignedUrlResponse = {
+ signedS3Url: 'https://s3.example.com/signed-url',
+ fileKey: 'client-123/campaign/test.jpg',
+ };
+ mockedAxios.post.mockResolvedValue({ data: mockSignedUrlResponse });
+
+ const result = await mms.getSignedUploadUrl('test.jpg', 'image/jpeg');
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ 'https://files.cloudcontactai.com/upload/url',
+ expect.objectContaining({
+ fileName: 'test.jpg',
+ fileType: 'image/jpeg',
+ }),
+ expect.any(Object)
+ );
+ expect(result.signedS3Url).toBe('https://s3.example.com/signed-url');
+ });
+
+ it('should throw error if fileName is missing', async () => {
+ await expect(mms.getSignedUploadUrl('', 'image/jpeg')).rejects.toThrow(
+ 'File name is required'
+ );
+ });
+
+ it('should throw error if fileType is missing', async () => {
+ await expect(mms.getSignedUploadUrl('test.jpg', '')).rejects.toThrow('File type is required');
+ });
+
+ it('should use custom fileBasePath when provided', async () => {
+ const mockSignedUrlResponse = {
+ signedS3Url: 'https://s3.example.com/custom-url',
+ fileKey: 'client-123/custom/test.jpg',
+ };
+ mockedAxios.post.mockResolvedValue({ data: mockSignedUrlResponse });
+
+ await mms.getSignedUploadUrl('test.jpg', 'image/jpeg', 'custom/path');
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ fileBasePath: 'custom/path',
+ }),
+ expect.any(Object)
+ );
+ });
+
+ it('should include correct headers with API key', async () => {
+ mockedAxios.post.mockResolvedValue({
+ data: { signedS3Url: 'https://s3.example.com/url' },
+ });
+
+ await mms.getSignedUploadUrl('test.jpg', 'image/jpeg');
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer api-key-456',
+ 'Content-Type': 'application/json',
+ }),
+ })
+ );
+ });
+
+ it('should throw error if response is missing signedS3Url', async () => {
+ mockedAxios.post.mockResolvedValue({ data: { fileKey: 'test' } });
+
+ await expect(mms.getSignedUploadUrl('test.jpg', 'image/jpeg')).rejects.toThrow(
+ 'Invalid response from upload URL API'
+ );
+ });
+
+ it('should throw error if axios.post fails', async () => {
+ mockedAxios.post.mockRejectedValue(new Error('Network error'));
+
+ await expect(mms.getSignedUploadUrl('test.jpg', 'image/jpeg')).rejects.toThrow(
+ 'Failed to get signed upload URL'
+ );
+ });
+ });
+
+ describe('checkFileUploaded()', () => {
+ it('should return stored URL response when file exists', async () => {
+ const storedResponse = { storedUrl: 'https://cdn.example.com/file.png' };
+ mockRequest.mockResolvedValue(storedResponse);
+
+ const result = await mms.checkFileUploaded('client-123/campaign/image.png');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'GET',
+ '/clients/client-123/storedUrl?fileKey=client-123/campaign/image.png'
+ );
+ expect(result).toEqual(storedResponse);
+ });
+
+ it('should return object with empty storedUrl when file not found', async () => {
+ mockRequest.mockResolvedValue({ storedUrl: '' });
+ const result = await mms.checkFileUploaded('nonexistent-key');
+ expect(result).toEqual({ storedUrl: '' });
+ });
+
+ it('should return empty storedUrl object on request error', async () => {
+ mockRequest.mockRejectedValue(new Error('Not found'));
+ const result = await mms.checkFileUploaded('bad-key');
+ expect(result).toEqual({ storedUrl: '' });
+ });
+ });
+});
diff --git a/src/__tests__/sms.test.ts b/src/__tests__/sms.test.ts
new file mode 100644
index 0000000..82b4d05
--- /dev/null
+++ b/src/__tests__/sms.test.ts
@@ -0,0 +1,179 @@
+import { CCAI } from '../ccai';
+import { SMS } from '../sms/sms';
+
+const mockRequest = jest.fn();
+
+const mockCcai = {
+ getClientId: () => 'client-123',
+ getApiKey: () => 'api-key-456',
+ getBaseUrl: () => 'https://core.cloudcontactai.com/api',
+ request: mockRequest,
+} as unknown as CCAI;
+
+const validAccount = { firstName: 'John', lastName: 'Doe', phone: '+15551234567' };
+const mockResponse = { id: 'resp-1', campaignId: 'camp-1', status: 'sent' };
+
+describe('SMS Service', () => {
+ let sms: SMS;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ sms = new SMS(mockCcai);
+ mockRequest.mockResolvedValue(mockResponse);
+ });
+
+ describe('send()', () => {
+ it('should send SMS to multiple recipients', async () => {
+ const accounts = [
+ validAccount,
+ { firstName: 'Jane', lastName: 'Smith', phone: '+15559876543' },
+ ];
+ const result = await sms.send(accounts, 'Hello ${firstName}!', 'Test Campaign');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'post',
+ '/clients/client-123/campaigns/direct',
+ expect.objectContaining({
+ accounts,
+ message: 'Hello ${firstName}!',
+ title: 'Test Campaign',
+ })
+ );
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should throw if accounts array is empty', async () => {
+ await expect(sms.send([], 'Hello', 'Test')).rejects.toThrow();
+ });
+
+ it('should throw if message is missing', async () => {
+ await expect(sms.send([validAccount], '', 'Test')).rejects.toThrow();
+ });
+
+ it('should throw if title is missing', async () => {
+ await expect(sms.send([validAccount], 'Hello', '')).rejects.toThrow();
+ });
+
+ it('should throw if account is missing firstName', async () => {
+ const bad = { firstName: '', lastName: 'Doe', phone: '+15551234567' };
+ await expect(sms.send([bad], 'Hello', 'Test')).rejects.toThrow();
+ });
+
+ it('should throw if account is missing lastName', async () => {
+ const bad = { firstName: 'John', lastName: '', phone: '+15551234567' };
+ await expect(sms.send([bad], 'Hello', 'Test')).rejects.toThrow();
+ });
+
+ it('should throw if account is missing phone', async () => {
+ const bad = { firstName: 'John', lastName: 'Doe', phone: '' };
+ await expect(sms.send([bad], 'Hello', 'Test')).rejects.toThrow();
+ });
+
+ it('should call onProgress callback when provided', async () => {
+ const onProgress = jest.fn();
+ await sms.send([validAccount], 'Hello', 'Test', undefined, { onProgress });
+ expect(onProgress).toHaveBeenCalledWith('Preparing to send SMS');
+ expect(onProgress).toHaveBeenCalledWith('Sending SMS');
+ expect(onProgress).toHaveBeenCalledWith('SMS sent successfully');
+ });
+
+ it('should call onProgress with error message on failure', async () => {
+ const onProgress = jest.fn();
+ mockRequest.mockRejectedValueOnce(new Error('Send failed'));
+
+ await expect(
+ sms.send([validAccount], 'Hello', 'Test', undefined, { onProgress })
+ ).rejects.toThrow('Send failed');
+ expect(onProgress).toHaveBeenCalledWith('SMS sending failed');
+ });
+
+ it('should send data field in request (API wire format for variable substitution)', async () => {
+ // Base variables always available: ${firstName}, ${lastName}, ${phone}, ${email}
+ // data extends these with any additional key-value pairs the client defines
+ // CloudContact substitutes them in the message: "Hello ${firstName} from ${city}!"
+ const account = {
+ firstName: 'John',
+ lastName: 'Doe',
+ phone: '+15551234567',
+ data: { city: 'Miami', country: 'USA', plan: 'premium' },
+ };
+ await sms.send([account], 'Hello ${firstName} from ${city}, ${country}!', 'Test');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'post',
+ '/clients/client-123/campaigns/direct',
+ expect.objectContaining({
+ accounts: [
+ expect.objectContaining({
+ data: { city: 'Miami', country: 'USA', plan: 'premium' },
+ }),
+ ],
+ })
+ );
+ });
+
+ it('should send customData as messageData in request (API wire format)', async () => {
+ // customData is NOT used in the message — CloudContact forwards it as-is
+ // in the webhook payload so the client can process it on their end.
+ // The SDK maps customData → messageData before sending (Java @JsonProperty("messageData")).
+ const account = {
+ firstName: 'John',
+ lastName: 'Doe',
+ phone: '+15551234567',
+ customData: '{"orderId":"ORD-123","source":"checkout"}',
+ };
+ await sms.send([account], 'Your order ${orderId} is ready!', 'Test');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'post',
+ '/clients/client-123/campaigns/direct',
+ expect.objectContaining({
+ accounts: [
+ expect.objectContaining({
+ messageData: '{"orderId":"ORD-123","source":"checkout"}',
+ }),
+ ],
+ })
+ );
+ });
+ });
+
+ describe('SMSResponse fields', () => {
+ it('should return message and responseId from API response', async () => {
+ mockRequest.mockResolvedValueOnce({
+ id: 'resp-1',
+ campaignId: 'camp-1',
+ status: 'sent',
+ message: 'SMS sent successfully',
+ responseId: 'resp-id-abc',
+ });
+
+ const result = await sms.send([validAccount], 'Hello', 'Test');
+
+ expect(result.message).toBe('SMS sent successfully');
+ expect(result.responseId).toBe('resp-id-abc');
+ });
+ });
+
+ describe('sendSingle()', () => {
+ it('should send SMS to a single recipient', async () => {
+ const result = await sms.sendSingle('John', 'Doe', '+15551234567', 'Hello!', 'Test');
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'post',
+ '/clients/client-123/campaigns/direct',
+ expect.objectContaining({
+ accounts: [{ firstName: 'John', lastName: 'Doe', phone: '+15551234567' }],
+ message: 'Hello!',
+ title: 'Test',
+ })
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should throw if phone is empty', async () => {
+ await expect(sms.sendSingle('John', 'Doe', '', 'Hello', 'Test')).rejects.toThrow();
+ });
+ });
+});
diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts
new file mode 100644
index 0000000..7921531
--- /dev/null
+++ b/src/__tests__/webhook.test.ts
@@ -0,0 +1,294 @@
+import * as crypto from 'crypto';
+import { CCAI } from '../ccai';
+import { Webhook } from '../webhook/webhook';
+
+const mockRequest = jest.fn();
+
+const mockCcai = {
+ getClientId: () => 'client-123',
+ getApiKey: () => 'api-key-456',
+ request: mockRequest,
+} as unknown as CCAI;
+
+describe('Webhook Service', () => {
+ let webhook: Webhook;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ webhook = new Webhook(mockCcai);
+ });
+
+ describe('register()', () => {
+ it('should register a webhook without secret - server generates it', async () => {
+ const responseItem = {
+ id: '147',
+ url: 'https://example.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ secretKey: 'test-secret-key-12345',
+ };
+ mockRequest.mockResolvedValue([responseItem]);
+
+ const result = await webhook.register({ url: 'https://example.com/hook' });
+
+ // Verify secretKey was NOT sent to server (allowing server to generate)
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/v1/client/client-123/integration',
+ expect.arrayContaining([
+ expect.objectContaining({
+ url: 'https://example.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ }),
+ ])
+ );
+ const payload = mockRequest.mock.calls[0]?.[2] as Array> | undefined;
+ expect(payload?.[0]?.secretKey).toBeUndefined();
+
+ // Verify the auto-generated secret is returned
+ expect(result.secretKey).toBe('test-secret-key-12345');
+ });
+
+ it('should register a webhook with custom secret', async () => {
+ const customSecret = 'my-custom-secret-key';
+ const responseItem = {
+ id: '148',
+ url: 'https://example.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ secretKey: customSecret,
+ };
+ mockRequest.mockResolvedValue([responseItem]);
+
+ const result = await webhook.register({
+ url: 'https://example.com/hook',
+ secret: customSecret,
+ });
+
+ // Verify custom secret WAS sent to server
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/v1/client/client-123/integration',
+ expect.arrayContaining([
+ expect.objectContaining({
+ url: 'https://example.com/hook',
+ secretKey: customSecret,
+ }),
+ ])
+ );
+
+ // Verify the custom secret is returned
+ expect(result.secretKey).toBe(customSecret);
+ });
+
+ it('should register a webhook with secretKey field', async () => {
+ const customSecret = 'test-secret-key-67890';
+ const responseItem = {
+ id: '149',
+ url: 'https://example.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ secretKey: customSecret,
+ };
+ mockRequest.mockResolvedValue([responseItem]);
+
+ const result = await webhook.register({
+ url: 'https://example.com/hook',
+ secretKey: customSecret,
+ });
+
+ // Verify secretKey field is used
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/v1/client/client-123/integration',
+ expect.arrayContaining([
+ expect.objectContaining({
+ url: 'https://example.com/hook',
+ secretKey: customSecret,
+ }),
+ ])
+ );
+ expect(result.secretKey).toBe(customSecret);
+ });
+
+ it('should default integrationType to ALL', async () => {
+ mockRequest.mockResolvedValue([
+ { id: '1', url: 'https://test.com', method: 'POST', integrationType: 'ALL' },
+ ]);
+ await webhook.register({ url: 'https://test.com' });
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/v1/client/client-123/integration',
+ expect.arrayContaining([
+ expect.objectContaining({
+ integrationType: 'ALL',
+ }),
+ ])
+ );
+ });
+
+ it('should handle non-array response from API', async () => {
+ const nonArrayResponse = {
+ id: '999',
+ url: 'https://example.com/hook',
+ method: 'POST',
+ integrationType: 'SMS',
+ secretKey: 'sk_test_123',
+ };
+ mockRequest.mockResolvedValue(nonArrayResponse);
+
+ const result = await webhook.register({ url: 'https://example.com/hook' });
+
+ expect(result).toEqual(nonArrayResponse);
+ expect(result.id).toBe('999');
+ });
+ });
+
+ describe('update()', () => {
+ it('should update webhook using POST with id in array payload', async () => {
+ const responseItem = {
+ id: '42',
+ url: 'https://new.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ };
+ mockRequest.mockResolvedValue([responseItem]);
+
+ const result = await webhook.update('42', { url: 'https://new.com/hook' });
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/v1/client/client-123/integration',
+ expect.arrayContaining([expect.objectContaining({ id: 42, url: 'https://new.com/hook' })])
+ );
+ expect(result).toEqual(responseItem);
+ });
+
+ it('should include secretKey when updating with custom secret', async () => {
+ const responseItem = {
+ id: '42',
+ url: 'https://new.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ secretKey: 'custom-secret-123',
+ };
+ mockRequest.mockResolvedValue([responseItem]);
+
+ await webhook.update('42', { url: 'https://new.com/hook', secret: 'custom-secret-123' });
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/v1/client/client-123/integration',
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 42,
+ url: 'https://new.com/hook',
+ secretKey: 'custom-secret-123',
+ }),
+ ])
+ );
+ });
+
+ it('should handle non-array response from API in update', async () => {
+ const nonArrayResponse = {
+ id: '42',
+ url: 'https://new.com/hook',
+ method: 'POST',
+ integrationType: 'ALL',
+ };
+ mockRequest.mockResolvedValue(nonArrayResponse);
+
+ const result = await webhook.update('42', { url: 'https://new.com/hook' });
+
+ expect(result).toEqual(nonArrayResponse);
+ });
+ });
+
+ describe('list()', () => {
+ it('should list all webhooks via GET', async () => {
+ const webhooks = [
+ { id: '1', url: 'https://a.com', method: 'POST', integrationType: 'ALL' },
+ { id: '2', url: 'https://b.com', method: 'POST', integrationType: 'SMS' },
+ ];
+ mockRequest.mockResolvedValue(webhooks);
+
+ const result = await webhook.list();
+
+ expect(mockRequest).toHaveBeenCalledWith('GET', '/v1/client/client-123/integration');
+ expect(result).toEqual(webhooks);
+ });
+ });
+
+ describe('delete()', () => {
+ it('should delete a webhook by id via DELETE', async () => {
+ mockRequest.mockResolvedValue({ success: true, message: 'deleted' });
+
+ const result = await webhook.delete('42');
+
+ expect(mockRequest).toHaveBeenCalledWith('DELETE', '/v1/client/client-123/integration/42');
+ expect(result).toEqual({ success: true, message: 'deleted' });
+ });
+ });
+
+ describe('verifySignature()', () => {
+ const clientId = 'client-123';
+ const eventHash = 'event-hash-abc123';
+ const secret = 'test-webhook-secret';
+
+ function computeHmac(clientId: string | number, eventHash: string, key: string): string {
+ const data = `${clientId}:${eventHash}`;
+ return crypto.createHmac('sha256', key).update(data).digest('base64');
+ }
+
+ it('should return true for a valid signature', () => {
+ const validSig = computeHmac(clientId, eventHash, secret);
+ expect(webhook.verifySignature(validSig, clientId, eventHash, secret)).toBe(true);
+ });
+
+ it('should return false for an invalid signature', () => {
+ expect(webhook.verifySignature('invalidsig', clientId, eventHash, secret)).toBe(false);
+ });
+
+ it('should return false when signature is empty', () => {
+ expect(webhook.verifySignature('', clientId, eventHash, secret)).toBe(false);
+ });
+
+ it('should return false when clientId is missing', () => {
+ const validSig = computeHmac(clientId, eventHash, secret);
+ expect(webhook.verifySignature(validSig, '', eventHash, secret)).toBe(false);
+ });
+
+ it('should return false when eventHash is missing', () => {
+ const validSig = computeHmac(clientId, eventHash, secret);
+ expect(webhook.verifySignature(validSig, clientId, '', secret)).toBe(false);
+ });
+
+ it('should return false when secret is empty', () => {
+ const validSig = computeHmac(clientId, eventHash, secret);
+ expect(webhook.verifySignature(validSig, clientId, eventHash, '')).toBe(false);
+ });
+ });
+
+ describe('parseEvent()', () => {
+ it('should parse a valid webhook event JSON', () => {
+ const payload = JSON.stringify({
+ eventType: 'message.sent',
+ eventHash: 'hash-abc123',
+ data: {
+ To: '+15551234567',
+ Message: 'Hello',
+ },
+ });
+
+ const event = webhook.parseEvent(payload);
+ expect(event).toBeDefined();
+ expect(event.data.To).toBe('+15551234567');
+ });
+
+ it('should throw on invalid JSON', () => {
+ expect(() => webhook.parseEvent('not-json')).toThrow();
+ });
+ });
+});
diff --git a/src/ccai.ts b/src/ccai.ts
index 1973a62..dcc427b 100644
--- a/src/ccai.ts
+++ b/src/ccai.ts
@@ -1,60 +1,165 @@
/**
* ccai.ts - A TypeScript module for interacting with the Cloud Contact AI API
- * This module provides functionality to send SMS messages and email campaigns through the CCAI platform.
- *
+ * This module provides functionality to send SMS, MMS, and Email messages,
+ * manage webhooks, and handle contact preferences through the CCAI platform.
+ *
* @license MIT
* @copyright 2025 CloudContactAI LLC
*/
import axios, { AxiosResponse } from 'axios';
-import { SMS } from './sms/sms';
-import { MMS } from './sms/mms';
+import { Contact } from './contact/contact';
import { Email } from './email/email';
+import { MMS } from './sms/mms';
+import { SMS } from './sms/sms';
import { Webhook } from './webhook/webhook';
-// Define types for type safety
+// Production URLs
+const PROD_BASE_URL = 'https://core.cloudcontactai.com/api';
+const PROD_EMAIL_URL = 'https://email-campaigns.cloudcontactai.com/api/v1';
+const PROD_FILES_URL = 'https://files.cloudcontactai.com';
+
+// Test environment URLs
+const TEST_BASE_URL = 'https://core-test-cloudcontactai.allcode.com/api';
+const TEST_EMAIL_URL = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1';
+const TEST_FILES_URL = 'https://files-test-cloudcontactai.allcode.com';
+
+/**
+ * Account representing a message recipient.
+ *
+ * Base fields always available as template variables in messages:
+ * ${firstName}, ${lastName}, ${phone}, ${email}
+ */
export type Account = {
firstName: string;
lastName: string;
phone: string;
+ /**
+ * Additional key-value pairs for variable substitution in message templates.
+ * Define any keys you want and use them as ${key} in your message.
+ *
+ * Example:
+ * data: { city: "Miami", country: "USA" }
+ * message: "Hello ${firstName}, greetings from ${city}, ${country}!"
+ *
+ * CloudContact substitutes all matching variables automatically.
+ */
+ data?: Record;
+ /**
+ * Arbitrary string payload forwarded as-is to your webhook handler.
+ * Not used in the message body — useful for passing context (e.g. order ID, session data)
+ * that your system needs when processing the webhook event.
+ *
+ * Example: '{"orderId":"ORD-123","source":"checkout"}'
+ *
+ * Note: sent to the API as `messageData` (wire format) — the SDK handles the mapping.
+ */
+ customData?: string;
};
+/**
+ * Configuration options for the CCAI client
+ */
export type CCAIConfig = {
+ /** Client ID for authentication */
clientId: string;
+ /** API key for authentication */
apiKey: string;
+ /** Whether to use test environment URLs (default: false) */
+ useTestEnvironment?: boolean;
+ /** Override base URL for the core API */
baseUrl?: string;
+ /** Override base URL for the Email API */
+ emailBaseUrl?: string;
+ /** Override base URL for the Files API */
+ filesBaseUrl?: string;
};
+/**
+ * Main client for interacting with the CloudContactAI API
+ */
export class CCAI {
private clientId: string;
private apiKey: string;
private baseUrl: string;
+ private emailBaseUrl: string;
+ private filesBaseUrl: string;
+ private useTestEnvironment: boolean;
+
+ /** SMS service for sending text messages */
public sms: SMS;
+
+ /** MMS service for sending multimedia messages */
public mms: MMS;
+
+ /** Email service for sending email campaigns */
public email: Email;
+
+ /** Webhook service for managing webhook endpoints */
public webhook: Webhook;
+ /** Contact service for managing contact preferences */
+ public contact: Contact;
+
/**
* Create a new CCAI client instance
- * @param config - Configuration object containing clientId and apiKey
+ * @param config - Configuration object
*/
constructor(config: CCAIConfig) {
if (!config.clientId) throw new Error('Client ID is required');
if (!config.apiKey) throw new Error('API Key is required');
-
+
this.clientId = config.clientId;
this.apiKey = config.apiKey;
- this.baseUrl = config.baseUrl || 'https://core-test-cloudcontactai.allcode.com/api';
-
+ this.useTestEnvironment = config.useTestEnvironment ?? false;
+
+ // Resolve URLs: explicit override > env var > test/prod default
+ this.baseUrl = this.resolveUrl(
+ config.baseUrl,
+ process.env.CCAI_BASE_URL,
+ PROD_BASE_URL,
+ TEST_BASE_URL
+ );
+
+ this.emailBaseUrl = this.resolveUrl(
+ config.emailBaseUrl,
+ process.env.CCAI_EMAIL_BASE_URL,
+ PROD_EMAIL_URL,
+ TEST_EMAIL_URL
+ );
+
+ this.filesBaseUrl = this.resolveUrl(
+ config.filesBaseUrl,
+ process.env.CCAI_FILES_BASE_URL,
+ PROD_FILES_URL,
+ TEST_FILES_URL
+ );
+
// Initialize the services
this.sms = new SMS(this);
this.mms = new MMS(this);
this.email = new Email(this);
this.webhook = new Webhook(this);
+ this.contact = new Contact(this);
+ }
+
+ /**
+ * Resolve URL with priority: explicit > env > prod/test default
+ */
+ private resolveUrl(
+ explicit: string | undefined,
+ envVar: string | undefined,
+ prodDefault: string,
+ testDefault: string
+ ): string {
+ if (explicit) return explicit;
+ if (envVar) return envVar;
+ return this.useTestEnvironment ? testDefault : prodDefault;
}
/**
* Get the client ID
+ * @returns The client ID
*/
getClientId(): string {
return this.clientId;
@@ -62,117 +167,126 @@ export class CCAI {
/**
* Get the API key
+ * @returns The API key
*/
getApiKey(): string {
return this.apiKey;
}
/**
- * Get the base URL
+ * Get the base URL for the core API
+ * @returns The base URL
*/
getBaseUrl(): string {
return this.baseUrl;
}
+ /**
+ * Get the base URL for the Email API
+ * @returns The email base URL
+ */
+ getEmailBaseUrl(): string {
+ return this.emailBaseUrl;
+ }
+
+ /**
+ * Get the base URL for the Files API
+ * @returns The files base URL
+ */
+ getFilesBaseUrl(): string {
+ return this.filesBaseUrl;
+ }
+
+ /**
+ * Whether the test environment is active
+ * @returns True if using test environment
+ */
+ isTestEnvironment(): boolean {
+ return this.useTestEnvironment;
+ }
+
/**
* Make an authenticated API request to the CCAI API
- * @param method - HTTP method
- * @param endpoint - API endpoint
- * @param data - Request data
+ * @param method - HTTP method (get, post, put, delete)
+ * @param endpoint - API endpoint (relative to base URL)
+ * @param data - Request body data
* @returns Promise resolving to the API response
*/
async request(method: string, endpoint: string, data?: unknown): Promise {
const url = `${this.baseUrl}${endpoint}`;
-
+
try {
const response: AxiosResponse = await axios({
method,
url,
headers: {
- 'Authorization': `Bearer ${this.apiKey}`,
+ Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
- 'Accept': '*/*'
+ Accept: '*/*',
},
- data
+ data,
});
-
+
return response.data;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response: { status: number; data: unknown } };
- throw new Error(`API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`);
- } else if (error && typeof error === 'object' && 'request' in error) {
+ throw new Error(
+ `API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`
+ );
+ }
+ if (error && typeof error === 'object' && 'request' in error) {
throw new Error('No response received from API');
- } else {
- throw error;
}
+ throw error;
}
}
/**
* Make an authenticated API request to a custom API endpoint
- * @param method - HTTP method
- * @param endpoint - API endpoint
- * @param data - Request data
- * @param customBaseUrl - Custom base URL for the API
+ * @param method - HTTP method (get, post, put, delete)
+ * @param endpoint - API endpoint (relative to custom base URL)
+ * @param data - Request body data
+ * @param customBaseUrl - Custom base URL (defaults to core base URL)
+ * @param extraHeaders - Additional headers to merge with defaults
* @returns Promise resolving to the API response
*/
- async customRequest(method: string, endpoint: string, data?: unknown, customBaseUrl?: string): Promise {
+ async customRequest(
+ method: string,
+ endpoint: string,
+ data?: unknown,
+ customBaseUrl?: string,
+ extraHeaders?: Record
+ ): Promise {
const url = `${customBaseUrl || this.baseUrl}${endpoint}`;
-
- // Log equivalent curl command
- const curlCommand = this.generateCurlCommand(method, url, data);
- console.log('\n📡 Equivalent curl command:');
- console.log(curlCommand);
- console.log('');
-
+
+ const headers: Record = {
+ Authorization: `Bearer ${this.apiKey}`,
+ 'Content-Type': 'application/json',
+ Accept: '*/*',
+ ...extraHeaders,
+ };
+
try {
const response: AxiosResponse = await axios({
method,
url,
- headers: {
- 'Authorization': `Bearer ${this.apiKey}`,
- 'Content-Type': 'application/json',
- 'Accept': '*/*'
- },
- data
+ headers,
+ data,
});
-
+
return response.data;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response: { status: number; data: unknown } };
- throw new Error(`API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`);
- } else if (error && typeof error === 'object' && 'request' in error) {
+ throw new Error(
+ `API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`
+ );
+ }
+ if (error && typeof error === 'object' && 'request' in error) {
throw new Error('No response received from API');
- } else {
- throw error;
}
+ throw error;
}
}
-
- /**
- * Generate equivalent curl command for debugging
- * @param method - HTTP method
- * @param url - Full URL
- * @param data - Request data
- * @returns Curl command string
- */
- private generateCurlCommand(method: string, url: string, data?: unknown): string {
- let curl = `curl -X ${method.toUpperCase()} "${url}"`;
- curl += ` \\
- -H "Authorization: Bearer ${this.apiKey}"`;
- curl += ` \\
- -H "Content-Type: application/json"`;
- curl += ` \\
- -H "Accept: */*"`;
-
- if (data) {
- const jsonData = JSON.stringify(data, null, 2).replace(/"/g, '\\"');
- curl += ` \\
- -d "${jsonData}"`;
- }
-
- return curl;
- }
}
diff --git a/src/contact/contact.ts b/src/contact/contact.ts
new file mode 100644
index 0000000..84262c4
--- /dev/null
+++ b/src/contact/contact.ts
@@ -0,0 +1,65 @@
+/**
+ * contact.ts - A TypeScript module for managing contact preferences via CloudContactAI
+ *
+ * @license MIT
+ * @copyright 2025 CloudContactAI LLC
+ */
+
+import { CCAI } from '../ccai';
+
+/**
+ * Response from the setDoNotText API
+ */
+export type SetDoNotTextResponse = {
+ /** Contact ID */
+ contactId?: string;
+ /** Phone number */
+ phone?: string;
+ /** Whether the contact is opted out of text messages */
+ doNotText?: boolean;
+ /** Additional data from the API */
+ [key: string]: unknown;
+};
+
+/**
+ * Service for managing contact preferences (opt-out)
+ */
+export class Contact {
+ private ccai: CCAI;
+
+ /**
+ * Create a new Contact service instance
+ * @param ccai - The parent CCAI instance
+ */
+ constructor(ccai: CCAI) {
+ this.ccai = ccai;
+ }
+
+ /**
+ * Set the do-not-text preference for a contact
+ * @param doNotText - True to opt out, false to opt in
+ * @param contactId - Contact ID (optional if phone is provided)
+ * @param phone - Phone number in E.164 format (optional if contactId is provided)
+ * @returns Promise resolving to the API response
+ */
+ async setDoNotText(
+ doNotText: boolean,
+ contactId?: string,
+ phone?: string
+ ): Promise {
+ const payload: Record = {
+ clientId: this.ccai.getClientId(),
+ doNotText,
+ };
+
+ if (contactId !== undefined) {
+ payload.contactId = contactId;
+ }
+
+ if (phone !== undefined) {
+ payload.phone = phone;
+ }
+
+ return this.ccai.request('PUT', '/account/do-not-text', payload);
+ }
+}
diff --git a/src/email/email.ts b/src/email/email.ts
index b48e6c1..76aeb85 100644
--- a/src/email/email.ts
+++ b/src/email/email.ts
@@ -1,28 +1,36 @@
/**
* email.ts - Email service for the CCAI API
* Handles sending email campaigns through the Cloud Contact AI platform.
- *
+ *
* @license MIT
* @copyright 2025 CloudContactAI LLC
*/
-import { CCAI, Account } from '../ccai';
+import { Account, CCAI } from '../ccai';
+/**
+ * Email recipient account
+ */
export type EmailAccount = Account & {
email: string;
+ customAccountId?: string;
};
+/**
+ * Email campaign configuration
+ */
export type EmailCampaign = {
subject: string;
title: string;
message: string;
+ textContent?: string | null;
editor?: string | null;
fileKey?: string | null;
senderEmail: string;
replyEmail: string;
senderName: string;
accounts: EmailAccount[];
- campaignType: "EMAIL";
+ campaignType: 'EMAIL';
scheduledTimestamp?: string | null;
scheduledTimezone?: string | null;
addToList: string;
@@ -33,39 +41,40 @@ export type EmailCampaign = {
emailTemplateId?: string | null;
fluxId?: string | null;
fromType: string;
- senders: any[];
+ senders: Record[];
};
+/**
+ * Email API response
+ */
export type EmailResponse = {
- // Define the expected response structure from the API
id?: string;
status?: string;
campaignId?: string;
+ message?: string;
+ responseId?: string;
messagesSent?: number;
timestamp?: string;
[key: string]: unknown;
};
+/**
+ * Optional settings for email operations
+ */
export type EmailOptions = {
- /**
- * Optional timeout in milliseconds
- */
+ /** Request timeout in milliseconds */
timeout?: number;
-
- /**
- * Optional retry count for failed requests
- */
+ /** Retry count for failed requests */
retries?: number;
-
- /**
- * Optional callback for tracking progress
- */
+ /** Callback for tracking progress */
onProgress?: (status: string) => void;
};
+/**
+ * Service for sending email campaigns through the CCAI API
+ */
export class Email {
private ccai: CCAI;
- private baseUrl: string = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1';
/**
* Create a new Email service instance
@@ -76,104 +85,122 @@ export class Email {
}
/**
- * Make an authenticated API request to the email campaigns API with required headers
+ * Make an authenticated API request to the email campaigns API
+ * Uses the email base URL with AccountId and ClientId headers
* @param method - HTTP method
* @param endpoint - API endpoint
- * @param data - Request data
+ * @param data - Request body data
* @returns Promise resolving to the API response
*/
private async makeEmailRequest(method: string, endpoint: string, data?: unknown): Promise {
- const url = `${this.baseUrl}${endpoint}`;
-
- try {
- const axios = (await import('axios')).default;
- const response = await axios({
- method,
- url,
- headers: {
- 'Authorization': `Bearer ${this.ccai.getApiKey()}`,
- 'Content-Type': 'application/json',
- 'Accept': '*/*',
- 'clientId': this.ccai.getClientId(),
- 'accountId': '1223' // This should be configurable in the future
- },
- data
- });
-
- return response.data;
- } catch (error: unknown) {
- if (error && typeof error === 'object' && 'response' in error) {
- const axiosError = error as { response: { status: number; data: unknown } };
- throw new Error(`API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`);
- } else if (error && typeof error === 'object' && 'request' in error) {
- throw new Error('No response received from API');
- } else {
- throw error;
- }
- }
+ return this.ccai.customRequest(method, endpoint, data, this.ccai.getEmailBaseUrl(), {
+ AccountId: this.ccai.getClientId(),
+ ClientId: this.ccai.getClientId(),
+ });
}
/**
* Send an email campaign to one or more recipients
- * @param campaign - The email campaign configuration
- * @param options - Optional settings for the email send operation
+ * @param accounts - Array of recipient accounts
+ * @param subject - Email subject line
+ * @param message - The HTML message content
+ * @param senderEmail - Sender's email address
+ * @param replyEmail - Reply-to email address
+ * @param senderName - Sender's display name
+ * @param title - Campaign title (defaults to subject)
+ * @param options - Optional settings for progress tracking
* @returns Promise resolving to the API response
*/
- async sendCampaign(
- campaign: EmailCampaign,
+ async send(
+ accounts: EmailAccount[],
+ subject: string,
+ message: string,
+ senderEmail: string,
+ replyEmail: string,
+ senderName: string,
+ title?: string,
options?: EmailOptions
): Promise {
+ const campaignTitle = title || subject;
+
+ const campaign: EmailCampaign = {
+ subject,
+ title: campaignTitle,
+ message,
+ senderEmail,
+ replyEmail,
+ senderName,
+ accounts,
+ campaignType: 'EMAIL',
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
+ };
+
+ return this.sendCampaign(campaign, options);
+ }
+
+ /**
+ * Send an email campaign with full campaign configuration
+ * @param campaign - The complete email campaign configuration
+ * @param options - Optional settings for progress tracking
+ * @returns Promise resolving to the API response
+ */
+ async sendCampaign(campaign: EmailCampaign, options?: EmailOptions): Promise {
// Validate inputs
if (!campaign.accounts || !Array.isArray(campaign.accounts) || campaign.accounts.length === 0) {
throw new Error('At least one account is required');
}
-
+
if (!campaign.subject) throw new Error('Subject is required');
if (!campaign.title) throw new Error('Campaign title is required');
if (!campaign.message) throw new Error('Message content is required');
if (!campaign.senderEmail) throw new Error('Sender email is required');
if (!campaign.replyEmail) throw new Error('Reply email is required');
if (!campaign.senderName) throw new Error('Sender name is required');
-
+
// Validate each account has the required fields
campaign.accounts.forEach((account, index) => {
- if (!account.firstName) throw new Error(`First name is required for account at index ${index}`);
+ if (!account.firstName)
+ throw new Error(`First name is required for account at index ${index}`);
if (!account.lastName) throw new Error(`Last name is required for account at index ${index}`);
if (!account.email) throw new Error(`Email is required for account at index ${index}`);
});
-
+
// Notify progress if callback provided
if (options?.onProgress) {
options.onProgress('Preparing to send email campaign');
}
-
+
const endpoint = '/campaigns';
-
+
+ // Map customData → messageData (API wire format)
+ const mappedAccounts = campaign.accounts.map(({ data, customData, ...rest }) => ({
+ ...rest,
+ ...(data !== undefined ? { data } : {}),
+ ...(customData !== undefined ? { messageData: customData } : {}),
+ }));
+
+ const payload = { ...campaign, accounts: mappedAccounts };
+
try {
- // Notify progress if callback provided
if (options?.onProgress) {
options.onProgress('Sending email campaign');
}
-
- // Make the API request to the email campaigns API with custom headers
- const response = await this.makeEmailRequest(
- 'POST',
- endpoint,
- campaign
- );
-
- // Notify progress if callback provided
+
+ const response = await this.makeEmailRequest('POST', endpoint, payload);
+
if (options?.onProgress) {
options.onProgress('Email campaign sent successfully');
}
-
+
return response;
} catch (error) {
- // Notify progress if callback provided
if (options?.onProgress) {
options.onProgress('Email campaign sending failed');
}
-
+
throw error;
}
}
@@ -183,13 +210,13 @@ export class Email {
* @param firstName - Recipient's first name
* @param lastName - Recipient's last name
* @param email - Recipient's email address
- * @param subject - Email subject
+ * @param subject - Email subject line
* @param message - The HTML message content
* @param senderEmail - Sender's email address
* @param replyEmail - Reply-to email address
- * @param senderName - Sender's name
+ * @param senderName - Sender's display name
* @param title - Campaign title
- * @param options - Optional settings for the email send operation
+ * @param options - Optional settings for progress tracking
* @returns Promise resolving to the API response
*/
async sendSingle(
@@ -198,34 +225,36 @@ export class Email {
email: string,
subject: string,
message: string,
- senderEmail: string,
- replyEmail: string,
- senderName: string,
- title: string,
+ textContent?: string,
+ senderEmail = 'noreply@cloudcontactai.com',
+ replyEmail = 'noreply@cloudcontactai.com',
+ senderName = 'CloudContactAI',
+ title: string = subject,
options?: EmailOptions
): Promise {
const account: EmailAccount = {
firstName,
lastName,
email,
- phone: '' // Required by Account type but not used for email
+ phone: '', // Required by Account type but not used for email
};
-
+
const campaign: EmailCampaign = {
subject,
- title,
+ title: title || subject,
message,
+ ...(textContent ? { textContent } : {}),
senderEmail,
replyEmail,
senderName,
accounts: [account],
- campaignType: "EMAIL",
- addToList: "noList",
- contactInput: "accounts",
- fromType: "single",
- senders: []
+ campaignType: 'EMAIL',
+ addToList: 'noList',
+ contactInput: 'accounts',
+ fromType: 'single',
+ senders: [],
};
-
+
return this.sendCampaign(campaign, options);
}
}
diff --git a/src/email_send.ts b/src/email_send.ts
index e735a32..c05e92d 100644
--- a/src/email_send.ts
+++ b/src/email_send.ts
@@ -9,15 +9,15 @@ const ccai = new CCAI({
async function sendEmail() {
try {
const response = await ccai.email.sendSingle(
- "Andreas",
- "Doe",
- "andreas@allcode.com",
- "Test Email Subject",
- "Hello ${firstName},
This is a test email.
Thanks,
AllCode Team
",
- "noreply@allcode.com",
- "support@allcode.com",
- "AllCode Team",
- "Email Test Campaign"
+ 'Andreas',
+ 'Doe',
+ 'andreas@allcode.com',
+ 'Test Email Subject',
+ 'Hello ${firstName},
This is a test email.
Thanks,
AllCode Team
',
+ 'noreply@allcode.com',
+ 'support@allcode.com',
+ 'AllCode Team',
+ 'Email Test Campaign'
);
console.log('Email sent successfully:', response);
} catch (error) {
diff --git a/src/examples/async-example.ts b/src/examples/async-example.ts
index fde4cdb..22ce26c 100644
--- a/src/examples/async-example.ts
+++ b/src/examples/async-example.ts
@@ -1,6 +1,6 @@
/**
* Advanced example using async/await with progress tracking
- *
+ *
* @license MIT
* @copyright 2025 CloudContactAI LLC
*/
@@ -15,21 +15,21 @@ dotenv.config();
// Create a new CCAI client
const ccai = new CCAI({
clientId: process.env.CCAI_CLIENT_ID || '',
- apiKey: process.env.CCAI_API_KEY || ''
+ apiKey: process.env.CCAI_API_KEY || '',
});
// Example recipients
const accounts: Account[] = [
{
- firstName: "John",
- lastName: "Doe",
- phone: "+15551234567"
+ firstName: 'John',
+ lastName: 'Doe',
+ phone: '+15551234567',
},
{
- firstName: "Jane",
- lastName: "Smith",
- phone: "+15559876543"
- }
+ firstName: 'Jane',
+ lastName: 'Smith',
+ phone: '+15559876543',
+ },
];
// Options for SMS sending with progress tracking
@@ -38,7 +38,7 @@ const options: SMSOptions = {
retries: 2,
onProgress: (status: string) => {
console.log(`Progress: ${status}`);
- }
+ },
};
/**
@@ -47,18 +47,19 @@ const options: SMSOptions = {
async function sendMessagesWithTracking() {
try {
console.log('Starting SMS campaign...');
-
+
// Send campaign with progress tracking
const response = await ccai.sms.send(
accounts,
- "Hello ${firstName}, this is a tracked message!",
- "Tracked Campaign",
+ 'Hello ${firstName}, this is a tracked message!',
+ 'Tracked Campaign',
+ undefined,
options
);
-
+
console.log('Campaign completed successfully!');
console.log('Response:', JSON.stringify(response, null, 2));
-
+
return response;
} catch (error: unknown) {
if (error instanceof Error) {
@@ -75,32 +76,32 @@ async function sendMessagesWithTracking() {
*/
async function sendSequentialMessages() {
const results: SMSResponse[] = [];
-
+
try {
// Send first message
console.log('Sending first message...');
const firstResponse = await ccai.sms.sendSingle(
- "Alex",
- "Johnson",
- "+15551112222",
- "Hi ${firstName}, this is message 1!",
- "Sequential Test 1"
+ 'Alex',
+ 'Johnson',
+ '+15551112222',
+ 'Hi ${firstName}, this is message 1!',
+ 'Sequential Test 1'
);
results.push(firstResponse);
console.log('First message sent successfully!');
-
+
// Send second message only if first one succeeds
console.log('Sending second message...');
const secondResponse = await ccai.sms.sendSingle(
- "Maria",
- "Garcia",
- "+15553334444",
- "Hi ${firstName}, this is message 2!",
- "Sequential Test 2"
+ 'Maria',
+ 'Garcia',
+ '+15553334444',
+ 'Hi ${firstName}, this is message 2!',
+ 'Sequential Test 2'
);
results.push(secondResponse);
console.log('Second message sent successfully!');
-
+
return results;
} catch (error: unknown) {
if (error instanceof Error) {
@@ -108,7 +109,7 @@ async function sendSequentialMessages() {
} else {
console.error('Sequential sending failed with unknown error');
}
-
+
// Return partial results if any
if (results.length > 0) {
console.log(`Successfully sent ${results.length} messages before failure`);
@@ -123,12 +124,12 @@ async function runExamples() {
try {
// Run the tracked example
await sendMessagesWithTracking();
-
+
console.log('\n-----------------------------------\n');
-
+
// Run the sequential example
await sendSequentialMessages();
-
+
console.log('\nAll examples completed successfully!');
} catch (error: unknown) {
if (error instanceof Error) {
diff --git a/src/examples/basic-example.ts b/src/examples/basic-example.ts
index e24e5f3..8b8648d 100644
--- a/src/examples/basic-example.ts
+++ b/src/examples/basic-example.ts
@@ -15,21 +15,21 @@ dotenv.config();
// Create a new CCAI client
const ccai = new CCAI({
clientId: process.env.CCAI_CLIENT_ID || '',
- apiKey: process.env.CCAI_API_KEY || ''
+ apiKey: process.env.CCAI_API_KEY || '',
});
// Example recipients
const accounts: Account[] = [
{
- firstName: "John",
- lastName: "Doe",
- phone: "+15551234567" // Use E.164 format
- }
+ firstName: 'John',
+ lastName: 'Doe',
+ phone: '+15551234567', // Use E.164 format
+ },
];
// Message with variable placeholders
-const message = "Hello ${firstName} ${lastName}, this is a test message!";
-const title = "Test Campaign";
+const message = 'Hello ${firstName} ${lastName}, this is a test message!';
+const title = 'Test Campaign';
/**
* Example of sending SMS messages using async/await
@@ -38,22 +38,18 @@ async function sendMessages() {
try {
// Method 1: Send SMS to multiple recipients
console.log('Sending campaign to multiple recipients...');
- const campaignResponse: SMSResponse = await ccai.sms.send(
- accounts,
- message,
- title
- );
+ const campaignResponse: SMSResponse = await ccai.sms.send(accounts, message, title);
console.log('SMS campaign sent successfully!');
console.log(campaignResponse);
// Method 2: Send SMS to a single recipient
console.log('\nSending message to a single recipient...');
const singleResponse: SMSResponse = await ccai.sms.sendSingle(
- "Jane",
- "Smith",
- "+15559876543",
- "Hi ${firstName}, thanks for your interest!",
- "Single Message Test"
+ 'Jane',
+ 'Smith',
+ '+15559876543',
+ 'Hi ${firstName}, thanks for your interest!',
+ 'Single Message Test'
);
console.log('Single SMS sent successfully!');
console.log(singleResponse);
@@ -71,9 +67,9 @@ async function sendMessages() {
// Execute the async function
sendMessages()
- .then(results => {
+ .then((results) => {
console.log('\nAll messages sent successfully!');
- console.log('\nResults ' + results.toString());
+ console.log(`\nResults ${results.toString()}`);
})
.catch(() => {
console.error('\nFailed to send one or more messages.');
diff --git a/src/examples/email-examples.ts b/src/examples/email-examples.ts
index fd37ff7..9bb9ff8 100644
--- a/src/examples/email-examples.ts
+++ b/src/examples/email-examples.ts
@@ -27,7 +27,7 @@ async function sendSingleEmail() {
'AllCode',
'Welcome Email'
);
-
+
console.log('Email sent successfully:', response);
} catch (error) {
console.error('Error sending email:', error);
@@ -62,32 +62,32 @@ async function sendEmailCampaign() {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
- phone: ''
+ phone: '',
},
{
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
- phone: ''
+ phone: '',
},
{
firstName: 'Bob',
lastName: 'Johnson',
email: 'bob@example.com',
- phone: ''
- }
+ phone: '',
+ },
],
campaignType: 'EMAIL',
addToList: 'noList',
contactInput: 'accounts',
fromType: 'single',
- senders: []
+ senders: [],
};
-
+
const response = await ccai.email.sendCampaign(campaign, {
- onProgress: (status) => console.log(`Status: ${status}`)
+ onProgress: (status) => console.log(`Status: ${status}`),
});
-
+
console.log('Email campaign sent successfully:', response);
} catch (error) {
console.error('Error sending email campaign:', error);
@@ -103,7 +103,7 @@ async function scheduleEmailCampaign() {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
-
+
const campaign: EmailCampaign = {
subject: 'Upcoming Event Reminder',
title: 'Event Reminder Campaign',
@@ -122,8 +122,8 @@ async function scheduleEmailCampaign() {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
- phone: ''
- }
+ phone: '',
+ },
],
campaignType: 'EMAIL',
scheduledTimestamp: tomorrow.toISOString(),
@@ -131,11 +131,11 @@ async function scheduleEmailCampaign() {
addToList: 'noList',
contactInput: 'accounts',
fromType: 'single',
- senders: []
+ senders: [],
};
-
+
const response = await ccai.email.sendCampaign(campaign);
-
+
console.log('Email campaign scheduled successfully:', response);
} catch (error) {
console.error('Error scheduling email campaign:', error);
@@ -177,7 +177,7 @@ async function sendHtmlTemplateEmail() {