feat: initial @armco/mesa-sdk with flexible mailConfig

This commit is contained in:
2026-01-07 21:12:06 +05:30
commit 7ef38a5e03
8 changed files with 884 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.env
.env.local
.DS_Store
coverage/

297
README.md Normal file
View File

@@ -0,0 +1,297 @@
# @armco/mesa-sdk
Official Node.js SDK for the Mesa/Sandesh Email Platform.
## Installation
```bash
npm install @armco/mesa-sdk
# or
yarn add @armco/mesa-sdk
# or
pnpm add @armco/mesa-sdk
```
## Quick Start
```typescript
import { MesaClient } from '@armco/mesa-sdk';
const mesa = new MesaClient({
apiKey: process.env.MESA_API_KEY!,
});
await mesa.send({
from: { email: 'noreply@example.com', name: 'My App' },
to: 'user@example.com',
subject: 'Welcome!',
html: '<h1>Hello World</h1>',
});
```
## Configuration
### Basic Configuration
```typescript
const mesa = new MesaClient({
apiKey: 'your-api-key', // Required
baseUrl: 'https://custom.api.url', // Optional (default: https://api.sandesh.armco.dev)
timeout: 30000, // Optional (default: 30000ms)
retries: 3, // Optional (default: 3)
});
```
### Mail Configuration
The `mailConfig` object allows you to set default email settings. **This is a flexible JSON config** - new settings added to Sandesh can be passed here without SDK updates.
```typescript
const mesa = new MesaClient({
apiKey: process.env.MESA_API_KEY!,
mailConfig: {
// Core settings (typed)
defaultFrom: { email: 'noreply@example.com', name: 'My App' },
defaultReplyTo: { email: 'support@example.com' },
trackClicks: true,
trackOpens: true,
trackingDomain: 'track.example.com',
defaultTags: ['transactional'],
sandbox: false,
ipPool: 'dedicated-pool',
sendTimeOptimization: true,
// Any additional config fields (forward compatible)
customField: 'value',
newFeatureFlag: true,
},
});
```
#### Available Mail Config Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `defaultFrom` | `EmailAddress` | - | Default sender address |
| `defaultReplyTo` | `EmailAddress` | - | Default reply-to address |
| `trackClicks` | `boolean` | `true` | Enable click tracking |
| `trackOpens` | `boolean` | `true` | Enable open tracking |
| `trackingDomain` | `string` | - | Custom tracking domain |
| `defaultTags` | `string[]` | - | Tags applied to all emails |
| `sandbox` | `boolean` | `false` | Sandbox mode (emails logged, not sent) |
| `ipPool` | `string` | - | Dedicated IP pool name |
| `sendTimeOptimization` | `boolean` | `false` | AI-powered send time optimization |
| `[key: string]` | `unknown` | - | Any additional config for future features |
## Sending Emails
### Basic Send
```typescript
const response = await mesa.send({
to: 'user@example.com',
subject: 'Hello',
html: '<p>Welcome to our platform!</p>',
text: 'Welcome to our platform!',
});
console.log('Email queued:', response.id);
```
### Multiple Recipients
```typescript
await mesa.send({
to: [
{ email: 'user1@example.com', name: 'User One' },
{ email: 'user2@example.com', name: 'User Two' },
],
cc: 'manager@example.com',
bcc: ['audit@example.com'],
subject: 'Team Update',
html: '<p>Important announcement...</p>',
});
```
### Using Templates
```typescript
await mesa.send({
to: 'user@example.com',
subject: 'Order Confirmation',
templateId: 'order-confirmation',
templateData: {
orderNumber: 'ORD-12345',
items: [
{ name: 'Widget', quantity: 2, price: 29.99 },
],
total: 59.98,
},
});
```
### With Attachments
```typescript
import { readFileSync } from 'fs';
await mesa.send({
to: 'user@example.com',
subject: 'Your Invoice',
html: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: 'invoice.pdf',
content: readFileSync('./invoice.pdf').toString('base64'),
contentType: 'application/pdf',
},
],
});
```
### Scheduled Send
```typescript
await mesa.send({
to: 'user@example.com',
subject: 'Reminder',
html: '<p>Don\'t forget about your appointment!</p>',
scheduledAt: '2024-01-15T09:00:00Z',
});
```
### Override Tracking per Email
```typescript
await mesa.send({
to: 'user@example.com',
subject: 'Sensitive Information',
html: '<p>Your password reset link...</p>',
tracking: {
clicks: false, // Disable click tracking for this email
opens: false, // Disable open tracking for this email
},
});
```
## Batch Sending
```typescript
const responses = await mesa.sendBatch([
{ to: 'user1@example.com', subject: 'Hello User 1', html: '...' },
{ to: 'user2@example.com', subject: 'Hello User 2', html: '...' },
{ to: 'user3@example.com', subject: 'Hello User 3', html: '...' },
]);
responses.forEach((res) => console.log('Sent:', res.id));
```
## Check Email Status
```typescript
const status = await mesa.getStatus('email-id-here');
console.log(status);
```
## Webhook Handling
### Verify Webhook Signature
```typescript
import express from 'express';
const app = express();
app.post('/webhooks/mesa', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-mesa-signature'] as string;
const payload = req.body.toString();
if (!mesa.verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = mesa.parseWebhook(payload);
switch (event.event) {
case 'email.delivered':
console.log('Email delivered:', event.messageId);
break;
case 'email.bounced':
console.log('Email bounced:', event.messageId, event.data);
break;
case 'email.opened':
console.log('Email opened:', event.messageId);
break;
case 'email.clicked':
console.log('Link clicked:', event.data.url);
break;
}
res.status(200).send('OK');
});
```
## Runtime Configuration Updates
```typescript
// Update mail config at runtime
mesa.updateMailConfig({
sandbox: true, // Enable sandbox mode
});
// Get current config
const config = mesa.getMailConfig();
```
## Error Handling
```typescript
import { MesaClient, MesaAPIError, MesaValidationError } from '@armco/mesa-sdk';
try {
await mesa.send({ /* ... */ });
} catch (error) {
if (error instanceof MesaValidationError) {
console.error('Validation error:', error.message, 'Field:', error.field);
} else if (error instanceof MesaAPIError) {
console.error('API error:', error.message);
console.error('Status:', error.statusCode);
console.error('Code:', error.code);
console.error('Details:', error.details);
} else {
console.error('Unknown error:', error);
}
}
```
## TypeScript Support
Full TypeScript support with exported types:
```typescript
import type {
MesaConfig,
MailConfig,
SendEmailOptions,
SendEmailResponse,
EmailAddress,
Attachment,
WebhookEvent,
WebhookPayload,
} from '@armco/mesa-sdk';
```
## Environment Variables
Recommended setup:
```bash
# .env
MESA_API_KEY=your-api-key
MESA_API_URL=https://api.sandesh.armco.dev # Optional
WEBHOOK_SECRET=your-webhook-secret
```
## License
MIT

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@armco/mesa-sdk",
"version": "1.0.0",
"description": "Official Node.js SDK for Sandesh/Mesa Email Platform",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "vitest",
"lint": "eslint src --ext .ts",
"prepublishOnly": "npm run build"
},
"keywords": [
"email",
"sandesh",
"mesa",
"armco",
"smtp",
"transactional-email",
"email-api"
],
"author": "Armco",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://gitea.armco.dev/Restruct-Corporate-Advantage/mesa-client-sdk"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20.10.6",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}

