@bernierllc/email-sendgrid-plugin 1.0.1
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/.eslintrc.js +27 -0
- package/README.md +432 -0
- package/__tests__/api/rate-limiter.test.ts +125 -0
- package/__tests__/converters/sender-converter.test.ts +148 -0
- package/__tests__/converters/template-converter.test.ts +230 -0
- package/__tests__/plugin/sender-manager.test.ts +135 -0
- package/__tests__/plugin/sendgrid-plugin.test.ts +195 -0
- package/__tests__/plugin/template-manager.test.ts +110 -0
- package/__tests__/safety/ownership-validator.test.ts +130 -0
- package/__tests__/safety/prefix-enforcer.test.ts +136 -0
- package/dist/api/errors.d.ts +20 -0
- package/dist/api/errors.js +50 -0
- package/dist/api/rate-limiter.d.ts +17 -0
- package/dist/api/rate-limiter.js +58 -0
- package/dist/api/sendgrid-client.d.ts +21 -0
- package/dist/api/sendgrid-client.js +67 -0
- package/dist/converters/sender-converter.d.ts +6 -0
- package/dist/converters/sender-converter.js +40 -0
- package/dist/converters/template-converter.d.ts +11 -0
- package/dist/converters/template-converter.js +76 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +42 -0
- package/dist/plugin/sender-manager.d.ts +20 -0
- package/dist/plugin/sender-manager.js +124 -0
- package/dist/plugin/sendgrid-plugin.d.ts +60 -0
- package/dist/plugin/sendgrid-plugin.js +204 -0
- package/dist/plugin/sync-manager.d.ts +19 -0
- package/dist/plugin/sync-manager.js +94 -0
- package/dist/plugin/template-manager.d.ts +25 -0
- package/dist/plugin/template-manager.js +152 -0
- package/dist/safety/ownership-validator.d.ts +15 -0
- package/dist/safety/ownership-validator.js +42 -0
- package/dist/safety/prefix-enforcer.d.ts +8 -0
- package/dist/safety/prefix-enforcer.js +34 -0
- package/dist/types/config-types.d.ts +24 -0
- package/dist/types/config-types.js +9 -0
- package/dist/types/plugin-types.d.ts +79 -0
- package/dist/types/plugin-types.js +9 -0
- package/dist/types/sendgrid-types.d.ts +56 -0
- package/dist/types/sendgrid-types.js +9 -0
- package/jest.config.js +25 -0
- package/package.json +42 -0
- package/src/api/errors.ts +49 -0
- package/src/api/rate-limiter.ts +79 -0
- package/src/api/sendgrid-client.ts +86 -0
- package/src/converters/sender-converter.ts +41 -0
- package/src/converters/template-converter.ts +97 -0
- package/src/index.ts +58 -0
- package/src/plugin/sender-manager.ts +166 -0
- package/src/plugin/sendgrid-plugin.ts +316 -0
- package/src/plugin/sync-manager.ts +115 -0
- package/src/plugin/template-manager.ts +240 -0
- package/src/safety/ownership-validator.ts +55 -0
- package/src/safety/prefix-enforcer.ts +37 -0
- package/src/types/config-types.ts +34 -0
- package/src/types/plugin-types.ts +97 -0
- package/src/types/sendgrid-types.ts +69 -0
- package/tsconfig.json +18 -0
package/.eslintrc.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
parser: '@typescript-eslint/parser',
|
|
11
|
+
extends: [
|
|
12
|
+
'eslint:recommended',
|
|
13
|
+
'plugin:@typescript-eslint/recommended',
|
|
14
|
+
],
|
|
15
|
+
parserOptions: {
|
|
16
|
+
ecmaVersion: 2020,
|
|
17
|
+
sourceType: 'module',
|
|
18
|
+
},
|
|
19
|
+
rules: {
|
|
20
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
21
|
+
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
22
|
+
'@typescript-eslint/no-unused-vars': ['error', {
|
|
23
|
+
'argsIgnorePattern': '^_+',
|
|
24
|
+
'varsIgnorePattern': '^_+'
|
|
25
|
+
}],
|
|
26
|
+
},
|
|
27
|
+
};
|
package/README.md
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# @bernierllc/email-sendgrid-plugin
|
|
2
|
+
|
|
3
|
+
SendGrid-specific template management, sender verification, and advanced features plugin for email template services.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @bernierllc/email-sendgrid-plugin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
This package provides a comprehensive plugin for integrating SendGrid's template management and sender verification features into your email template service. It includes:
|
|
14
|
+
|
|
15
|
+
- **Template CRUD Operations** - Create, read, update, and delete SendGrid templates
|
|
16
|
+
- **Verified Sender Management** - Manage and validate SendGrid verified senders
|
|
17
|
+
- **Template Synchronization** - Bi-directional sync between local and SendGrid templates
|
|
18
|
+
- **Safety Features** - Shared account protection and template ownership validation
|
|
19
|
+
- **Rate Limiting** - Built-in API rate limiting to respect SendGrid quotas
|
|
20
|
+
- **Template Versioning** - Support for SendGrid template versions and activation
|
|
21
|
+
|
|
22
|
+
## Key Features
|
|
23
|
+
|
|
24
|
+
### Template Management
|
|
25
|
+
- Full CRUD operations with SendGrid API
|
|
26
|
+
- Template versioning and activation
|
|
27
|
+
- Shared account safety (application-scoped operations)
|
|
28
|
+
- Template prefix enforcement
|
|
29
|
+
- Conflict detection and resolution
|
|
30
|
+
|
|
31
|
+
### Verified Sender Integration
|
|
32
|
+
- Sender verification status checking
|
|
33
|
+
- Domain matching and validation
|
|
34
|
+
- Automatic sender selection
|
|
35
|
+
- Sender configuration management
|
|
36
|
+
|
|
37
|
+
### Safety and Security
|
|
38
|
+
- Application-scoped operations (never access non-app templates)
|
|
39
|
+
- Template prefix enforcement
|
|
40
|
+
- Shared account protection
|
|
41
|
+
- Operation auditing and logging
|
|
42
|
+
- Strict/permissive safety modes
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Basic Setup
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { SendGridTemplatePlugin } from '@bernierllc/email-sendgrid-plugin';
|
|
50
|
+
|
|
51
|
+
const plugin = new SendGridTemplatePlugin({
|
|
52
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
53
|
+
templatePrefix: 'MyApp - ',
|
|
54
|
+
safetyMode: 'strict',
|
|
55
|
+
rateLimits: {
|
|
56
|
+
templatesPerMinute: 60,
|
|
57
|
+
apiCallsPerSecond: 10
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Initialize the plugin
|
|
62
|
+
await plugin.initialize({});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Creating Templates
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { Template } from '@bernierllc/email-sendgrid-plugin';
|
|
69
|
+
|
|
70
|
+
const template: Template = {
|
|
71
|
+
id: 'local-123',
|
|
72
|
+
name: 'Welcome Email',
|
|
73
|
+
subject: 'Welcome to {{appName}}',
|
|
74
|
+
htmlContent: '<html><body><h1>Welcome {{userName}}</h1></body></html>',
|
|
75
|
+
textContent: 'Welcome {{userName}}',
|
|
76
|
+
category: 'transactional',
|
|
77
|
+
tags: ['welcome', 'onboarding'],
|
|
78
|
+
version: '1.0.0',
|
|
79
|
+
isActive: true,
|
|
80
|
+
requiredVariables: ['appName', 'userName'],
|
|
81
|
+
createdAt: new Date(),
|
|
82
|
+
updatedAt: new Date(),
|
|
83
|
+
createdBy: 'system',
|
|
84
|
+
lastModifiedBy: 'system'
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const sendgridTemplateId = await plugin.createTemplate(template);
|
|
88
|
+
console.log(`Created SendGrid template: ${sendgridTemplateId}`);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Managing Templates
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// Get template
|
|
95
|
+
const template = await plugin.getTemplate('sendgrid-template-id');
|
|
96
|
+
|
|
97
|
+
// List all templates
|
|
98
|
+
const templates = await plugin.listTemplates();
|
|
99
|
+
|
|
100
|
+
// Update template
|
|
101
|
+
await plugin.updateTemplate('sendgrid-template-id', updatedTemplate);
|
|
102
|
+
|
|
103
|
+
// Delete template
|
|
104
|
+
await plugin.deleteTemplate('sendgrid-template-id');
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Sender Verification
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// Get all verified senders
|
|
111
|
+
const senders = await plugin.getVerifiedSenders();
|
|
112
|
+
|
|
113
|
+
// Validate sender email
|
|
114
|
+
const validation = await plugin.validateSender('noreply@myapp.com');
|
|
115
|
+
|
|
116
|
+
if (!validation.isValid) {
|
|
117
|
+
console.log('Sender validation failed:');
|
|
118
|
+
validation.errors.forEach(error => console.log(`- ${error}`));
|
|
119
|
+
|
|
120
|
+
console.log('Suggestions:');
|
|
121
|
+
validation.suggestions.forEach(suggestion => console.log(`- ${suggestion}`));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Select best sender for outgoing email
|
|
125
|
+
const bestSender = await plugin.selectBestSender('noreply@myapp.com');
|
|
126
|
+
if (bestSender) {
|
|
127
|
+
console.log(`Using verified sender: ${bestSender.email}`);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Template Synchronization
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// Sync local template to SendGrid
|
|
135
|
+
const syncResult = await plugin.syncToProvider(localTemplate);
|
|
136
|
+
|
|
137
|
+
if (syncResult.success) {
|
|
138
|
+
console.log(`Synced template ${syncResult.action}: ${syncResult.providerTemplateId}`);
|
|
139
|
+
} else {
|
|
140
|
+
console.error(`Sync failed: ${syncResult.error}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Pull template from SendGrid
|
|
144
|
+
const localTemplate = await plugin.syncFromProvider('sendgrid-template-id');
|
|
145
|
+
|
|
146
|
+
// Batch synchronization
|
|
147
|
+
const results = await plugin.batchSyncTemplates([template1, template2, template3]);
|
|
148
|
+
const successful = results.filter(r => r.success).length;
|
|
149
|
+
console.log(`Batch sync: ${successful}/${results.length} successful`);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Template Versioning
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// Create new template version
|
|
156
|
+
const versionId = await plugin.createTemplateVersion('sendgrid-template-id', {
|
|
157
|
+
name: 'v2.0.0',
|
|
158
|
+
subject: 'Updated Subject',
|
|
159
|
+
htmlContent: '<html><body>Updated content</body></html>',
|
|
160
|
+
textContent: 'Updated content',
|
|
161
|
+
active: false
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Activate template version
|
|
165
|
+
await plugin.activateTemplateVersion('sendgrid-template-id', versionId);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
### SendGridPluginConfig
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
interface SendGridPluginConfig {
|
|
174
|
+
// SendGrid API key (required)
|
|
175
|
+
apiKey: string;
|
|
176
|
+
|
|
177
|
+
// Template prefix for shared account safety (required)
|
|
178
|
+
templatePrefix: string;
|
|
179
|
+
|
|
180
|
+
// Safety mode: 'strict' or 'permissive' (required)
|
|
181
|
+
safetyMode: 'strict' | 'permissive';
|
|
182
|
+
|
|
183
|
+
// Whether using shared SendGrid account (optional)
|
|
184
|
+
sharedAccount?: boolean;
|
|
185
|
+
|
|
186
|
+
// API rate limits (optional)
|
|
187
|
+
rateLimits?: {
|
|
188
|
+
templatesPerMinute: number;
|
|
189
|
+
apiCallsPerSecond: number;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Webhook configuration (optional)
|
|
193
|
+
webhookConfig?: {
|
|
194
|
+
secret: string;
|
|
195
|
+
eventTypes: string[];
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Safety Modes
|
|
201
|
+
|
|
202
|
+
**Strict Mode** (Recommended):
|
|
203
|
+
- Only allows operations on templates registered with the application
|
|
204
|
+
- Throws `UnauthorizedTemplateAccessError` for unregistered templates
|
|
205
|
+
- Provides maximum safety in shared account environments
|
|
206
|
+
|
|
207
|
+
**Permissive Mode**:
|
|
208
|
+
- Allows operations on any template
|
|
209
|
+
- Useful for administrative tools or migration scripts
|
|
210
|
+
- Use with caution in shared account environments
|
|
211
|
+
|
|
212
|
+
### Rate Limiting
|
|
213
|
+
|
|
214
|
+
The plugin includes built-in rate limiting to respect SendGrid's API quotas:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
const plugin = new SendGridTemplatePlugin({
|
|
218
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
219
|
+
templatePrefix: 'MyApp - ',
|
|
220
|
+
safetyMode: 'strict',
|
|
221
|
+
rateLimits: {
|
|
222
|
+
templatesPerMinute: 60, // Max 60 template operations per minute
|
|
223
|
+
apiCallsPerSecond: 10 // Max 10 API calls per second
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Plugin Capabilities
|
|
229
|
+
|
|
230
|
+
The plugin provides information about its capabilities:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// Get all capabilities
|
|
234
|
+
const capabilities = plugin.getCapabilities();
|
|
235
|
+
|
|
236
|
+
// Check if feature is supported
|
|
237
|
+
if (plugin.supportsFeature('sender-verification')) {
|
|
238
|
+
console.log('Sender verification is supported');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Get feature configuration
|
|
242
|
+
const config = plugin.getFeatureConfig('template-management');
|
|
243
|
+
console.log(`Supports versioning: ${config.supportsVersioning}`);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Available Capabilities
|
|
247
|
+
|
|
248
|
+
- **template-management**: Full CRUD operations for SendGrid templates
|
|
249
|
+
- **sender-verification**: SendGrid verified sender management
|
|
250
|
+
- **template-analytics**: Template usage and performance analytics
|
|
251
|
+
- **batch-operations**: Efficient bulk template operations
|
|
252
|
+
|
|
253
|
+
## Error Handling
|
|
254
|
+
|
|
255
|
+
The plugin provides specific error types for different failure scenarios:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import {
|
|
259
|
+
SendGridApiError,
|
|
260
|
+
TemplateNotFoundError,
|
|
261
|
+
UnauthorizedTemplateAccessError,
|
|
262
|
+
TemplateConflictError,
|
|
263
|
+
RateLimitError
|
|
264
|
+
} from '@bernierllc/email-sendgrid-plugin';
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await plugin.getTemplate('template-id');
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error instanceof TemplateNotFoundError) {
|
|
270
|
+
console.log('Template does not exist');
|
|
271
|
+
} else if (error instanceof UnauthorizedTemplateAccessError) {
|
|
272
|
+
console.log('Template not owned by this application');
|
|
273
|
+
} else if (error instanceof SendGridApiError) {
|
|
274
|
+
console.log(`SendGrid API error: ${error.statusCode}`);
|
|
275
|
+
} else if (error instanceof RateLimitError) {
|
|
276
|
+
console.log('Rate limit exceeded');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Integration with Email Template Service
|
|
282
|
+
|
|
283
|
+
This plugin is designed to work with `@bernierllc/email-template-service`:
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
import { SendGridTemplatePlugin } from '@bernierllc/email-sendgrid-plugin';
|
|
287
|
+
import { EmailTemplateService } from '@bernierllc/email-template-service';
|
|
288
|
+
|
|
289
|
+
const sendGridPlugin = new SendGridTemplatePlugin({
|
|
290
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
291
|
+
templatePrefix: 'MyApp - ',
|
|
292
|
+
sharedAccount: true,
|
|
293
|
+
safetyMode: 'strict',
|
|
294
|
+
rateLimits: {
|
|
295
|
+
templatesPerMinute: 60,
|
|
296
|
+
apiCallsPerSecond: 10
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const templateService = new EmailTemplateService({
|
|
301
|
+
providers: [sendGridPlugin],
|
|
302
|
+
syncConfig: {
|
|
303
|
+
enabled: true,
|
|
304
|
+
providers: {
|
|
305
|
+
sendgrid: {
|
|
306
|
+
enabled: true,
|
|
307
|
+
strategy: 'auto',
|
|
308
|
+
direction: 'bidirectional'
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await templateService.initialize();
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Testing
|
|
318
|
+
|
|
319
|
+
Run the test suite:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
# Run tests in watch mode
|
|
323
|
+
npm test
|
|
324
|
+
|
|
325
|
+
# Run tests with coverage
|
|
326
|
+
npm run test:coverage
|
|
327
|
+
|
|
328
|
+
# Run tests once
|
|
329
|
+
npm run test:run
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## API Reference
|
|
333
|
+
|
|
334
|
+
### SendGridTemplatePlugin
|
|
335
|
+
|
|
336
|
+
#### Core Methods
|
|
337
|
+
|
|
338
|
+
- `initialize(config: PluginConfig): Promise<void>` - Initialize the plugin
|
|
339
|
+
- `destroy(): Promise<void>` - Cleanup and destroy the plugin
|
|
340
|
+
|
|
341
|
+
#### Template Operations
|
|
342
|
+
|
|
343
|
+
- `createTemplate(template: Template): Promise<string>` - Create a new template
|
|
344
|
+
- `updateTemplate(id: string, template: Template): Promise<void>` - Update existing template
|
|
345
|
+
- `deleteTemplate(id: string): Promise<void>` - Delete a template
|
|
346
|
+
- `getTemplate(id: string): Promise<ProviderTemplate>` - Get template by ID
|
|
347
|
+
- `listTemplates(): Promise<ProviderTemplate[]>` - List all templates
|
|
348
|
+
|
|
349
|
+
#### Sender Operations
|
|
350
|
+
|
|
351
|
+
- `getVerifiedSenders(): Promise<VerifiedSender[]>` - Get all verified senders
|
|
352
|
+
- `validateSender(email: string): Promise<SenderValidationResult>` - Validate sender email
|
|
353
|
+
- `selectBestSender(email: string): Promise<VerifiedSender | null>` - Auto-select best sender
|
|
354
|
+
|
|
355
|
+
#### Synchronization
|
|
356
|
+
|
|
357
|
+
- `syncToProvider(template: Template): Promise<SyncResult>` - Sync template to SendGrid
|
|
358
|
+
- `syncFromProvider(id: string): Promise<Template>` - Pull template from SendGrid
|
|
359
|
+
- `batchSyncTemplates(templates: Template[]): Promise<SyncResult[]>` - Batch sync
|
|
360
|
+
|
|
361
|
+
#### Versioning
|
|
362
|
+
|
|
363
|
+
- `createTemplateVersion(id: string, version: TemplateVersion): Promise<string>` - Create version
|
|
364
|
+
- `activateTemplateVersion(id: string, versionId: string): Promise<void>` - Activate version
|
|
365
|
+
|
|
366
|
+
#### Capabilities
|
|
367
|
+
|
|
368
|
+
- `getCapabilities(): ProviderCapability[]` - Get all capabilities
|
|
369
|
+
- `supportsFeature(feature: string): boolean` - Check feature support
|
|
370
|
+
- `getFeatureConfig(feature: string): FeatureConfig | null` - Get feature config
|
|
371
|
+
|
|
372
|
+
## Integration Status
|
|
373
|
+
|
|
374
|
+
- **Logger**: Optional - All SendGrid operations and safety events
|
|
375
|
+
- **Docs-Suite**: Ready - Complete API documentation
|
|
376
|
+
- **NeverHub**: Optional - SendGrid API health monitoring and template sync events
|
|
377
|
+
|
|
378
|
+
### NeverHub Integration
|
|
379
|
+
|
|
380
|
+
This package supports optional NeverHub integration for enhanced observability and event-driven workflows:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import { SendGridTemplatePlugin } from '@bernierllc/email-sendgrid-plugin';
|
|
384
|
+
import { NeverHubAdapter } from '@bernierllc/neverhub-adapter';
|
|
385
|
+
|
|
386
|
+
const plugin = new SendGridTemplatePlugin({
|
|
387
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
388
|
+
templatePrefix: 'MyApp - ',
|
|
389
|
+
safetyMode: 'strict'
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Auto-detect NeverHub availability
|
|
393
|
+
const neverhub = await NeverHubAdapter.detectNeverHub()
|
|
394
|
+
? new NeverHubAdapter()
|
|
395
|
+
: null;
|
|
396
|
+
|
|
397
|
+
if (neverhub) {
|
|
398
|
+
// Register plugin capabilities
|
|
399
|
+
await neverhub.register({
|
|
400
|
+
type: 'email-plugin',
|
|
401
|
+
name: '@bernierllc/email-sendgrid-plugin',
|
|
402
|
+
capabilities: ['template-management', 'sender-verification']
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Subscribe to template events
|
|
406
|
+
await neverhub.subscribe('template.updated', async (event) => {
|
|
407
|
+
await plugin.syncFromProvider(event.templateId);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
await plugin.initialize({});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The plugin works fully without NeverHub integration - all core functionality is available regardless of NeverHub presence.
|
|
415
|
+
|
|
416
|
+
## Dependencies
|
|
417
|
+
|
|
418
|
+
- `@sendgrid/mail` - Official SendGrid SDK
|
|
419
|
+
- `@bernierllc/logger` - Operation logging (optional)
|
|
420
|
+
- `@bernierllc/retry-policy` - API call retries
|
|
421
|
+
- `@bernierllc/rate-limiter` - API rate limiting
|
|
422
|
+
|
|
423
|
+
## License
|
|
424
|
+
|
|
425
|
+
Copyright (c) 2025 Bernier LLC. All rights reserved.
|
|
426
|
+
|
|
427
|
+
## See Also
|
|
428
|
+
|
|
429
|
+
- [@bernierllc/email-template-service](../email-template-service) - Email template service
|
|
430
|
+
- [@bernierllc/email-sender](../email-sender) - Email sending abstraction
|
|
431
|
+
- [@bernierllc/retry-policy](../retry-policy) - Retry policy utilities
|
|
432
|
+
- [@bernierllc/rate-limiter](../rate-limiter) - Rate limiting utilities
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SimpleRateLimiter } from '../../src/api/rate-limiter';
|
|
10
|
+
import { RateLimitError } from '../../src/api/errors';
|
|
11
|
+
|
|
12
|
+
describe('SimpleRateLimiter', () => {
|
|
13
|
+
describe('constructor', () => {
|
|
14
|
+
it('should create rate limiter with specified limits', () => {
|
|
15
|
+
const limiter = new SimpleRateLimiter({
|
|
16
|
+
maxRequestsPerSecond: 10,
|
|
17
|
+
maxRequestsPerMinute: 100
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(limiter).toBeDefined();
|
|
21
|
+
expect(limiter.getRemainingCapacity()).toEqual({
|
|
22
|
+
perSecond: 10,
|
|
23
|
+
perMinute: 100
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('acquire', () => {
|
|
29
|
+
it('should allow requests within per-second limit', async () => {
|
|
30
|
+
const limiter = new SimpleRateLimiter({
|
|
31
|
+
maxRequestsPerSecond: 5,
|
|
32
|
+
maxRequestsPerMinute: 100
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Should allow 5 requests without delay
|
|
36
|
+
for (let i = 0; i < 5; i++) {
|
|
37
|
+
await expect(limiter.acquire()).resolves.toBeUndefined();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should enforce per-second rate limit', async () => {
|
|
42
|
+
const limiter = new SimpleRateLimiter({
|
|
43
|
+
maxRequestsPerSecond: 2,
|
|
44
|
+
maxRequestsPerMinute: 100
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Use up per-second quota
|
|
48
|
+
await limiter.acquire();
|
|
49
|
+
await limiter.acquire();
|
|
50
|
+
|
|
51
|
+
const start = Date.now();
|
|
52
|
+
await limiter.acquire(); // Should wait
|
|
53
|
+
const elapsed = Date.now() - start;
|
|
54
|
+
|
|
55
|
+
expect(elapsed).toBeGreaterThanOrEqual(900); // Allow some margin
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw error if per-minute limit wait time exceeds threshold', async () => {
|
|
59
|
+
const limiter = new SimpleRateLimiter({
|
|
60
|
+
maxRequestsPerSecond: 100,
|
|
61
|
+
maxRequestsPerMinute: 2
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Use up per-minute quota
|
|
65
|
+
await limiter.acquire();
|
|
66
|
+
await limiter.acquire();
|
|
67
|
+
|
|
68
|
+
// Next request should throw since wait > 5s
|
|
69
|
+
await expect(limiter.acquire()).rejects.toThrow(RateLimitError);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.skip('should clean up old timestamps (test too slow)', async () => {
|
|
73
|
+
// This test would take >60s to run, skipping for CI/CD speed
|
|
74
|
+
// Manual verification: timestamps older than 60s are filtered out
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('getRemainingCapacity', () => {
|
|
79
|
+
it('should return correct remaining capacity', async () => {
|
|
80
|
+
const limiter = new SimpleRateLimiter({
|
|
81
|
+
maxRequestsPerSecond: 5,
|
|
82
|
+
maxRequestsPerMinute: 50
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Initial capacity
|
|
86
|
+
expect(limiter.getRemainingCapacity()).toEqual({
|
|
87
|
+
perSecond: 5,
|
|
88
|
+
perMinute: 50
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// After 2 requests
|
|
92
|
+
await limiter.acquire();
|
|
93
|
+
await limiter.acquire();
|
|
94
|
+
|
|
95
|
+
expect(limiter.getRemainingCapacity()).toEqual({
|
|
96
|
+
perSecond: 3,
|
|
97
|
+
perMinute: 48
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should never return negative capacity', async () => {
|
|
102
|
+
const limiter = new SimpleRateLimiter({
|
|
103
|
+
maxRequestsPerSecond: 1,
|
|
104
|
+
maxRequestsPerMinute: 10
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await limiter.acquire();
|
|
108
|
+
|
|
109
|
+
const capacity = limiter.getRemainingCapacity();
|
|
110
|
+
expect(capacity.perSecond).toBeGreaterThanOrEqual(0);
|
|
111
|
+
expect(capacity.perMinute).toBeGreaterThanOrEqual(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('release', () => {
|
|
116
|
+
it('should be a no-op', () => {
|
|
117
|
+
const limiter = new SimpleRateLimiter({
|
|
118
|
+
maxRequestsPerSecond: 10,
|
|
119
|
+
maxRequestsPerMinute: 100
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(() => limiter.release()).not.toThrow();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|