From 7ef38a5e0364ef97d94058d58ebf0e35a2086d64 Mon Sep 17 00:00:00 2001 From: mohiit1502 Date: Wed, 7 Jan 2026 21:12:06 +0530 Subject: [PATCH] feat: initial @armco/mesa-sdk with flexible mailConfig --- .gitignore | 7 ++ README.md | 297 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 49 +++++++++ src/client.ts | 245 +++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 57 ++++++++++ src/index.ts | 12 ++ src/types.ts | 192 ++++++++++++++++++++++++++++++++ tsconfig.json | 25 +++++ 8 files changed, 884 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/client.ts create mode 100644 src/errors.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..572f5e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.env +.env.local +.DS_Store +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e50340 --- /dev/null +++ b/README.md @@ -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: '

Hello World

', +}); +``` + +## 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: '

Welcome to our platform!

', + 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: '

Important announcement...

', +}); +``` + +### 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: '

Please find your invoice attached.

', + 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: '

Don\'t forget about your appointment!

', + scheduledAt: '2024-01-15T09:00:00Z', +}); +``` + +### Override Tracking per Email + +```typescript +await mesa.send({ + to: 'user@example.com', + subject: 'Sensitive Information', + html: '

Your password reset link...

', + 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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..0019a46 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..efd548d --- /dev/null +++ b/src/client.ts @@ -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: '

Welcome!

' + * }); + * ``` + */ +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 { + const payload = this.buildSendPayload(options); + return this.request('POST', '/v1/emails', payload); + } + + /** + * Send multiple emails in a batch + */ + async sendBatch(emails: SendEmailOptions[]): Promise { + const payloads = emails.map((opts) => this.buildSendPayload(opts)); + return this.request('POST', '/v1/emails/batch', { emails: payloads }); + } + + /** + * Get email status by ID + */ + async getStatus(emailId: string): Promise> { + return this.request>('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): WebhookPayload { + const data = typeof body === 'string' ? JSON.parse(body) : body; + return data as WebhookPayload; + } + + /** + * Update mail configuration at runtime + */ + updateMailConfig(config: Partial): void { + Object.assign(this.mailConfig, config); + } + + /** + * Get current mail configuration + */ + getMailConfig(): MailConfig { + return { ...this.mailConfig }; + } + + private buildSendPayload(options: SendEmailOptions): Record { + 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 { + const knownKeys = new Set([ + 'to', 'from', 'subject', 'html', 'text', 'cc', 'bcc', 'replyTo', + 'templateId', 'templateData', 'attachments', 'headers', 'tags', + 'metadata', 'scheduledAt', 'tracking', + ]); + + const extended: Record = {}; + 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( + method: string, + path: string, + body?: unknown + ): Promise { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..dbcc2ff --- /dev/null +++ b/src/errors.ts @@ -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; + + constructor( + message: string, + statusCode: number, + code: string, + details?: Record + ) { + 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); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..748309b --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3cda8ae --- /dev/null +++ b/src/types.ts @@ -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; + + /** File attachments */ + attachments?: Attachment[]; + + /** Custom headers */ + headers?: Record; + + /** Tags for categorization */ + tags?: string[]; + + /** Custom metadata */ + metadata?: Record; + + /** 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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..914d14d --- /dev/null +++ b/tsconfig.json @@ -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"] +}