245
src/client.ts Normal file
View File

@@ -0,0 +1,245 @@
import { MesaAPIError, MesaValidationError } from './errors';
import type {
MesaConfig,
MailConfig,
SendEmailOptions,
SendEmailResponse,
EmailAddress,
WebhookPayload,
} from './types';
const DEFAULT_BASE_URL = 'https://api.sandesh.armco.dev';
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_RETRIES = 3;
/**
* Mesa Email SDK Client
*
* @example
* ```typescript
* const mesa = new MesaClient({
* apiKey: 'your-api-key',
* mailConfig: {
* defaultFrom: { email: 'noreply@example.com', name: 'My App' },
* trackClicks: true,
* trackOpens: true,
* }
* });
*
* await mesa.send({
* to: 'user@example.com',
* subject: 'Hello',
* html: '<h1>Welcome!</h1>'
* });
* ```
*/
export class MesaClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly timeout: number;
private readonly retries: number;
private readonly mailConfig: MailConfig;
constructor(config: MesaConfig) {
if (!config.apiKey) {
throw new MesaValidationError('API key is required', 'apiKey');
}
this.apiKey = config.apiKey;
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
this.timeout = config.timeout || DEFAULT_TIMEOUT;
this.retries = config.retries || DEFAULT_RETRIES;
this.mailConfig = config.mailConfig || {};
}
/**
* Send an email
*/
async send(options: SendEmailOptions): Promise<SendEmailResponse> {
const payload = this.buildSendPayload(options);
return this.request<SendEmailResponse>('POST', '/v1/emails', payload);
}
/**
* Send multiple emails in a batch
*/
async sendBatch(emails: SendEmailOptions[]): Promise<SendEmailResponse[]> {
const payloads = emails.map((opts) => this.buildSendPayload(opts));
return this.request<SendEmailResponse[]>('POST', '/v1/emails/batch', { emails: payloads });
}
/**
* Get email status by ID
*/
async getStatus(emailId: string): Promise<Record<string, unknown>> {
return this.request<Record<string, unknown>>('GET', `/v1/emails/${emailId}`);
}
/**
* Verify webhook signature
*/
verifyWebhook(payload: string, signature: string, secret: string): boolean {
const crypto = require('crypto');
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
/**
* Parse webhook payload
*/
parseWebhook(body: string | Record<string, unknown>): WebhookPayload {
const data = typeof body === 'string' ? JSON.parse(body) : body;
return data as WebhookPayload;
}
/**
* Update mail configuration at runtime
*/
updateMailConfig(config: Partial<MailConfig>): void {
Object.assign(this.mailConfig, config);
}
/**
* Get current mail configuration
*/
getMailConfig(): MailConfig {
return { ...this.mailConfig };
}
private buildSendPayload(options: SendEmailOptions): Record<string, unknown> {
const from = options.from || this.mailConfig.defaultFrom;
if (!from) {
throw new MesaValidationError(
'Sender address is required. Set "from" in options or "defaultFrom" in mailConfig.',
'from'
);
}
const tracking = {
clicks: options.tracking?.clicks ?? this.mailConfig.trackClicks ?? true,
opens: options.tracking?.opens ?? this.mailConfig.trackOpens ?? true,
};
const tags = [
...(this.mailConfig.defaultTags || []),
...(options.tags || []),
];
return {
from: this.normalizeAddress(from),
to: this.normalizeAddresses(options.to),
subject: options.subject,
html: options.html,
text: options.text,
cc: options.cc ? this.normalizeAddresses(options.cc) : undefined,
bcc: options.bcc ? this.normalizeAddresses(options.bcc) : undefined,
reply_to: this.normalizeAddress(options.replyTo || this.mailConfig.defaultReplyTo),
template_id: options.templateId,
template_data: options.templateData,
attachments: options.attachments,
headers: options.headers,
tags: tags.length > 0 ? tags : undefined,
metadata: options.metadata,
scheduled_at: options.scheduledAt,
tracking,
sandbox: this.mailConfig.sandbox,
ip_pool: this.mailConfig.ipPool,
...this.getExtendedConfig(options),
};
}
private getExtendedConfig(options: SendEmailOptions): Record<string, unknown> {
const knownKeys = new Set([
'to', 'from', 'subject', 'html', 'text', 'cc', 'bcc', 'replyTo',
'templateId', 'templateData', 'attachments', 'headers', 'tags',
'metadata', 'scheduledAt', 'tracking',
]);
const extended: Record<string, unknown> = {};
for (const [key, value] of Object.entries(options)) {
if (!knownKeys.has(key)) {
extended[key] = value;
}
}
return extended;
}
private normalizeAddress(addr: EmailAddress | string | undefined): EmailAddress | undefined {
if (!addr) return undefined;
if (typeof addr === 'string') {
return { email: addr };
}
return addr;
}
private normalizeAddresses(
addrs: EmailAddress | EmailAddress[] | string | string[]
): EmailAddress[] {
const arr = Array.isArray(addrs) ? addrs : [addrs];
return arr.map((a) => (typeof a === 'string' ? { email: a } : a));
}
private async request<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const url = `${this.baseUrl}${path}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'User-Agent': '@armco/mesa-sdk/1.0.0',
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new MesaAPIError(
errorData.message || `Request failed with status ${response.status}`,
response.status,
errorData.code || 'API_ERROR',
errorData.details
);
}
return (await response.json()) as T;
} catch (error) {
lastError = error as Error;
if (error instanceof MesaAPIError && error.statusCode < 500) {
throw error;
}
if (attempt < this.retries) {
await this.sleep(Math.pow(2, attempt) * 100);
}
}
}
throw lastError;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

57
src/errors.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Base error class for Mesa SDK
*/
export class MesaError extends Error {
constructor(message: string) {
super(message);
this.name = 'MesaError';
Object.setPrototypeOf(this, MesaError.prototype);
}
}
/**
* API error with status code and response details
*/
export class MesaAPIError extends MesaError {
public readonly statusCode: number;
public readonly code: string;
public readonly details?: Record<string, unknown>;
constructor(
message: string,
statusCode: number,
code: string,
details?: Record<string, unknown>
) {
super(message);
this.name = 'MesaAPIError';
this.statusCode = statusCode;
this.code = code;
this.details = details;
Object.setPrototypeOf(this, MesaAPIError.prototype);
}
toJSON() {
return {
name: this.name,
message: this.message,
statusCode: this.statusCode,
code: this.code,
details: this.details,
};
}
}
/**
* Validation error for invalid input
*/
export class MesaValidationError extends MesaError {
public readonly field: string;
constructor(message: string, field: string) {
super(message);
this.name = 'MesaValidationError';
this.field = field;
Object.setPrototypeOf(this, MesaValidationError.prototype);
}
}

12
src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { MesaClient } from './client';
export { MesaError, MesaAPIError, MesaValidationError } from './errors';
export type {
MesaConfig,
MailConfig,
SendEmailOptions,
SendEmailResponse,
EmailAddress,
Attachment,
WebhookEvent,
WebhookPayload,
} from './types';

192
src/types.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Mesa SDK Configuration
*/
export interface MesaConfig {
/** API Key for authentication */
apiKey: string;
/** Base URL of Mesa/Sandesh API (default: https://api.sandesh.armco.dev) */
baseUrl?: string;
/** Request timeout in milliseconds (default: 30000) */
timeout?: number;
/** Number of retry attempts for failed requests (default: 3) */
retries?: number;
/**
* Mail configuration object - flexible JSON config for email settings.
* New settings added to Sandesh can be passed here without SDK updates.
*/
mailConfig?: MailConfig;
}
/**
* Flexible mail configuration - supports any key-value pairs.
* Core fields are typed, additional fields are allowed for extensibility.
*/
export interface MailConfig {
/** Default sender email address */
defaultFrom?: EmailAddress;
/** Default reply-to address */
defaultReplyTo?: EmailAddress;
/** Enable click tracking (default: true) */
trackClicks?: boolean;
/** Enable open tracking (default: true) */
trackOpens?: boolean;
/** Custom tracking domain */
trackingDomain?: string;
/** Default tags to apply to all emails */
defaultTags?: string[];
/** Sandbox mode - emails are logged but not sent */
sandbox?: boolean;
/** IP pool name for dedicated IPs */
ipPool?: string;
/** Send time optimization */
sendTimeOptimization?: boolean;
/** Allow any additional config fields for forward compatibility */
[key: string]: unknown;
}
/**
* Email address with optional display name
*/
export interface EmailAddress {
email: string;
name?: string;
}
/**
* Email attachment
*/
export interface Attachment {
/** Filename */
filename: string;
/** Base64 encoded content */
content: string;
/** MIME type (e.g., 'application/pdf') */
contentType?: string;
/** Content-ID for inline attachments */
cid?: string;
}
/**
* Options for sending an email
*/
export interface SendEmailOptions {
/** Recipient(s) */
to: EmailAddress | EmailAddress[] | string | string[];
/** Sender (overrides mailConfig.defaultFrom) */
from?: EmailAddress | string;
/** Email subject */
subject: string;
/** HTML body */
html?: string;
/** Plain text body */
text?: string;
/** CC recipients */
cc?: EmailAddress | EmailAddress[] | string | string[];
/** BCC recipients */
bcc?: EmailAddress | EmailAddress[] | string | string[];
/** Reply-to address */
replyTo?: EmailAddress | string;
/** Template ID to use */
templateId?: string;
/** Template variables */
templateData?: Record<string, unknown>;
/** File attachments */
attachments?: Attachment[];
/** Custom headers */
headers?: Record<string, string>;
/** Tags for categorization */
tags?: string[];
/** Custom metadata */
metadata?: Record<string, string>;
/** Schedule send time (ISO 8601) */
scheduledAt?: string;
/** Override tracking settings for this email */
tracking?: {
clicks?: boolean;
opens?: boolean;
};
/** Allow any additional options for forward compatibility */
[key: string]: unknown;
}
/**
* Response from sending an email
*/
export interface SendEmailResponse {
/** Unique message ID */
id: string;
/** Status of the send operation */
status: 'queued' | 'sent' | 'scheduled';
/** Timestamp */
timestamp: string;
/** Additional response data */
[key: string]: unknown;
}
/**
* Webhook event types
*/
export type WebhookEvent =
| 'email.sent'
| 'email.delivered'
| 'email.bounced'
| 'email.complained'
| 'email.opened'
| 'email.clicked'
| 'email.unsubscribed'
| 'inbound.received';
/**
* Webhook payload
*/
export interface WebhookPayload {
/** Event type */
event: WebhookEvent;
/** Event timestamp */
timestamp: string;
/** Message ID */
messageId: string;
/** Recipient email */
recipient?: string;
/** Event-specific data */
data: Record<string, unknown>;
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"types": ["node"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}