@ewyn/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Mailer, MailerApiError } from '../index.js';
3
+ // Mock fetch globally
4
+ global.fetch = vi.fn();
5
+ describe('Mailer SDK', () => {
6
+ let client;
7
+ beforeEach(() => {
8
+ vi.clearAllMocks();
9
+ global.fetch.mockReset();
10
+ client = new Mailer({
11
+ workspaceId: 'test-workspace-id',
12
+ apiKey: 'test-api-key',
13
+ baseUrl: 'https://api.test.com/api/v1',
14
+ maxRetries: 2, // Allow 1 retry (attempt < maxRetries)
15
+ timeout: 5000,
16
+ });
17
+ });
18
+ describe('constructor', () => {
19
+ it('should create client with required options', () => {
20
+ expect(client).toBeInstanceOf(Mailer);
21
+ });
22
+ it('should use default base URL if not provided', () => {
23
+ const defaultClient = new Mailer({
24
+ workspaceId: 'test-workspace-id',
25
+ apiKey: 'test-api-key',
26
+ });
27
+ expect(defaultClient).toBeInstanceOf(Mailer);
28
+ });
29
+ it('should accept template config', () => {
30
+ const config = {
31
+ welcome: {
32
+ id: 'version-uuid',
33
+ name: 'Welcome',
34
+ version: 1,
35
+ vars: {
36
+ firstName: { required: true },
37
+ },
38
+ },
39
+ };
40
+ const configClient = new Mailer({
41
+ workspaceId: 'test-workspace-id',
42
+ apiKey: 'test-api-key',
43
+ templates: config,
44
+ });
45
+ expect(configClient).toBeInstanceOf(Mailer);
46
+ });
47
+ });
48
+ describe('send', () => {
49
+ it('should send email with template ID', async () => {
50
+ const mockResponse = {
51
+ messageId: 'msg-123',
52
+ status: 'queued',
53
+ queuedAt: '2024-01-01T00:00:00Z',
54
+ };
55
+ global.fetch.mockResolvedValueOnce({
56
+ ok: true,
57
+ status: 200,
58
+ json: async () => mockResponse,
59
+ });
60
+ const result = await client.send({
61
+ to: 'test@example.com',
62
+ templateId: 'template-uuid',
63
+ variables: {
64
+ firstName: 'John',
65
+ },
66
+ });
67
+ expect(result).toEqual(mockResponse);
68
+ expect(global.fetch).toHaveBeenCalledWith('https://api.test.com/api/v1/workspaces/test-workspace-id/send', expect.objectContaining({
69
+ method: 'POST',
70
+ headers: expect.objectContaining({
71
+ 'Content-Type': 'application/json',
72
+ 'Authorization': 'Bearer test-api-key',
73
+ }),
74
+ body: JSON.stringify({
75
+ to: 'test@example.com',
76
+ templateId: 'template-uuid',
77
+ variables: {
78
+ firstName: 'John',
79
+ },
80
+ }),
81
+ }));
82
+ });
83
+ it('should send email with template name when config provided', async () => {
84
+ const config = {
85
+ welcome: {
86
+ id: 'version-uuid',
87
+ name: 'Welcome',
88
+ version: 1,
89
+ vars: {
90
+ firstName: { required: true },
91
+ },
92
+ },
93
+ };
94
+ const configClient = new Mailer({
95
+ workspaceId: 'test-workspace-id',
96
+ apiKey: 'test-api-key',
97
+ templates: config,
98
+ });
99
+ const mockResponse = {
100
+ messageId: 'msg-123',
101
+ status: 'queued',
102
+ queuedAt: '2024-01-01T00:00:00Z',
103
+ };
104
+ global.fetch.mockResolvedValueOnce({
105
+ ok: true,
106
+ status: 200,
107
+ json: async () => mockResponse,
108
+ });
109
+ const result = await configClient.send({
110
+ to: 'test@example.com',
111
+ template: 'welcome',
112
+ variables: {
113
+ firstName: 'John',
114
+ },
115
+ });
116
+ expect(result).toEqual(mockResponse);
117
+ });
118
+ it('should include version parameter if specified', async () => {
119
+ const mockResponse = {
120
+ messageId: 'msg-123',
121
+ status: 'queued',
122
+ queuedAt: '2024-01-01T00:00:00Z',
123
+ };
124
+ global.fetch.mockResolvedValueOnce({
125
+ ok: true,
126
+ status: 200,
127
+ json: async () => mockResponse,
128
+ });
129
+ await client.send({
130
+ to: 'test@example.com',
131
+ templateId: 'template-uuid',
132
+ version: 2,
133
+ variables: {
134
+ firstName: 'John',
135
+ },
136
+ });
137
+ expect(global.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
138
+ body: expect.stringContaining('"version":2'),
139
+ }));
140
+ });
141
+ it('should include metadata if provided', async () => {
142
+ const mockResponse = {
143
+ messageId: 'msg-123',
144
+ status: 'queued',
145
+ queuedAt: '2024-01-01T00:00:00Z',
146
+ };
147
+ global.fetch.mockResolvedValueOnce({
148
+ ok: true,
149
+ status: 200,
150
+ json: async () => mockResponse,
151
+ });
152
+ await client.send({
153
+ to: 'test@example.com',
154
+ templateId: 'template-uuid',
155
+ metadata: {
156
+ userId: 'user-123',
157
+ },
158
+ });
159
+ expect(global.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
160
+ body: expect.stringContaining('"metadata"'),
161
+ }));
162
+ });
163
+ it('should include idempotency key if provided', async () => {
164
+ const mockResponse = {
165
+ messageId: 'msg-123',
166
+ status: 'queued',
167
+ queuedAt: '2024-01-01T00:00:00Z',
168
+ };
169
+ global.fetch.mockResolvedValueOnce({
170
+ ok: true,
171
+ status: 200,
172
+ json: async () => mockResponse,
173
+ });
174
+ await client.send({
175
+ to: 'test@example.com',
176
+ templateId: 'template-uuid',
177
+ idempotencyKey: 'idempotency-key-123',
178
+ });
179
+ expect(global.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
180
+ body: expect.stringContaining('"idempotencyKey":"idempotency-key-123"'),
181
+ }));
182
+ });
183
+ it('should throw error if neither templateId nor template provided', async () => {
184
+ await expect(client.send({
185
+ to: 'test@example.com',
186
+ variables: {},
187
+ })).rejects.toThrow('Either templateId or template must be provided');
188
+ });
189
+ it('should throw error if template name provided without config', async () => {
190
+ await expect(client.send({
191
+ to: 'test@example.com',
192
+ template: 'welcome',
193
+ variables: {},
194
+ })).rejects.toThrow('Template name provided but no template config was provided');
195
+ });
196
+ it('should throw error if template not found in config', async () => {
197
+ const config = {
198
+ welcome: {
199
+ id: 'version-uuid',
200
+ name: 'Welcome',
201
+ version: 1,
202
+ vars: {},
203
+ },
204
+ };
205
+ const configClient = new Mailer({
206
+ workspaceId: 'test-workspace-id',
207
+ apiKey: 'test-api-key',
208
+ templates: config,
209
+ });
210
+ await expect(configClient.send({
211
+ to: 'test@example.com',
212
+ template: 'nonexistent',
213
+ variables: {},
214
+ })).rejects.toThrow('Template "nonexistent" not found in template config');
215
+ });
216
+ });
217
+ describe('variable validation', () => {
218
+ it('should validate required variables', async () => {
219
+ const config = {
220
+ welcome: {
221
+ id: 'version-uuid',
222
+ name: 'Welcome',
223
+ version: 1,
224
+ vars: {
225
+ firstName: { required: true },
226
+ lastName: { required: true },
227
+ },
228
+ },
229
+ };
230
+ const configClient = new Mailer({
231
+ workspaceId: 'test-workspace-id',
232
+ apiKey: 'test-api-key',
233
+ templates: config,
234
+ });
235
+ await expect(configClient.send({
236
+ to: 'test@example.com',
237
+ template: 'welcome',
238
+ variables: {
239
+ firstName: 'John',
240
+ // lastName missing
241
+ },
242
+ })).rejects.toThrow('Missing required variables for template "welcome": lastName');
243
+ });
244
+ it('should allow optional variables to be omitted', async () => {
245
+ const config = {
246
+ welcome: {
247
+ id: 'version-uuid',
248
+ name: 'Welcome',
249
+ version: 1,
250
+ vars: {
251
+ firstName: { required: true },
252
+ lastName: { required: false },
253
+ },
254
+ },
255
+ };
256
+ const configClient = new Mailer({
257
+ workspaceId: 'test-workspace-id',
258
+ apiKey: 'test-api-key',
259
+ templates: config,
260
+ });
261
+ const mockResponse = {
262
+ messageId: 'msg-123',
263
+ status: 'queued',
264
+ queuedAt: '2024-01-01T00:00:00Z',
265
+ };
266
+ global.fetch.mockResolvedValueOnce({
267
+ ok: true,
268
+ status: 200,
269
+ json: async () => mockResponse,
270
+ });
271
+ const result = await configClient.send({
272
+ to: 'test@example.com',
273
+ template: 'welcome',
274
+ variables: {
275
+ firstName: 'John',
276
+ // lastName omitted (optional)
277
+ },
278
+ });
279
+ expect(result).toEqual(mockResponse);
280
+ });
281
+ });
282
+ describe('error handling', () => {
283
+ it('should throw MailerApiError on 400 error', async () => {
284
+ const mock400Response = {
285
+ ok: false,
286
+ status: 400,
287
+ json: async () => ({
288
+ error: 'Bad request',
289
+ code: 'INVALID_REQUEST',
290
+ details: { field: 'to' },
291
+ }),
292
+ };
293
+ global.fetch
294
+ .mockResolvedValueOnce(mock400Response)
295
+ .mockResolvedValueOnce(mock400Response);
296
+ await expect(client.send({
297
+ to: 'test@example.com',
298
+ templateId: 'template-uuid',
299
+ })).rejects.toThrow(MailerApiError);
300
+ try {
301
+ await client.send({
302
+ to: 'test@example.com',
303
+ templateId: 'template-uuid',
304
+ });
305
+ }
306
+ catch (error) {
307
+ expect(error).toBeInstanceOf(MailerApiError);
308
+ expect(error.status).toBe(400);
309
+ expect(error.code).toBe('INVALID_REQUEST');
310
+ expect(error.isClientError()).toBe(true);
311
+ expect(error.isServerError()).toBe(false);
312
+ expect(error.isRetryable()).toBe(false);
313
+ }
314
+ });
315
+ it('should throw MailerApiError on 429 rate limit', async () => {
316
+ global.fetch.mockResolvedValue({
317
+ ok: false,
318
+ status: 429,
319
+ json: async () => ({
320
+ error: 'Too many requests',
321
+ code: 'RATE_LIMITED',
322
+ }),
323
+ });
324
+ try {
325
+ await client.send({
326
+ to: 'test@example.com',
327
+ templateId: 'template-uuid',
328
+ });
329
+ }
330
+ catch (error) {
331
+ expect(error).toBeInstanceOf(MailerApiError);
332
+ expect(error.status).toBe(429);
333
+ expect(error.isRetryable()).toBe(true);
334
+ }
335
+ // Should have retried once (maxRetries: 1)
336
+ expect(global.fetch).toHaveBeenCalledTimes(2);
337
+ });
338
+ it('should throw MailerApiError on 500 server error', async () => {
339
+ global.fetch.mockResolvedValue({
340
+ ok: false,
341
+ status: 500,
342
+ json: async () => ({
343
+ error: 'Internal server error',
344
+ }),
345
+ });
346
+ try {
347
+ await client.send({
348
+ to: 'test@example.com',
349
+ templateId: 'template-uuid',
350
+ });
351
+ }
352
+ catch (error) {
353
+ expect(error).toBeInstanceOf(MailerApiError);
354
+ expect(error.status).toBe(500);
355
+ expect(error.isServerError()).toBe(true);
356
+ expect(error.isRetryable()).toBe(true);
357
+ }
358
+ // Should have retried once
359
+ expect(global.fetch).toHaveBeenCalledTimes(2);
360
+ });
361
+ it('should handle network errors', async () => {
362
+ global.fetch.mockRejectedValue(new Error('Network error'));
363
+ await expect(client.send({
364
+ to: 'test@example.com',
365
+ templateId: 'template-uuid',
366
+ })).rejects.toThrow('Network error');
367
+ // Should have retried once
368
+ expect(global.fetch).toHaveBeenCalledTimes(2);
369
+ });
370
+ it('should handle timeout errors', async () => {
371
+ const abortError = new Error('The operation was aborted');
372
+ abortError.name = 'AbortError';
373
+ global.fetch.mockRejectedValueOnce(abortError);
374
+ try {
375
+ await client.send({
376
+ to: 'test@example.com',
377
+ templateId: 'template-uuid',
378
+ });
379
+ }
380
+ catch (error) {
381
+ expect(error).toBeInstanceOf(MailerApiError);
382
+ expect(error.status).toBe(408);
383
+ expect(error.code).toBe('TIMEOUT');
384
+ }
385
+ });
386
+ });
387
+ describe('retry logic', () => {
388
+ it('should retry on 5xx errors with exponential backoff', async () => {
389
+ const startTime = Date.now();
390
+ global.fetch
391
+ .mockResolvedValueOnce({
392
+ ok: false,
393
+ status: 500,
394
+ json: async () => ({ error: 'Server error' }),
395
+ })
396
+ .mockResolvedValueOnce({
397
+ ok: true,
398
+ status: 200,
399
+ json: async () => ({
400
+ messageId: 'msg-123',
401
+ status: 'queued',
402
+ queuedAt: '2024-01-01T00:00:00Z',
403
+ }),
404
+ });
405
+ const result = await client.send({
406
+ to: 'test@example.com',
407
+ templateId: 'template-uuid',
408
+ });
409
+ const duration = Date.now() - startTime;
410
+ expect(result.messageId).toBe('msg-123');
411
+ expect(global.fetch).toHaveBeenCalledTimes(2);
412
+ // Should have waited ~1 second between attempts
413
+ expect(duration).toBeGreaterThanOrEqual(950);
414
+ });
415
+ it('should not retry on 4xx client errors', async () => {
416
+ global.fetch.mockResolvedValueOnce({
417
+ ok: false,
418
+ status: 404,
419
+ json: async () => ({ error: 'Not found' }),
420
+ });
421
+ await expect(client.send({
422
+ to: 'test@example.com',
423
+ templateId: 'template-uuid',
424
+ })).rejects.toThrow(MailerApiError);
425
+ // Should not retry
426
+ expect(global.fetch).toHaveBeenCalledTimes(1);
427
+ });
428
+ });
429
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * API Error class for handling HTTP errors from the Mailer API
3
+ */
4
+ export declare class MailerApiError extends Error {
5
+ status: number;
6
+ code?: string | undefined;
7
+ details?: unknown | undefined;
8
+ constructor(status: number, code?: string | undefined, details?: unknown | undefined, message?: string);
9
+ /**
10
+ * Check if the error is a client error (4xx)
11
+ */
12
+ isClientError(): boolean;
13
+ /**
14
+ * Check if the error is a server error (5xx)
15
+ */
16
+ isServerError(): boolean;
17
+ /**
18
+ * Check if the error is retryable (5xx or 429)
19
+ */
20
+ isRetryable(): boolean;
21
+ }
22
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,cAAe,SAAQ,KAAK;IAE9B,MAAM,EAAE,MAAM;IACd,IAAI,CAAC,EAAE,MAAM;IACb,OAAO,CAAC,EAAE,OAAO;gBAFjB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA,EACb,OAAO,CAAC,EAAE,OAAO,YAAA,EACxB,OAAO,CAAC,EAAE,MAAM;IAOlB;;OAEG;IACH,aAAa,IAAI,OAAO;IAIxB;;OAEG;IACH,aAAa,IAAI,OAAO;IAIxB;;OAEG;IACH,WAAW,IAAI,OAAO;CAGvB"}
package/dist/errors.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * API Error class for handling HTTP errors from the Mailer API
3
+ */
4
+ export class MailerApiError extends Error {
5
+ status;
6
+ code;
7
+ details;
8
+ constructor(status, code, details, message) {
9
+ super(message || `API request failed with status ${status}`);
10
+ this.status = status;
11
+ this.code = code;
12
+ this.details = details;
13
+ this.name = 'MailerApiError';
14
+ Object.setPrototypeOf(this, MailerApiError.prototype);
15
+ }
16
+ /**
17
+ * Check if the error is a client error (4xx)
18
+ */
19
+ isClientError() {
20
+ return this.status >= 400 && this.status < 500;
21
+ }
22
+ /**
23
+ * Check if the error is a server error (5xx)
24
+ */
25
+ isServerError() {
26
+ return this.status >= 500;
27
+ }
28
+ /**
29
+ * Check if the error is retryable (5xx or 429)
30
+ */
31
+ isRetryable() {
32
+ return this.status === 429 || this.isServerError();
33
+ }
34
+ }
@@ -0,0 +1,99 @@
1
+ import type { MailerOptions, MailerOptionsTyped, SendEmailOptions, SendEmailOptionsTyped, SendEmailResponse, TemplateConfig } from './types.js';
2
+ /**
3
+ * Mailer SDK Client
4
+ *
5
+ * @example Basic usage without template config
6
+ * ```ts
7
+ * const client = new Mailer({
8
+ * workspaceId: 'your-workspace-id',
9
+ * apiKey: 'your-api-key',
10
+ * });
11
+ *
12
+ * await client.send({
13
+ * to: 'user@example.com',
14
+ * templateId: 'version-uuid',
15
+ * variables: { firstName: 'John' }
16
+ * });
17
+ * ```
18
+ *
19
+ * @example Type-safe usage with template config
20
+ * ```ts
21
+ * const config = {
22
+ * welcome: {
23
+ * id: 'version-uuid',
24
+ * name: 'Welcome Email',
25
+ * version: 1,
26
+ * vars: {
27
+ * firstName: { required: true },
28
+ * plan: { required: false }
29
+ * }
30
+ * }
31
+ * } as const;
32
+ *
33
+ * const client = new Mailer({
34
+ * workspaceId: 'your-workspace-id',
35
+ * apiKey: 'your-api-key',
36
+ * templates: config
37
+ * });
38
+ *
39
+ * // Type-safe sending
40
+ * await client.send({
41
+ * to: 'user@example.com',
42
+ * template: 'welcome', // Autocomplete available
43
+ * variables: {
44
+ * firstName: 'John' // TypeScript enforces required vars
45
+ * }
46
+ * });
47
+ * ```
48
+ */
49
+ export declare class Mailer<TConfig extends TemplateConfig = TemplateConfig> {
50
+ private readonly workspaceId;
51
+ private readonly apiKey;
52
+ private readonly baseUrl;
53
+ private readonly templates?;
54
+ private readonly maxRetries;
55
+ private readonly timeout;
56
+ constructor(options: MailerOptions | MailerOptionsTyped<TConfig>);
57
+ /**
58
+ * Send an email using a template
59
+ *
60
+ * @param options - Email sending options
61
+ * @returns Promise resolving to the send response
62
+ * @throws {MailerApiError} If the API request fails
63
+ *
64
+ * @example With template ID
65
+ * ```ts
66
+ * await client.send({
67
+ * to: 'user@example.com',
68
+ * templateId: 'version-uuid',
69
+ * variables: { firstName: 'John' }
70
+ * });
71
+ * ```
72
+ *
73
+ * @example With template name (requires config)
74
+ * ```ts
75
+ * await client.send({
76
+ * to: 'user@example.com',
77
+ * template: 'welcome',
78
+ * version: 2, // Optional: defaults to latest
79
+ * variables: { firstName: 'John' }
80
+ * });
81
+ * ```
82
+ */
83
+ send(options: TConfig extends TemplateConfig ? SendEmailOptionsTyped<TConfig> | SendEmailOptions : SendEmailOptions): Promise<SendEmailResponse>;
84
+ /**
85
+ * Resolve template ID from either templateId or template name
86
+ */
87
+ private resolveTemplateId;
88
+ /**
89
+ * Validate that all required variables are provided
90
+ */
91
+ private validateVariables;
92
+ /**
93
+ * Make HTTP request with retry logic
94
+ */
95
+ private requestWithRetry;
96
+ }
97
+ export { MailerApiError } from './errors.js';
98
+ export type { MailerOptions, MailerOptionsTyped, SendEmailOptions, SendEmailOptionsBase, SendEmailOptionsTyped, SendEmailResponse, TemplateConfig, TemplateConfigEntry, TemplateVariable } from './types.js';
99
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,aAAa,EACb,kBAAkB,EAClB,gBAAgB,EAChB,qBAAqB,EACrB,iBAAiB,EACjB,cAAc,EACf,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,qBAAa,MAAM,CAAC,OAAO,SAAS,cAAc,GAAG,cAAc;IACjE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAU;IACrC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,OAAO,EAAE,aAAa,GAAG,kBAAkB,CAAC,OAAO,CAAC;IAShE;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACG,IAAI,CACR,OAAO,EAAE,OAAO,SAAS,cAAc,GACnC,qBAAqB,CAAC,OAAO,CAAC,GAAG,gBAAgB,GACjD,gBAAgB,GACnB,OAAO,CAAC,iBAAiB,CAAC;IA8C7B;;OAEG;YACW,iBAAiB;IAyB/B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA2BzB;;OAEG;YACW,gBAAgB;CAuE/B;AAED,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EACV,aAAa,EACb,kBAAkB,EAClB,gBAAgB,EAChB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,gBAAgB,EACjB,MAAM,YAAY,CAAC"}