@bernierllc/sender-identity-verification 1.0.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.
Files changed (51) hide show
  1. package/.env.test +8 -0
  2. package/.eslintrc.js +30 -0
  3. package/README.md +376 -0
  4. package/__tests__/SenderIdentityVerification.test.ts +461 -0
  5. package/__tests__/__mocks__/fetch-mock.ts +156 -0
  6. package/__tests__/additional-coverage.test.ts +129 -0
  7. package/__tests__/additional-error-coverage.test.ts +483 -0
  8. package/__tests__/branch-coverage.test.ts +509 -0
  9. package/__tests__/config.test.ts +119 -0
  10. package/__tests__/error-handling.test.ts +321 -0
  11. package/__tests__/final-branch-coverage.test.ts +372 -0
  12. package/__tests__/integration.real-api.test.ts +295 -0
  13. package/__tests__/providers.test.ts +331 -0
  14. package/__tests__/service-coverage.test.ts +412 -0
  15. package/dist/SenderIdentityVerification.d.ts +72 -0
  16. package/dist/SenderIdentityVerification.js +643 -0
  17. package/dist/config.d.ts +31 -0
  18. package/dist/config.js +38 -0
  19. package/dist/errors.d.ts +27 -0
  20. package/dist/errors.js +61 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.js +21 -0
  23. package/dist/providers/MailgunProvider.d.ts +13 -0
  24. package/dist/providers/MailgunProvider.js +35 -0
  25. package/dist/providers/SESProvider.d.ts +12 -0
  26. package/dist/providers/SESProvider.js +47 -0
  27. package/dist/providers/SMTPProvider.d.ts +12 -0
  28. package/dist/providers/SMTPProvider.js +30 -0
  29. package/dist/providers/SendGridProvider.d.ts +19 -0
  30. package/dist/providers/SendGridProvider.js +98 -0
  31. package/dist/templates/verification-email.d.ts +9 -0
  32. package/dist/templates/verification-email.js +67 -0
  33. package/dist/types.d.ts +139 -0
  34. package/dist/types.js +33 -0
  35. package/dist/utils/domain-extractor.d.ts +4 -0
  36. package/dist/utils/domain-extractor.js +20 -0
  37. package/jest.config.cjs +33 -0
  38. package/package.json +60 -0
  39. package/src/SenderIdentityVerification.ts +796 -0
  40. package/src/config.ts +81 -0
  41. package/src/errors.ts +64 -0
  42. package/src/global.d.ts +24 -0
  43. package/src/index.ts +24 -0
  44. package/src/providers/MailgunProvider.ts +35 -0
  45. package/src/providers/SESProvider.ts +51 -0
  46. package/src/providers/SMTPProvider.ts +29 -0
  47. package/src/providers/SendGridProvider.ts +108 -0
  48. package/src/templates/verification-email.ts +67 -0
  49. package/src/types.ts +163 -0
  50. package/src/utils/domain-extractor.ts +18 -0
  51. package/tsconfig.json +22 -0
