@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.
- package/README.md +267 -0
- package/dist/__tests__/config-types.test-d.d.ts +10 -0
- package/dist/__tests__/config-types.test-d.d.ts.map +1 -0
- package/dist/__tests__/config-types.test-d.js +96 -0
- package/dist/__tests__/dashboard-config.test.d.ts +10 -0
- package/dist/__tests__/dashboard-config.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard-config.test.js +182 -0
- package/dist/__tests__/errors.test.d.ts +2 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test.js +90 -0
- package/dist/__tests__/mailer.test.d.ts +2 -0
- package/dist/__tests__/mailer.test.d.ts.map +1 -0
- package/dist/__tests__/mailer.test.js +429 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +34 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +211 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# @ewyn/client
|
|
2
|
+
|
|
3
|
+
Official TypeScript SDK for the Ewyn email service with full type safety.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @ewyn/client
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @ewyn/client
|
|
11
|
+
# or
|
|
12
|
+
yarn add @ewyn/client
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 18+ (uses native `fetch`)
|
|
18
|
+
- TypeScript 5.0+ (for type-safe features)
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Basic Usage
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { Mailer } from '@ewyn/client';
|
|
26
|
+
|
|
27
|
+
const client = new Mailer({
|
|
28
|
+
workspaceId: 'your-workspace-id',
|
|
29
|
+
apiKey: 'your-api-key',
|
|
30
|
+
baseUrl: 'https://www.ewyn.ai/api/v1', // optional; omit to use production default
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Send an email using template version ID
|
|
34
|
+
await client.send({
|
|
35
|
+
to: 'user@example.com',
|
|
36
|
+
templateId: 'template-version-uuid',
|
|
37
|
+
variables: {
|
|
38
|
+
firstName: 'John',
|
|
39
|
+
lastName: 'Doe',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Type-Safe Usage with Template Config
|
|
45
|
+
|
|
46
|
+
Get full TypeScript autocomplete and validation by copying your template config from the dashboard:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { Mailer } from '@ewyn/client';
|
|
50
|
+
|
|
51
|
+
// 1. Copy template config from dashboard (API Keys page)
|
|
52
|
+
const config = {
|
|
53
|
+
welcome: {
|
|
54
|
+
id: 'version-uuid-1',
|
|
55
|
+
name: 'Welcome Email',
|
|
56
|
+
version: 1,
|
|
57
|
+
vars: {
|
|
58
|
+
firstName: { required: true },
|
|
59
|
+
lastName: { required: true },
|
|
60
|
+
plan: { required: false },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
'password-reset': {
|
|
64
|
+
id: 'version-uuid-2',
|
|
65
|
+
name: 'Password Reset',
|
|
66
|
+
version: 1,
|
|
67
|
+
vars: {
|
|
68
|
+
resetUrl: { required: true },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
} as const; // ← Important: use 'as const'
|
|
72
|
+
|
|
73
|
+
// 2. Initialize with config
|
|
74
|
+
const client = new Mailer({
|
|
75
|
+
workspaceId: 'your-workspace-id',
|
|
76
|
+
apiKey: 'your-api-key',
|
|
77
|
+
templates: config,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 3. Send with full type safety
|
|
81
|
+
await client.send({
|
|
82
|
+
to: 'user@example.com',
|
|
83
|
+
template: 'welcome', // ← Autocomplete available!
|
|
84
|
+
variables: {
|
|
85
|
+
firstName: 'John', // ← TypeScript enforces required vars
|
|
86
|
+
lastName: 'Doe',
|
|
87
|
+
plan: 'Pro', // ← Optional vars allowed
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Getting Your Template Configuration
|
|
93
|
+
|
|
94
|
+
### From Dashboard (Recommended)
|
|
95
|
+
|
|
96
|
+
1. Go to your dashboard → **API Keys** page
|
|
97
|
+
2. Scroll to **Template Configuration** section
|
|
98
|
+
3. Click **Copy JSON** or **Download**
|
|
99
|
+
4. Paste into your code with `as const`:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
const config = {
|
|
103
|
+
// ... paste here
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
const client = new Mailer({
|
|
107
|
+
workspaceId: 'your-workspace-id',
|
|
108
|
+
apiKey: 'your-api-key',
|
|
109
|
+
templates: config, // Now fully type-safe!
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Via API
|
|
114
|
+
|
|
115
|
+
Alternatively, fetch the config programmatically:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
curl -X GET https://www.ewyn.ai/api/v1/workspaces/YOUR_WORKSPACE_ID/templates/config \
|
|
119
|
+
-H "Authorization: Bearer YOUR_API_KEY"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Note:** Regenerate the config after creating or updating templates to keep it in sync.
|
|
123
|
+
|
|
124
|
+
## API Reference
|
|
125
|
+
|
|
126
|
+
### `Mailer` Class
|
|
127
|
+
|
|
128
|
+
#### Constructor
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
new Mailer(options: MailerOptions)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Options:**
|
|
135
|
+
|
|
136
|
+
- `workspaceId` (string, required): Your workspace UUID
|
|
137
|
+
- `apiKey` (string, required): Your API key secret
|
|
138
|
+
- `baseUrl` (string, optional): Base URL for the API (defaults to `https://www.ewyn.ai/api/v1`)
|
|
139
|
+
- `templates` (TemplateConfig, optional): Template configuration for name-based sending
|
|
140
|
+
- `maxRetries` (number, optional): Maximum retries for retryable errors (default: 3)
|
|
141
|
+
- `timeout` (number, optional): Request timeout in milliseconds (default: 30000)
|
|
142
|
+
|
|
143
|
+
#### Methods
|
|
144
|
+
|
|
145
|
+
##### `send(options: SendEmailOptions): Promise<SendEmailResponse>`
|
|
146
|
+
|
|
147
|
+
Send an email using a template.
|
|
148
|
+
|
|
149
|
+
**Options:**
|
|
150
|
+
|
|
151
|
+
- `to` (string, required): Recipient email address
|
|
152
|
+
- `templateId` (string, optional): Template version UUID (required if `template` not provided)
|
|
153
|
+
- `template` (string, optional): Template name (requires `templates` config)
|
|
154
|
+
- `version` (number, optional): Major version number (use with template name, defaults to latest)
|
|
155
|
+
- `variables` (Record<string, string>, optional): Variables to substitute in template
|
|
156
|
+
- `metadata` (Record<string, unknown>, optional): Additional metadata to attach
|
|
157
|
+
- `idempotencyKey` (string, optional): Key to prevent duplicate sends (valid for 24 hours)
|
|
158
|
+
|
|
159
|
+
**Returns:**
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
{
|
|
163
|
+
messageId: string;
|
|
164
|
+
status: 'queued';
|
|
165
|
+
queuedAt: string;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Throws:**
|
|
170
|
+
|
|
171
|
+
- `MailerApiError`: If the API request fails
|
|
172
|
+
- `Error`: If validation fails (missing template, missing variables, etc.)
|
|
173
|
+
|
|
174
|
+
### Error Handling
|
|
175
|
+
|
|
176
|
+
The SDK throws `MailerApiError` for API errors:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { Mailer, MailerApiError } from '@ewyn/client';
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await client.send({ /* ... */ });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof MailerApiError) {
|
|
185
|
+
console.error('API Error:', error.status, error.message);
|
|
186
|
+
console.error('Details:', error.details);
|
|
187
|
+
|
|
188
|
+
if (error.isRetryable()) {
|
|
189
|
+
// Handle retryable error
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Error Properties:**
|
|
196
|
+
|
|
197
|
+
- `status` (number): HTTP status code
|
|
198
|
+
- `code` (string, optional): Error code
|
|
199
|
+
- `details` (unknown, optional): Additional error details
|
|
200
|
+
- `message` (string): Error message
|
|
201
|
+
|
|
202
|
+
**Error Methods:**
|
|
203
|
+
|
|
204
|
+
- `isClientError()`: Returns `true` for 4xx errors
|
|
205
|
+
- `isServerError()`: Returns `true` for 5xx errors
|
|
206
|
+
- `isRetryable()`: Returns `true` for 429 or 5xx errors
|
|
207
|
+
|
|
208
|
+
## Idempotency
|
|
209
|
+
|
|
210
|
+
To prevent duplicate email sends, provide an `idempotencyKey`:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
await client.send({
|
|
214
|
+
to: 'user@example.com',
|
|
215
|
+
templateId: 'template-uuid',
|
|
216
|
+
idempotencyKey: 'unique-request-id-123',
|
|
217
|
+
variables: { /* ... */ },
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If the same `idempotencyKey` is used within 24 hours, the API will return the original response instead of sending a duplicate email.
|
|
222
|
+
|
|
223
|
+
## Retry Logic
|
|
224
|
+
|
|
225
|
+
The SDK automatically retries on:
|
|
226
|
+
- 429 (Too Many Requests)
|
|
227
|
+
- 5xx (Server Errors)
|
|
228
|
+
- Network errors
|
|
229
|
+
|
|
230
|
+
Retries use exponential backoff (1s, 2s, 4s) up to `maxRetries` (default: 3).
|
|
231
|
+
|
|
232
|
+
## Examples
|
|
233
|
+
|
|
234
|
+
Check out the [examples directory](./examples/) for comprehensive examples:
|
|
235
|
+
|
|
236
|
+
- [Basic send](./examples/basic-send.ts) - Simple sending with template ID
|
|
237
|
+
- [Type-safe usage](./examples/with-template-config.ts) - Full type safety with template config
|
|
238
|
+
- [Error handling](./examples/error-handling.ts) - Comprehensive error handling patterns
|
|
239
|
+
- [Idempotency](./examples/idempotency.ts) - Preventing duplicate sends
|
|
240
|
+
- [Advanced patterns](./examples/advanced-usage.ts) - Batch sending, retries, and more
|
|
241
|
+
|
|
242
|
+
## Testing
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
# Run tests
|
|
246
|
+
pnpm test
|
|
247
|
+
|
|
248
|
+
# Run tests in watch mode
|
|
249
|
+
pnpm test:watch
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# Build the SDK
|
|
256
|
+
pnpm build
|
|
257
|
+
|
|
258
|
+
# Watch mode for development
|
|
259
|
+
pnpm dev
|
|
260
|
+
|
|
261
|
+
# Lint
|
|
262
|
+
pnpm lint
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safety tests for dashboard-copied template configs.
|
|
3
|
+
*
|
|
4
|
+
* Config shape and assertions mirror what users get when they copy
|
|
5
|
+
* "Template Configuration" from the dashboard (API Keys page). Ensures
|
|
6
|
+
* that typed send() options enforce template names and required/optional
|
|
7
|
+
* variables at compile time.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=config-types.test-d.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-types.test-d.d.ts","sourceRoot":"","sources":["../../src/__tests__/config-types.test-d.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safety tests for dashboard-copied template configs.
|
|
3
|
+
*
|
|
4
|
+
* Config shape and assertions mirror what users get when they copy
|
|
5
|
+
* "Template Configuration" from the dashboard (API Keys page). Ensures
|
|
6
|
+
* that typed send() options enforce template names and required/optional
|
|
7
|
+
* variables at compile time.
|
|
8
|
+
*/
|
|
9
|
+
import { expectTypeOf, test } from 'vitest';
|
|
10
|
+
import { Mailer } from '../index.js';
|
|
11
|
+
// Dashboard-shaped config: same structure as getConfig response (id, name, version, vars)
|
|
12
|
+
const dashboardConfig = {
|
|
13
|
+
welcome: {
|
|
14
|
+
id: 'version-uuid-1',
|
|
15
|
+
name: 'Welcome Email',
|
|
16
|
+
version: 1,
|
|
17
|
+
vars: {
|
|
18
|
+
firstName: { required: true },
|
|
19
|
+
plan: { required: false },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
'reset-password': {
|
|
23
|
+
id: 'version-uuid-2',
|
|
24
|
+
name: 'Reset Password',
|
|
25
|
+
version: 2,
|
|
26
|
+
vars: {
|
|
27
|
+
resetUrl: { required: true },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
test('client with dashboard config has typed send', () => {
|
|
32
|
+
const client = new Mailer({
|
|
33
|
+
workspaceId: 'ws',
|
|
34
|
+
apiKey: 'key',
|
|
35
|
+
templates: dashboardConfig,
|
|
36
|
+
});
|
|
37
|
+
expectTypeOf(client).toHaveProperty('send');
|
|
38
|
+
expectTypeOf(client.send).toBeFunction();
|
|
39
|
+
});
|
|
40
|
+
test('valid send: required var present, optional omitted', () => {
|
|
41
|
+
const client = new Mailer({
|
|
42
|
+
workspaceId: 'ws',
|
|
43
|
+
apiKey: 'key',
|
|
44
|
+
templates: dashboardConfig,
|
|
45
|
+
});
|
|
46
|
+
expectTypeOf({
|
|
47
|
+
to: 'x@example.com',
|
|
48
|
+
template: 'welcome',
|
|
49
|
+
variables: { firstName: 'J' },
|
|
50
|
+
}).toMatchTypeOf();
|
|
51
|
+
});
|
|
52
|
+
test('valid send: required and optional vars', () => {
|
|
53
|
+
const client = new Mailer({
|
|
54
|
+
workspaceId: 'ws',
|
|
55
|
+
apiKey: 'key',
|
|
56
|
+
templates: dashboardConfig,
|
|
57
|
+
});
|
|
58
|
+
expectTypeOf({
|
|
59
|
+
to: 'x@example.com',
|
|
60
|
+
template: 'welcome',
|
|
61
|
+
variables: { firstName: 'J', plan: 'Pro' },
|
|
62
|
+
}).toMatchTypeOf();
|
|
63
|
+
});
|
|
64
|
+
test('valid send: hyphenated template name', () => {
|
|
65
|
+
const client = new Mailer({
|
|
66
|
+
workspaceId: 'ws',
|
|
67
|
+
apiKey: 'key',
|
|
68
|
+
templates: dashboardConfig,
|
|
69
|
+
});
|
|
70
|
+
expectTypeOf({
|
|
71
|
+
to: 'x@example.com',
|
|
72
|
+
template: 'reset-password',
|
|
73
|
+
variables: { resetUrl: 'https://example.com/reset' },
|
|
74
|
+
}).toMatchTypeOf();
|
|
75
|
+
});
|
|
76
|
+
test('send return type is Promise<SendEmailResponse>', () => {
|
|
77
|
+
const client = new Mailer({
|
|
78
|
+
workspaceId: 'ws',
|
|
79
|
+
apiKey: 'key',
|
|
80
|
+
templates: dashboardConfig,
|
|
81
|
+
});
|
|
82
|
+
expectTypeOf(client.send).returns.toEqualTypeOf();
|
|
83
|
+
});
|
|
84
|
+
test('template name is constrained to config keys at type level', () => {
|
|
85
|
+
expectTypeOf().toMatchTypeOf();
|
|
86
|
+
expectTypeOf().toMatchTypeOf();
|
|
87
|
+
});
|
|
88
|
+
test('missing required variable is rejected by types', () => {
|
|
89
|
+
const client = new Mailer({
|
|
90
|
+
workspaceId: 'ws',
|
|
91
|
+
apiKey: 'key',
|
|
92
|
+
templates: dashboardConfig,
|
|
93
|
+
});
|
|
94
|
+
expectTypeOf().toMatchTypeOf();
|
|
95
|
+
expectTypeOf().toMatchTypeOf();
|
|
96
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime tests for dashboard-shaped template configs.
|
|
3
|
+
*
|
|
4
|
+
* Config fixtures mirror the exact shape returned by the dashboard
|
|
5
|
+
* (API Keys page → Template Configuration → Copy JSON), which comes from
|
|
6
|
+
* templates.getConfig (templates router). Ensures configs "copied from
|
|
7
|
+
* the dashboard" work with the SDK and that runtime validation behaves correctly.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=dashboard-config.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-config.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dashboard-config.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime tests for dashboard-shaped template configs.
|
|
3
|
+
*
|
|
4
|
+
* Config fixtures mirror the exact shape returned by the dashboard
|
|
5
|
+
* (API Keys page → Template Configuration → Copy JSON), which comes from
|
|
6
|
+
* templates.getConfig (templates router). Ensures configs "copied from
|
|
7
|
+
* the dashboard" work with the SDK and that runtime validation behaves correctly.
|
|
8
|
+
*/
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { Mailer } from '../index.js';
|
|
11
|
+
global.fetch = vi.fn();
|
|
12
|
+
describe('Dashboard config (runtime)', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
// Shape matches getConfig response: id, name, version, vars: Record<string, { required: boolean }>
|
|
17
|
+
const configOnlyRequired = {
|
|
18
|
+
welcome: {
|
|
19
|
+
id: 'version-uuid-1',
|
|
20
|
+
name: 'Welcome Email',
|
|
21
|
+
version: 1,
|
|
22
|
+
vars: {
|
|
23
|
+
firstName: { required: true },
|
|
24
|
+
lastName: { required: true },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const configMixedVars = {
|
|
29
|
+
'reset-password': {
|
|
30
|
+
id: 'version-uuid-2',
|
|
31
|
+
name: 'Reset Password',
|
|
32
|
+
version: 2,
|
|
33
|
+
vars: {
|
|
34
|
+
resetUrl: { required: true },
|
|
35
|
+
expiresIn: { required: false },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const configEmptyVars = {
|
|
40
|
+
notification: {
|
|
41
|
+
id: 'version-uuid-3',
|
|
42
|
+
name: 'Simple Notification',
|
|
43
|
+
version: 1,
|
|
44
|
+
vars: {},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
it('sends with dashboard-shaped config (only required vars)', async () => {
|
|
48
|
+
const client = new Mailer({
|
|
49
|
+
workspaceId: 'ws-1',
|
|
50
|
+
apiKey: 'key-1',
|
|
51
|
+
baseUrl: 'https://api.test.com/api/v1',
|
|
52
|
+
templates: configOnlyRequired,
|
|
53
|
+
maxRetries: 1,
|
|
54
|
+
timeout: 5000,
|
|
55
|
+
});
|
|
56
|
+
const mockResponse = {
|
|
57
|
+
messageId: 'msg-1',
|
|
58
|
+
status: 'queued',
|
|
59
|
+
queuedAt: '2024-01-01T00:00:00Z',
|
|
60
|
+
};
|
|
61
|
+
global.fetch.mockResolvedValueOnce({
|
|
62
|
+
ok: true,
|
|
63
|
+
status: 200,
|
|
64
|
+
json: async () => mockResponse,
|
|
65
|
+
});
|
|
66
|
+
const result = await client.send({
|
|
67
|
+
to: 'user@example.com',
|
|
68
|
+
template: 'welcome',
|
|
69
|
+
variables: {
|
|
70
|
+
firstName: 'Jane',
|
|
71
|
+
lastName: 'Doe',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
expect(result).toEqual(mockResponse);
|
|
75
|
+
expect(global.fetch).toHaveBeenCalledWith('https://api.test.com/api/v1/workspaces/ws-1/send', expect.objectContaining({
|
|
76
|
+
method: 'POST',
|
|
77
|
+
body: expect.stringContaining('"templateId":"version-uuid-1"'),
|
|
78
|
+
}));
|
|
79
|
+
const call = global.fetch.mock.calls[0];
|
|
80
|
+
const body = JSON.parse(call?.[1]?.body ?? '{}');
|
|
81
|
+
expect(body.variables).toEqual({ firstName: 'Jane', lastName: 'Doe' });
|
|
82
|
+
});
|
|
83
|
+
it('sends with dashboard-shaped config (mixed required/optional, hyphenated key)', async () => {
|
|
84
|
+
const client = new Mailer({
|
|
85
|
+
workspaceId: 'ws-2',
|
|
86
|
+
apiKey: 'key-2',
|
|
87
|
+
baseUrl: 'https://api.test.com/api/v1',
|
|
88
|
+
templates: configMixedVars,
|
|
89
|
+
maxRetries: 1,
|
|
90
|
+
timeout: 5000,
|
|
91
|
+
});
|
|
92
|
+
const mockResponse = {
|
|
93
|
+
messageId: 'msg-2',
|
|
94
|
+
status: 'queued',
|
|
95
|
+
queuedAt: '2024-01-01T00:00:00Z',
|
|
96
|
+
};
|
|
97
|
+
global.fetch.mockResolvedValueOnce({
|
|
98
|
+
ok: true,
|
|
99
|
+
status: 200,
|
|
100
|
+
json: async () => mockResponse,
|
|
101
|
+
});
|
|
102
|
+
const result = await client.send({
|
|
103
|
+
to: 'user@example.com',
|
|
104
|
+
template: 'reset-password',
|
|
105
|
+
variables: {
|
|
106
|
+
resetUrl: 'https://example.com/reset?token=abc',
|
|
107
|
+
expiresIn: '1 hour',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
expect(result).toEqual(mockResponse);
|
|
111
|
+
const call = global.fetch.mock.calls[0];
|
|
112
|
+
const body = JSON.parse(call?.[1]?.body ?? '{}');
|
|
113
|
+
expect(body.templateId).toBe('version-uuid-2');
|
|
114
|
+
expect(body.variables).toEqual({
|
|
115
|
+
resetUrl: 'https://example.com/reset?token=abc',
|
|
116
|
+
expiresIn: '1 hour',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
it('sends with template that has empty vars (dashboard shape)', async () => {
|
|
120
|
+
const client = new Mailer({
|
|
121
|
+
workspaceId: 'ws-3',
|
|
122
|
+
apiKey: 'key-3',
|
|
123
|
+
baseUrl: 'https://api.test.com/api/v1',
|
|
124
|
+
templates: configEmptyVars,
|
|
125
|
+
maxRetries: 1,
|
|
126
|
+
timeout: 5000,
|
|
127
|
+
});
|
|
128
|
+
const mockResponse = {
|
|
129
|
+
messageId: 'msg-3',
|
|
130
|
+
status: 'queued',
|
|
131
|
+
queuedAt: '2024-01-01T00:00:00Z',
|
|
132
|
+
};
|
|
133
|
+
global.fetch.mockResolvedValueOnce({
|
|
134
|
+
ok: true,
|
|
135
|
+
status: 200,
|
|
136
|
+
json: async () => mockResponse,
|
|
137
|
+
});
|
|
138
|
+
const result = await client.send({
|
|
139
|
+
to: 'user@example.com',
|
|
140
|
+
template: 'notification',
|
|
141
|
+
variables: {},
|
|
142
|
+
});
|
|
143
|
+
expect(result).toEqual(mockResponse);
|
|
144
|
+
const call = global.fetch.mock.calls[0];
|
|
145
|
+
const body = JSON.parse(call?.[1]?.body ?? '{}');
|
|
146
|
+
expect(body.templateId).toBe('version-uuid-3');
|
|
147
|
+
expect(body.variables).toEqual({});
|
|
148
|
+
});
|
|
149
|
+
it('throws when required variable is missing (dashboard config)', async () => {
|
|
150
|
+
const client = new Mailer({
|
|
151
|
+
workspaceId: 'ws-1',
|
|
152
|
+
apiKey: 'key-1',
|
|
153
|
+
baseUrl: 'https://api.test.com/api/v1',
|
|
154
|
+
templates: configOnlyRequired,
|
|
155
|
+
maxRetries: 1,
|
|
156
|
+
timeout: 5000,
|
|
157
|
+
});
|
|
158
|
+
await expect(client.send({
|
|
159
|
+
to: 'user@example.com',
|
|
160
|
+
template: 'welcome',
|
|
161
|
+
variables: {
|
|
162
|
+
firstName: 'Jane',
|
|
163
|
+
// lastName missing
|
|
164
|
+
},
|
|
165
|
+
})).rejects.toThrow('Missing required variables for template "welcome": lastName');
|
|
166
|
+
});
|
|
167
|
+
it('throws when template name is not in config (dashboard config)', async () => {
|
|
168
|
+
const client = new Mailer({
|
|
169
|
+
workspaceId: 'ws-1',
|
|
170
|
+
apiKey: 'key-1',
|
|
171
|
+
baseUrl: 'https://api.test.com/api/v1',
|
|
172
|
+
templates: configOnlyRequired,
|
|
173
|
+
maxRetries: 1,
|
|
174
|
+
timeout: 5000,
|
|
175
|
+
});
|
|
176
|
+
await expect(client.send({
|
|
177
|
+
to: 'user@example.com',
|
|
178
|
+
template: 'nonexistent',
|
|
179
|
+
variables: { firstName: 'J', lastName: 'D' },
|
|
180
|
+
})).rejects.toThrow('Template "nonexistent" not found in template config');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/errors.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { MailerApiError } from '../errors.js';
|
|
3
|
+
describe('MailerApiError', () => {
|
|
4
|
+
describe('constructor', () => {
|
|
5
|
+
it('should create error with status code', () => {
|
|
6
|
+
const error = new MailerApiError(400);
|
|
7
|
+
expect(error).toBeInstanceOf(Error);
|
|
8
|
+
expect(error).toBeInstanceOf(MailerApiError);
|
|
9
|
+
expect(error.status).toBe(400);
|
|
10
|
+
expect(error.message).toBe('API request failed with status 400');
|
|
11
|
+
expect(error.name).toBe('MailerApiError');
|
|
12
|
+
});
|
|
13
|
+
it('should create error with custom message', () => {
|
|
14
|
+
const error = new MailerApiError(400, 'INVALID_REQUEST', undefined, 'Invalid email address');
|
|
15
|
+
expect(error.status).toBe(400);
|
|
16
|
+
expect(error.code).toBe('INVALID_REQUEST');
|
|
17
|
+
expect(error.message).toBe('Invalid email address');
|
|
18
|
+
});
|
|
19
|
+
it('should create error with details', () => {
|
|
20
|
+
const details = { field: 'to', reason: 'Invalid email format' };
|
|
21
|
+
const error = new MailerApiError(400, 'VALIDATION_ERROR', details);
|
|
22
|
+
expect(error.status).toBe(400);
|
|
23
|
+
expect(error.code).toBe('VALIDATION_ERROR');
|
|
24
|
+
expect(error.details).toEqual(details);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('isClientError', () => {
|
|
28
|
+
it('should return true for 4xx errors', () => {
|
|
29
|
+
expect(new MailerApiError(400).isClientError()).toBe(true);
|
|
30
|
+
expect(new MailerApiError(401).isClientError()).toBe(true);
|
|
31
|
+
expect(new MailerApiError(404).isClientError()).toBe(true);
|
|
32
|
+
expect(new MailerApiError(429).isClientError()).toBe(true);
|
|
33
|
+
expect(new MailerApiError(499).isClientError()).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('should return false for non-4xx errors', () => {
|
|
36
|
+
expect(new MailerApiError(200).isClientError()).toBe(false);
|
|
37
|
+
expect(new MailerApiError(399).isClientError()).toBe(false);
|
|
38
|
+
expect(new MailerApiError(500).isClientError()).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('isServerError', () => {
|
|
42
|
+
it('should return true for 5xx errors', () => {
|
|
43
|
+
expect(new MailerApiError(500).isServerError()).toBe(true);
|
|
44
|
+
expect(new MailerApiError(502).isServerError()).toBe(true);
|
|
45
|
+
expect(new MailerApiError(503).isServerError()).toBe(true);
|
|
46
|
+
expect(new MailerApiError(599).isServerError()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('should return false for non-5xx errors', () => {
|
|
49
|
+
expect(new MailerApiError(200).isServerError()).toBe(false);
|
|
50
|
+
expect(new MailerApiError(400).isServerError()).toBe(false);
|
|
51
|
+
expect(new MailerApiError(499).isServerError()).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('isRetryable', () => {
|
|
55
|
+
it('should return true for 429 rate limit', () => {
|
|
56
|
+
expect(new MailerApiError(429).isRetryable()).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it('should return true for 5xx errors', () => {
|
|
59
|
+
expect(new MailerApiError(500).isRetryable()).toBe(true);
|
|
60
|
+
expect(new MailerApiError(502).isRetryable()).toBe(true);
|
|
61
|
+
expect(new MailerApiError(503).isRetryable()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('should return false for other errors', () => {
|
|
64
|
+
expect(new MailerApiError(200).isRetryable()).toBe(false);
|
|
65
|
+
expect(new MailerApiError(400).isRetryable()).toBe(false);
|
|
66
|
+
expect(new MailerApiError(401).isRetryable()).toBe(false);
|
|
67
|
+
expect(new MailerApiError(404).isRetryable()).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('error prototype chain', () => {
|
|
71
|
+
it('should maintain correct prototype chain', () => {
|
|
72
|
+
const error = new MailerApiError(400);
|
|
73
|
+
expect(error instanceof Error).toBe(true);
|
|
74
|
+
expect(error instanceof MailerApiError).toBe(true);
|
|
75
|
+
expect(Object.getPrototypeOf(error)).toBe(MailerApiError.prototype);
|
|
76
|
+
});
|
|
77
|
+
it('should work with try-catch instanceof checks', () => {
|
|
78
|
+
try {
|
|
79
|
+
throw new MailerApiError(400, 'TEST_ERROR');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
expect(error instanceof MailerApiError).toBe(true);
|
|
83
|
+
expect(error instanceof Error).toBe(true);
|
|
84
|
+
if (error instanceof MailerApiError) {
|
|
85
|
+
expect(error.code).toBe('TEST_ERROR');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mailer.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/mailer.test.ts"],"names":[],"mappings":""}
|