@@ -0,0 +1,461 @@
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 {
10
+ SenderIdentityVerification,
11
+ EmailProvider,
12
+ SenderStatus,
13
+ CreateSenderInput,
14
+ UpdateSenderInput
15
+ } from '../src/index';
16
+
17
+ describe('SenderIdentityVerification', () => {
18
+ let service: SenderIdentityVerification;
19
+
20
+ beforeEach(async () => {
21
+ service = new SenderIdentityVerification({
22
+ verificationBaseUrl: 'https://test.example.com',
23
+ verificationFromEmail: 'noreply@test.com',
24
+ verificationFromName: 'Test Verification',
25
+ sendgridApiKey: 'test-key',
26
+ emailSenderConfig: {},
27
+ domainVerificationConfig: {}
28
+ });
29
+
30
+ await service.initialize();
31
+ });
32
+
33
+ describe('createSender', () => {
34
+ it('should create sender successfully with valid input', async () => {
35
+ const input: CreateSenderInput = {
36
+ email: 'sender@example.com',
37
+ name: 'Test Sender',
38
+ provider: EmailProvider.SENDGRID,
39
+ skipVerification: true
40
+ };
41
+
42
+ const result = await service.createSender(input);
43
+
44
+ expect(result.success).toBe(true);
45
+ expect(result.data).toBeDefined();
46
+ expect(result.data?.email).toBe('sender@example.com');
47
+ expect(result.data?.name).toBe('Test Sender');
48
+ expect(result.data?.status).toBe(SenderStatus.VERIFIED);
49
+ });
50
+
51
+ it('should create sender with pending status when not skipping verification', async () => {
52
+ const input: CreateSenderInput = {
53
+ email: 'pending@example.com',
54
+ name: 'Pending Sender',
55
+ provider: EmailProvider.SENDGRID,
56
+ skipVerification: false
57
+ };
58
+
59
+ const result = await service.createSender(input);
60
+
61
+ expect(result.success).toBe(true);
62
+ expect(result.data?.status).toBe(SenderStatus.VERIFICATION_SENT);
63
+ });
64
+
65
+ it('should fail when creating duplicate sender', async () => {
66
+ const input: CreateSenderInput = {
67
+ email: 'duplicate@example.com',
68
+ name: 'Duplicate Sender',
69
+ provider: EmailProvider.SENDGRID,
70
+ skipVerification: true
71
+ };
72
+
73
+ // Create first sender
74
+ await service.createSender(input);
75
+
76
+ // Try to create duplicate
77
+ const result = await service.createSender(input);
78
+
79
+ expect(result.success).toBe(false);
80
+ expect(result.error).toContain('already exists');
81
+ });
82
+
83
+ it('should fail with invalid email format', async () => {
84
+ const input: CreateSenderInput = {
85
+ email: 'invalid-email',
86
+ name: 'Invalid Sender',
87
+ provider: EmailProvider.SENDGRID
88
+ };
89
+
90
+ const result = await service.createSender(input);
91
+
92
+ expect(result.success).toBe(false);
93
+ expect(result.error).toContain('Invalid email');
94
+ });
95
+
96
+ it('should set default reply-to email if not provided', async () => {
97
+ const input: CreateSenderInput = {
98
+ email: 'sender@example.com',
99
+ name: 'Test Sender',
100
+ provider: EmailProvider.SENDGRID,
101
+ skipVerification: true
102
+ };
103
+
104
+ const result = await service.createSender(input);
105
+
106
+ expect(result.success).toBe(true);
107
+ expect(result.data?.replyToEmail).toBe('sender@example.com');
108
+ expect(result.data?.replyToName).toBe('Test Sender');
109
+ });
110
+
111
+ it('should use custom reply-to if provided', async () => {
112
+ const input: CreateSenderInput = {
113
+ email: 'sender@example.com',
114
+ name: 'Test Sender',
115
+ replyToEmail: 'support@example.com',
116
+ replyToName: 'Support Team',
117
+ provider: EmailProvider.SENDGRID,
118
+ skipVerification: true
119
+ };
120
+
121
+ const result = await service.createSender(input);
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(result.data?.replyToEmail).toBe('support@example.com');
125
+ expect(result.data?.replyToName).toBe('Support Team');
126
+ });
127
+
128
+ it('should set isDefault correctly', async () => {
129
+ const input: CreateSenderInput = {
130
+ email: 'default@example.com',
131
+ name: 'Default Sender',
132
+ provider: EmailProvider.SENDGRID,
133
+ isDefault: true,
134
+ skipVerification: true
135
+ };
136
+
137
+ const result = await service.createSender(input);
138
+
139
+ expect(result.success).toBe(true);
140
+ expect(result.data?.isDefault).toBe(true);
141
+ });
142
+ });
143
+
144
+ describe('getSender', () => {
145
+ it('should retrieve sender by ID', async () => {
146
+ const createResult = await service.createSender({
147
+ email: 'get@example.com',
148
+ name: 'Get Sender',
149
+ provider: EmailProvider.SENDGRID,
150
+ skipVerification: true
151
+ });
152
+
153
+ const senderId = createResult.data!.id;
154
+ const getResult = await service.getSender(senderId);
155
+
156
+ expect(getResult.success).toBe(true);
157
+ expect(getResult.data?.id).toBe(senderId);
158
+ expect(getResult.data?.email).toBe('get@example.com');
159
+ });
160
+
161
+ it('should fail when sender not found', async () => {
162
+ const result = await service.getSender('non-existent-id');
163
+
164
+ expect(result.success).toBe(false);
165
+ expect(result.error).toContain('not found');
166
+ });
167
+ });
168
+
169
+ describe('listSenders', () => {
170
+ beforeEach(async () => {
171
+ // Create multiple senders
172
+ await service.createSender({
173
+ email: 'sendgrid1@example.com',
174
+ name: 'SendGrid 1',
175
+ provider: EmailProvider.SENDGRID,
176
+ skipVerification: true
177
+ });
178
+
179
+ await service.createSender({
180
+ email: 'sendgrid2@example.com',
181
+ name: 'SendGrid 2',
182
+ provider: EmailProvider.SENDGRID,
183
+ skipVerification: true
184
+ });
185
+
186
+ await service.createSender({
187
+ email: 'mailgun1@example.com',
188
+ name: 'Mailgun 1',
189
+ provider: EmailProvider.MAILGUN,
190
+ skipVerification: true
191
+ });
192
+ });
193
+
194
+ it('should list all senders', async () => {
195
+ const result = await service.listSenders();
196
+
197
+ expect(result.success).toBe(true);
198
+ expect(result.data?.length).toBeGreaterThanOrEqual(3);
199
+ });
200
+
201
+ it('should filter by provider', async () => {
202
+ const result = await service.listSenders({
203
+ provider: EmailProvider.SENDGRID
204
+ });
205
+
206
+ expect(result.success).toBe(true);
207
+ expect(result.data?.every(s => s.provider === EmailProvider.SENDGRID)).toBe(true);
208
+ });
209
+
210
+ it('should filter by status', async () => {
211
+ const result = await service.listSenders({
212
+ status: SenderStatus.VERIFIED
213
+ });
214
+
215
+ expect(result.success).toBe(true);
216
+ expect(result.data?.every(s => s.status === SenderStatus.VERIFIED)).toBe(true);
217
+ });
218
+
219
+ it('should filter by active status', async () => {
220
+ const result = await service.listSenders({
221
+ isActive: true
222
+ });
223
+
224
+ expect(result.success).toBe(true);
225
+ expect(result.data?.every(s => s.isActive === true)).toBe(true);
226
+ });
227
+
228
+ it('should handle pagination', async () => {
229
+ const result = await service.listSenders({
230
+ limit: 2,
231
+ offset: 0
232
+ });
233
+
234
+ expect(result.success).toBe(true);
235
+ expect(result.data?.length).toBeLessThanOrEqual(2);
236
+ });
237
+ });
238
+
239
+ describe('updateSender', () => {
240
+ it('should update sender name', async () => {
241
+ const createResult = await service.createSender({
242
+ email: 'update@example.com',
243
+ name: 'Original Name',
244
+ provider: EmailProvider.SENDGRID,
245
+ skipVerification: true
246
+ });
247
+
248
+ const senderId = createResult.data!.id;
249
+ const updateInput: UpdateSenderInput = {
250
+ name: 'Updated Name'
251
+ };
252
+
253
+ const updateResult = await service.updateSender(senderId, updateInput);
254
+
255
+ expect(updateResult.success).toBe(true);
256
+ expect(updateResult.data?.name).toBe('Updated Name');
257
+ });
258
+
259
+ it('should update reply-to email', async () => {
260
+ const createResult = await service.createSender({
261
+ email: 'update@example.com',
262
+ name: 'Test',
263
+ provider: EmailProvider.SENDGRID,
264
+ skipVerification: true
265
+ });
266
+
267
+ const senderId = createResult.data!.id;
268
+ const updateInput: UpdateSenderInput = {
269
+ replyToEmail: 'new-reply@example.com'
270
+ };
271
+
272
+ const updateResult = await service.updateSender(senderId, updateInput);
273
+
274
+ expect(updateResult.success).toBe(true);
275
+ expect(updateResult.data?.replyToEmail).toBe('new-reply@example.com');
276
+ });
277
+
278
+ it('should set isDefault and unset other defaults', async () => {
279
+ // Create two senders
280
+ const sender1 = await service.createSender({
281
+ email: 'sender1@example.com',
282
+ name: 'Sender 1',
283
+ provider: EmailProvider.SENDGRID,
284
+ isDefault: true,
285
+ skipVerification: true
286
+ });
287
+
288
+ const sender2 = await service.createSender({
289
+ email: 'sender2@example.com',
290
+ name: 'Sender 2',
291
+ provider: EmailProvider.SENDGRID,
292
+ skipVerification: true
293
+ });
294
+
295
+ // Set sender2 as default
296
+ await service.updateSender(sender2.data!.id, { isDefault: true });
297
+
298
+ // Check that sender1 is no longer default
299
+ const getSender1 = await service.getSender(sender1.data!.id);
300
+ expect(getSender1.data?.isDefault).toBe(false);
301
+
302
+ // Check that sender2 is default
303
+ const getSender2 = await service.getSender(sender2.data!.id);
304
+ expect(getSender2.data?.isDefault).toBe(true);
305
+ });
306
+
307
+ it('should fail when updating non-existent sender', async () => {
308
+ const result = await service.updateSender('non-existent', { name: 'Test' });
309
+
310
+ expect(result.success).toBe(false);
311
+ expect(result.error).toContain('not found');
312
+ });
313
+ });
314
+
315
+ describe('deleteSender', () => {
316
+ it('should soft delete sender', async () => {
317
+ const createResult = await service.createSender({
318
+ email: 'delete@example.com',
319
+ name: 'Delete Sender',
320
+ provider: EmailProvider.SENDGRID,
321
+ skipVerification: true
322
+ });
323
+
324
+ const senderId = createResult.data!.id;
325
+ const deleteResult = await service.deleteSender(senderId);
326
+
327
+ expect(deleteResult.success).toBe(true);
328
+
329
+ // Should not be able to get deleted sender
330
+ const getResult = await service.getSender(senderId);
331
+ expect(getResult.success).toBe(false);
332
+ });
333
+
334
+ it('should fail when deleting non-existent sender', async () => {
335
+ const result = await service.deleteSender('non-existent');
336
+
337
+ expect(result.success).toBe(false);
338
+ expect(result.error).toContain('not found');
339
+ });
340
+ });
341
+
342
+ describe('verifySender', () => {
343
+ it('should verify sender with valid token', async () => {
344
+ const createResult = await service.createSender({
345
+ email: 'verify@example.com',
346
+ name: 'Verify Sender',
347
+ provider: EmailProvider.SMTP, // Use SMTP to avoid provider API calls
348
+ skipVerification: false
349
+ });
350
+
351
+ const sender = createResult.data!;
352
+
353
+ // Get the verification token from the sender
354
+ const senderResult = await service.getSender(sender.id);
355
+ const token = senderResult.data!.verificationToken!;
356
+
357
+ // Verify with token
358
+ const verifyResult = await service.verifySender(token);
359
+
360
+ expect(verifyResult.success).toBe(true);
361
+ expect(verifyResult.status).toBe(SenderStatus.VERIFIED);
362
+ expect(verifyResult.verifiedAt).toBeDefined();
363
+ });
364
+
365
+ it('should return already verified for already verified sender', async () => {
366
+ await service.createSender({
367
+ email: 'already@example.com',
368
+ name: 'Already Verified',
369
+ provider: EmailProvider.SMTP,
370
+ skipVerification: true
371
+ });
372
+
373
+ // Try to verify with a fake token (sender is already verified)
374
+ const result = await service.verifySender('fake-token');
375
+
376
+ expect(result.success).toBe(false);
377
+ expect(result.message).toContain('Invalid');
378
+ });
379
+
380
+ it('should fail with invalid token', async () => {
381
+ const result = await service.verifySender('invalid-token');
382
+
383
+ expect(result.success).toBe(false);
384
+ expect(result.errors).toContain('Token not found or already used');
385
+ });
386
+ });
387
+
388
+ describe('getDefaultSender', () => {
389
+ it('should return default sender for provider', async () => {
390
+ await service.createSender({
391
+ email: 'default@example.com',
392
+ name: 'Default Sender',
393
+ provider: EmailProvider.SENDGRID,
394
+ isDefault: true,
395
+ skipVerification: true
396
+ });
397
+
398
+ const result = await service.getDefaultSender(EmailProvider.SENDGRID);
399
+
400
+ expect(result.success).toBe(true);
401
+ expect(result.data?.email).toBe('default@example.com');
402
+ expect(result.data?.isDefault).toBe(true);
403
+ });
404
+
405
+ it('should fail when no default sender exists', async () => {
406
+ const result = await service.getDefaultSender(EmailProvider.MAILGUN);
407
+
408
+ expect(result.success).toBe(false);
409
+ expect(result.error).toContain('No default sender found');
410
+ });
411
+ });
412
+
413
+ describe('checkCompliance', () => {
414
+ it('should check compliance for sender', async () => {
415
+ const createResult = await service.createSender({
416
+ email: 'compliance@example.com',
417
+ name: 'Compliance Sender',
418
+ provider: EmailProvider.SENDGRID,
419
+ skipVerification: true
420
+ });
421
+
422
+ const senderId = createResult.data!.id;
423
+ const complianceResult = await service.checkCompliance(senderId);
424
+
425
+ expect(complianceResult.success).toBe(true);
426
+ expect(complianceResult.data?.checks).toBeDefined();
427
+ expect(complianceResult.data?.checks.emailFormat).toBe(true);
428
+ });
429
+
430
+ it('should fail when checking compliance for non-existent sender', async () => {
431
+ const result = await service.checkCompliance('non-existent');
432
+
433
+ expect(result.success).toBe(false);
434
+ expect(result.error).toContain('not found');
435
+ });
436
+ });
437
+
438
+ describe('resendVerification', () => {
439
+ it('should fail when resending for verified sender', async () => {
440
+ const createResult = await service.createSender({
441
+ email: 'resend@example.com',
442
+ name: 'Resend Sender',
443
+ provider: EmailProvider.SENDGRID,
444
+ skipVerification: true
445
+ });
446
+
447
+ const senderId = createResult.data!.id;
448
+ const result = await service.resendVerification(senderId);
449
+
450
+ expect(result.success).toBe(false);
451
+ expect(result.error).toContain('already verified');
452
+ });
453
+
454
+ it('should fail when resending for non-existent sender', async () => {
455
+ const result = await service.resendVerification('non-existent');
456
+
457
+ expect(result.success).toBe(false);
458
+ expect(result.error).toContain('not found');
459
+ });
460
+ });
461
+ });
@@ -0,0 +1,156 @@
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
+ /**
10
+ * Fetch mock utilities for testing HTTP calls
11
+ */
12
+
13
+ export interface MockResponse {
14
+ status: number;
15
+ ok: boolean;
16
+ statusText: string;
17
+ data: unknown;
18
+ }
19
+
20
+ export class FetchMock {
21
+ private responses: Map<string, MockResponse[]> = new Map();
22
+ private callHistory: Array<{ url: string; method: string; body?: string }> = [];
23
+
24
+ /**
25
+ * Mock a successful response
26
+ */
27
+ mockSuccess(url: string, data: unknown, status = 200): void {
28
+ const existing = this.responses.get(url) || [];
29
+ existing.push({
30
+ status,
31
+ ok: true,
32
+ statusText: 'OK',
33
+ data
34
+ });
35
+ this.responses.set(url, existing);
36
+ }
37
+
38
+ /**
39
+ * Mock an error response
40
+ */
41
+ mockError(url: string, status: number, data: unknown): void {
42
+ const existing = this.responses.get(url) || [];
43
+ existing.push({
44
+ status,
45
+ ok: false,
46
+ statusText: this.getStatusText(status),
47
+ data
48
+ });
49
+ this.responses.set(url, existing);
50
+ }
51
+
52
+ /**
53
+ * Mock a network error
54
+ */
55
+ mockNetworkError(url: string, message = 'Network error'): void {
56
+ const existing = this.responses.get(url) || [];
57
+ existing.push({
58
+ status: 0,
59
+ ok: false,
60
+ statusText: 'Network Error',
61
+ data: { error: message }
62
+ });
63
+ this.responses.set(url, existing);
64
+ }
65
+
66
+ /**
67
+ * Get the mock fetch function
68
+ */
69
+ getFetchMock(): typeof fetch {
70
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
71
+ const url = typeof input === 'string' ? input : input.toString();
72
+ const method = init?.method || 'GET';
73
+ const body = init?.body?.toString();
74
+
75
+ // Record the call
76
+ this.callHistory.push({ url, method, body });
77
+
78
+ // Get response for this URL
79
+ const responses = this.responses.get(url);
80
+ if (!responses || responses.length === 0) {
81
+ throw new Error(`No mock response configured for ${method} ${url}`);
82
+ }
83
+
84
+ // Get and remove first response (FIFO)
85
+ const response = responses.shift()!;
86
+
87
+ // Network error case
88
+ if (response.status === 0) {
89
+ throw new Error(response.data && typeof response.data === 'object' && 'error' in response.data
90
+ ? String(response.data.error)
91
+ : 'Network error');
92
+ }
93
+
94
+ // Create mock Response object
95
+ return {
96
+ ok: response.ok,
97
+ status: response.status,
98
+ statusText: response.statusText,
99
+ headers: new Headers({ 'Content-Type': 'application/json' }),
100
+ json: async () => response.data,
101
+ text: async () => JSON.stringify(response.data),
102
+ blob: async () => new Blob(),
103
+ arrayBuffer: async () => new ArrayBuffer(0),
104
+ formData: async () => new FormData(),
105
+ clone: function() { return this; },
106
+ body: null,
107
+ bodyUsed: false,
108
+ url,
109
+ redirected: false,
110
+ type: 'basic'
111
+ } as Response;
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Get call history
117
+ */
118
+ getCallHistory(): Array<{ url: string; method: string; body?: string }> {
119
+ return [...this.callHistory];
120
+ }
121
+
122
+ /**
123
+ * Clear all mocks
124
+ */
125
+ clear(): void {
126
+ this.responses.clear();
127
+ this.callHistory = [];
128
+ }
129
+
130
+ /**
131
+ * Get HTTP status text
132
+ */
133
+ private getStatusText(status: number): string {
134
+ const statusTexts: Record<number, string> = {
135
+ 200: 'OK',
136
+ 201: 'Created',
137
+ 204: 'No Content',
138
+ 400: 'Bad Request',
139
+ 401: 'Unauthorized',
140
+ 403: 'Forbidden',
141
+ 404: 'Not Found',
142
+ 429: 'Too Many Requests',
143
+ 500: 'Internal Server Error',
144
+ 502: 'Bad Gateway',
145
+ 503: 'Service Unavailable'
146
+ };
147
+ return statusTexts[status] || 'Unknown';
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Create a new fetch mock instance
153
+ */
154
+ export function createFetchMock(): FetchMock {
155
+ return new FetchMock();
156
+ }