@bernierllc/email-domain-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 (36) hide show
  1. package/.eslintrc.cjs +29 -0
  2. package/README.md +397 -0
  3. package/__mocks__/@bernierllc/crypto-utils.ts +15 -0
  4. package/__mocks__/@bernierllc/logger.ts +42 -0
  5. package/__mocks__/@bernierllc/neverhub-adapter.ts +17 -0
  6. package/__tests__/EmailDomainVerificationService.test.ts +582 -0
  7. package/coverage/clover.xml +263 -0
  8. package/coverage/coverage-final.json +3 -0
  9. package/coverage/lcov-report/EmailDomainVerificationService.ts.html +3061 -0
  10. package/coverage/lcov-report/base.css +224 -0
  11. package/coverage/lcov-report/block-navigation.js +87 -0
  12. package/coverage/lcov-report/errors.ts.html +322 -0
  13. package/coverage/lcov-report/favicon.png +0 -0
  14. package/coverage/lcov-report/index.html +131 -0
  15. package/coverage/lcov-report/prettify.css +1 -0
  16. package/coverage/lcov-report/prettify.js +2 -0
  17. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  18. package/coverage/lcov-report/sorter.js +210 -0
  19. package/coverage/lcov.info +485 -0
  20. package/dist/EmailDomainVerificationService.d.ts +179 -0
  21. package/dist/EmailDomainVerificationService.js +822 -0
  22. package/dist/errors.d.ts +39 -0
  23. package/dist/errors.js +66 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.js +28 -0
  26. package/dist/types.d.ts +209 -0
  27. package/dist/types.js +9 -0
  28. package/jest.config.cjs +44 -0
  29. package/jest.setup.js +19 -0
  30. package/package.json +72 -0
  31. package/src/EmailDomainVerificationService.ts +992 -0
  32. package/src/dns2.d.ts +29 -0
  33. package/src/errors.ts +79 -0
  34. package/src/index.ts +11 -0
  35. package/src/types.ts +281 -0
  36. package/tsconfig.json +30 -0
@@ -0,0 +1,582 @@
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 { EmailDomainVerificationService } from '../src/EmailDomainVerificationService';
10
+ import { DNSRecord } from '../src/types';
11
+
12
+ // Create mock logger directly to avoid type conflicts with real logger
13
+ const createMockLogger = () => ({
14
+ debug: jest.fn(),
15
+ info: jest.fn(),
16
+ warn: jest.fn(),
17
+ error: jest.fn(),
18
+ fatal: jest.fn()
19
+ });
20
+
21
+ // Mock external dependencies only (logger and neverhub-adapter are mocked via moduleNameMapper)
22
+ jest.mock('dns2', () => ({
23
+ Resolver: jest.fn().mockImplementation(() => ({
24
+ resolve: jest.fn().mockResolvedValue({
25
+ answers: [
26
+ { type: 'TXT', data: 'v=spf1 include:sendgrid.net -all' },
27
+ { ns: '8.8.8.8' }
28
+ ]
29
+ })
30
+ }))
31
+ }));
32
+
33
+ jest.mock('axios', () => ({
34
+ create: jest.fn(() => ({
35
+ post: jest.fn().mockResolvedValue({
36
+ data: {
37
+ id: 'domain-123',
38
+ dns: [],
39
+ domain: {
40
+ name: 'example.com',
41
+ state: 'active'
42
+ },
43
+ receiving_dns_records: [],
44
+ sending_dns_records: []
45
+ }
46
+ }),
47
+ get: jest.fn().mockResolvedValue({
48
+ data: [{
49
+ domain: 'example.com',
50
+ id: 'domain-123'
51
+ }]
52
+ })
53
+ }))
54
+ }));
55
+
56
+ jest.mock('cron', () => ({
57
+ CronJob: jest.fn().mockImplementation((_schedule: string, _callback: () => void) => ({
58
+ start: jest.fn(),
59
+ stop: jest.fn()
60
+ }))
61
+ }));
62
+
63
+ describe('EmailDomainVerificationService', () => {
64
+ let service: EmailDomainVerificationService;
65
+ let logger: ReturnType<typeof createMockLogger>;
66
+
67
+ beforeEach(() => {
68
+ logger = createMockLogger();
69
+ service = new EmailDomainVerificationService({ logger: logger as any });
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await service.shutdown();
74
+ jest.clearAllMocks();
75
+ });
76
+
77
+ describe('setupDomain', () => {
78
+ it('should generate SPF record with provider includes', async () => {
79
+ const result = await service.setupDomain({
80
+ domain: 'example.com',
81
+ provider: 'sendgrid',
82
+ providerCredentials: { apiKey: 'test-key' },
83
+ dkim: { enabled: false },
84
+ spf: { enabled: true, strict: true },
85
+ dmarc: { enabled: false }
86
+ });
87
+
88
+ expect(result.success).toBe(true);
89
+ expect(result.dnsRecords.length).toBeGreaterThan(0);
90
+
91
+ const spfRecord = result.dnsRecords.find(r => r.type === 'SPF');
92
+ expect(spfRecord).toBeDefined();
93
+ expect(spfRecord!.value).toContain('include:sendgrid.net');
94
+ expect(spfRecord!.value).toContain('-all'); // strict mode
95
+ });
96
+
97
+ it('should generate DKIM keypair and DNS record when enabled', async () => {
98
+ const result = await service.setupDomain({
99
+ domain: 'example.com',
100
+ provider: 'sendgrid',
101
+ providerCredentials: { apiKey: 'test-key' },
102
+ dkim: { enabled: true, selector: 'default', keySize: 2048 },
103
+ spf: { enabled: false },
104
+ dmarc: { enabled: false }
105
+ });
106
+
107
+ expect(result.success).toBe(true);
108
+ expect(result.dkimConfig).toBeDefined();
109
+ expect(result.dkimConfig!.selector).toBe('default');
110
+ expect(result.dkimConfig!.keySize).toBe(2048);
111
+ expect(result.dkimConfig!.privateKey).toMatch(/-----BEGIN PRIVATE KEY-----/);
112
+ expect(result.dkimConfig!.publicKey).toMatch(/-----BEGIN PUBLIC KEY-----/);
113
+
114
+ const dkimRecord = result.dnsRecords.find(r => r.type === 'DKIM');
115
+ expect(dkimRecord).toBeDefined();
116
+ expect(dkimRecord!.host).toBe('default._domainkey.example.com');
117
+ expect(dkimRecord!.value).toContain('v=DKIM1');
118
+ });
119
+
120
+ it('should generate DMARC record with correct policy', async () => {
121
+ const result = await service.setupDomain({
122
+ domain: 'example.com',
123
+ provider: 'sendgrid',
124
+ providerCredentials: { apiKey: 'test-key' },
125
+ dkim: { enabled: false },
126
+ spf: { enabled: false },
127
+ dmarc: {
128
+ enabled: true,
129
+ policy: 'quarantine',
130
+ percentage: 50,
131
+ reportEmail: 'dmarc@example.com'
132
+ }
133
+ });
134
+
135
+ expect(result.success).toBe(true);
136
+
137
+ const dmarcRecord = result.dnsRecords.find(r => r.type === 'DMARC');
138
+ expect(dmarcRecord).toBeDefined();
139
+ expect(dmarcRecord!.host).toBe('_dmarc.example.com');
140
+ expect(dmarcRecord!.value).toContain('p=quarantine');
141
+ expect(dmarcRecord!.value).toContain('pct=50');
142
+ expect(dmarcRecord!.value).toContain('rua=mailto:dmarc@example.com');
143
+ });
144
+
145
+ it('should reject invalid domain names', async () => {
146
+ const result = await service.setupDomain({
147
+ domain: 'invalid domain with spaces',
148
+ provider: 'sendgrid',
149
+ providerCredentials: { apiKey: 'test-key' },
150
+ dkim: { enabled: false },
151
+ spf: { enabled: true },
152
+ dmarc: { enabled: false }
153
+ });
154
+
155
+ expect(result.success).toBe(false);
156
+ expect(result.status).toBe('failed');
157
+ expect(result.errors).toBeDefined();
158
+ expect(result.errors![0]).toContain('Invalid domain name');
159
+ });
160
+
161
+ it('should generate provider-specific records for SendGrid', async () => {
162
+ const result = await service.setupDomain({
163
+ domain: 'example.com',
164
+ provider: 'sendgrid',
165
+ providerCredentials: { apiKey: 'test-key' },
166
+ dkim: { enabled: false },
167
+ spf: { enabled: false },
168
+ dmarc: { enabled: false }
169
+ });
170
+
171
+ expect(result.success).toBe(true);
172
+ const cnameRecords = result.dnsRecords.filter(r => r.type === 'CNAME');
173
+ expect(cnameRecords.length).toBeGreaterThan(0);
174
+ expect(cnameRecords[0].provider).toBe('sendgrid');
175
+ });
176
+
177
+ it('should generate provider-specific records for Mailgun', async () => {
178
+ const result = await service.setupDomain({
179
+ domain: 'example.com',
180
+ provider: 'mailgun',
181
+ providerCredentials: { apiKey: 'test-key' },
182
+ dkim: { enabled: false },
183
+ spf: { enabled: false },
184
+ dmarc: { enabled: false }
185
+ });
186
+
187
+ expect(result.success).toBe(true);
188
+ const txtRecords = result.dnsRecords.filter(r => r.type === 'TXT');
189
+ expect(txtRecords.length).toBeGreaterThan(0);
190
+ expect(txtRecords[0].value).toContain('mailgun.org');
191
+ });
192
+
193
+ it('should generate provider-specific records for SES', async () => {
194
+ const result = await service.setupDomain({
195
+ domain: 'example.com',
196
+ provider: 'ses',
197
+ providerCredentials: { apiKey: 'test-key' },
198
+ dkim: { enabled: false },
199
+ spf: { enabled: false },
200
+ dmarc: { enabled: false }
201
+ });
202
+
203
+ expect(result.success).toBe(true);
204
+ const txtRecords = result.dnsRecords.filter(r => r.type === 'TXT');
205
+ expect(txtRecords.length).toBeGreaterThan(0);
206
+ expect(txtRecords[0].host).toContain('_amazonses');
207
+ });
208
+
209
+ it('should include custom SPF includes', async () => {
210
+ const result = await service.setupDomain({
211
+ domain: 'example.com',
212
+ provider: 'sendgrid',
213
+ providerCredentials: { apiKey: 'test-key' },
214
+ dkim: { enabled: false },
215
+ spf: {
216
+ enabled: true,
217
+ includes: ['include:custom-provider.com'],
218
+ ipv4Addresses: ['192.168.1.1'],
219
+ ipv6Addresses: ['2001:db8::1']
220
+ },
221
+ dmarc: { enabled: false }
222
+ });
223
+
224
+ expect(result.success).toBe(true);
225
+ const spfRecord = result.dnsRecords.find(r => r.type === 'SPF');
226
+ expect(spfRecord!.value).toContain('include:custom-provider.com');
227
+ expect(spfRecord!.value).toContain('ip4:192.168.1.1');
228
+ expect(spfRecord!.value).toContain('ip6:2001:db8::1');
229
+ });
230
+
231
+ it('should use ~all when strict is false', async () => {
232
+ const result = await service.setupDomain({
233
+ domain: 'example.com',
234
+ provider: 'sendgrid',
235
+ providerCredentials: { apiKey: 'test-key' },
236
+ dkim: { enabled: false },
237
+ spf: { enabled: true, strict: false },
238
+ dmarc: { enabled: false }
239
+ });
240
+
241
+ expect(result.success).toBe(true);
242
+ const spfRecord = result.dnsRecords.find(r => r.type === 'SPF');
243
+ expect(spfRecord!.value).toContain('~all');
244
+ expect(spfRecord!.value).not.toContain('-all');
245
+ });
246
+ });
247
+
248
+ describe('checkPropagation', () => {
249
+ it('should check DNS propagation across nameservers', async () => {
250
+ const records: DNSRecord[] = [{
251
+ type: 'SPF',
252
+ host: 'example.com',
253
+ value: 'v=spf1 include:sendgrid.net -all',
254
+ ttl: 3600,
255
+ provider: 'sendgrid',
256
+ required: true,
257
+ instructions: 'Add SPF record'
258
+ }];
259
+
260
+ const results = await service.checkPropagation('example.com', records);
261
+
262
+ expect(results).toHaveLength(1);
263
+ expect(results[0].domain).toBe('example.com');
264
+ expect(results[0].record).toEqual(records[0]);
265
+ expect(results[0].propagationPercentage).toBeGreaterThanOrEqual(0);
266
+ expect(results[0].propagationPercentage).toBeLessThanOrEqual(100);
267
+ });
268
+
269
+ it('should handle DNS query failures gracefully', async () => {
270
+ const records: DNSRecord[] = [{
271
+ type: 'DKIM',
272
+ host: 'nonexistent._domainkey.example.com',
273
+ value: 'v=DKIM1; k=rsa; p=...',
274
+ ttl: 3600,
275
+ provider: 'sendgrid',
276
+ required: true,
277
+ instructions: 'Add DKIM record'
278
+ }];
279
+
280
+ const results = await service.checkPropagation('example.com', records);
281
+
282
+ expect(results).toHaveLength(1);
283
+ expect(results[0].propagated).toBe(false);
284
+ // Should not throw error - graceful degradation
285
+ });
286
+
287
+ it('should return propagation results for multiple records', async () => {
288
+ const records: DNSRecord[] = [
289
+ {
290
+ type: 'SPF',
291
+ host: 'example.com',
292
+ value: 'v=spf1 include:sendgrid.net -all',
293
+ ttl: 3600,
294
+ provider: 'sendgrid',
295
+ required: true,
296
+ instructions: 'Add SPF record'
297
+ },
298
+ {
299
+ type: 'DMARC',
300
+ host: '_dmarc.example.com',
301
+ value: 'v=DMARC1; p=none; pct=100; sp=none;',
302
+ ttl: 3600,
303
+ provider: 'sendgrid',
304
+ required: true,
305
+ instructions: 'Add DMARC record'
306
+ }
307
+ ];
308
+
309
+ const results = await service.checkPropagation('example.com', records);
310
+
311
+ expect(results).toHaveLength(2);
312
+ expect(results[0].record.type).toBe('SPF');
313
+ expect(results[1].record.type).toBe('DMARC');
314
+ });
315
+ });
316
+
317
+ describe('verifyDomain', () => {
318
+ it('should verify domain with provider', async () => {
319
+ // Mock successful verification
320
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
321
+ verified: true,
322
+ message: 'Domain verified'
323
+ });
324
+
325
+ const result = await service.verifyDomain(
326
+ 'example.com',
327
+ 'sendgrid',
328
+ { apiKey: 'test-key' }
329
+ );
330
+
331
+ expect(result.success).toBe(true);
332
+ expect(result.status).toBe('verified');
333
+ });
334
+
335
+ it('should handle verification failure', async () => {
336
+ // Mock failed verification
337
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
338
+ verified: false,
339
+ message: 'Verification failed'
340
+ });
341
+
342
+ const result = await service.verifyDomain(
343
+ 'example.com',
344
+ 'sendgrid',
345
+ { apiKey: 'test-key' }
346
+ );
347
+
348
+ expect(result.success).toBe(false);
349
+ expect(result.status).toBe('failed');
350
+ expect(result.errors).toBeDefined();
351
+ });
352
+
353
+ it('should start health monitoring after successful verification', async () => {
354
+ // Mock successful verification
355
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
356
+ verified: true,
357
+ message: 'Domain verified'
358
+ });
359
+
360
+ const result = await service.verifyDomain(
361
+ 'example.com',
362
+ 'sendgrid',
363
+ { apiKey: 'test-key' }
364
+ );
365
+
366
+ expect(result.success).toBe(true);
367
+ expect(result.status).toBe('verified');
368
+
369
+ // Health monitoring should be started
370
+ const jobs = (service as any).healthCheckJobs;
371
+ expect(jobs.has('example.com')).toBe(true);
372
+ });
373
+ });
374
+
375
+ describe('getDomainHealth', () => {
376
+ it('should return domain health status', async () => {
377
+ const health = await service.getDomainHealth('example.com');
378
+
379
+ expect(health.domain).toBe('example.com');
380
+ expect(health.healthy).toBeDefined();
381
+ expect(health.verificationStatus).toBeDefined();
382
+ expect(health.lastChecked).toBeInstanceOf(Date);
383
+ expect(health.records).toBeDefined();
384
+ });
385
+ });
386
+
387
+ describe('getVerificationHistory', () => {
388
+ it('should track verification events in history', async () => {
389
+ await service.setupDomain({
390
+ domain: 'example.com',
391
+ provider: 'sendgrid',
392
+ providerCredentials: { apiKey: 'test-key' },
393
+ dkim: { enabled: false },
394
+ spf: { enabled: true },
395
+ dmarc: { enabled: false }
396
+ });
397
+
398
+ const history = service.getVerificationHistory('example.com');
399
+
400
+ expect(history).toHaveLength(1);
401
+ expect(history[0].domain).toBe('example.com');
402
+ expect(history[0].eventType).toBe('initiated');
403
+ expect(history[0].newStatus).toBe('pending');
404
+ });
405
+
406
+ it('should limit history entries based on limit parameter', async () => {
407
+ // Add multiple entries
408
+ for (let i = 0; i < 10; i++) {
409
+ await service.setupDomain({
410
+ domain: 'example.com',
411
+ provider: 'sendgrid',
412
+ providerCredentials: { apiKey: 'test-key' },
413
+ dkim: { enabled: false },
414
+ spf: { enabled: true },
415
+ dmarc: { enabled: false }
416
+ });
417
+ }
418
+
419
+ const history = service.getVerificationHistory('example.com', 5);
420
+
421
+ expect(history.length).toBeLessThanOrEqual(5);
422
+ });
423
+
424
+ it('should return empty array for domain with no history', () => {
425
+ const history = service.getVerificationHistory('nonexistent.com');
426
+
427
+ expect(history).toEqual([]);
428
+ });
429
+ });
430
+
431
+ describe('NeverHub Integration', () => {
432
+ it('should publish events to NeverHub when passed in constructor', async () => {
433
+ const mockNeverHub = {
434
+ register: jest.fn(),
435
+ publishEvent: jest.fn()
436
+ } as any;
437
+
438
+ const serviceWithNH = new EmailDomainVerificationService({
439
+ logger: logger as any,
440
+ neverhub: mockNeverHub
441
+ });
442
+
443
+ // When neverhub is passed in constructor, it should publish events
444
+ await serviceWithNH.setupDomain({
445
+ domain: 'test-registration.com',
446
+ provider: 'sendgrid',
447
+ providerCredentials: { apiKey: 'test-key' },
448
+ dkim: { enabled: false },
449
+ spf: { enabled: true },
450
+ dmarc: { enabled: false }
451
+ });
452
+
453
+ // Events should be published via the passed NeverHub adapter
454
+ expect(mockNeverHub.publishEvent).toHaveBeenCalled();
455
+ });
456
+
457
+ it('should publish events to NeverHub', async () => {
458
+ const mockNeverHub = {
459
+ register: jest.fn(),
460
+ publishEvent: jest.fn()
461
+ } as any;
462
+
463
+ const serviceWithNH = new EmailDomainVerificationService({
464
+ logger: logger as any,
465
+ neverhub: mockNeverHub
466
+ });
467
+
468
+ await serviceWithNH.setupDomain({
469
+ domain: 'example.com',
470
+ provider: 'sendgrid',
471
+ providerCredentials: { apiKey: 'test-key' },
472
+ dkim: { enabled: false },
473
+ spf: { enabled: true },
474
+ dmarc: { enabled: false }
475
+ });
476
+
477
+ expect(mockNeverHub.publishEvent).toHaveBeenCalledWith({
478
+ type: 'domain.verification.initiated',
479
+ data: expect.objectContaining({
480
+ domain: 'example.com',
481
+ provider: 'sendgrid'
482
+ }),
483
+ source: '@bernierllc/email-domain-verification',
484
+ timestamp: expect.any(String)
485
+ });
486
+ });
487
+
488
+ it('should work without NeverHub', async () => {
489
+ const serviceWithoutNH = new EmailDomainVerificationService({ logger: logger as any });
490
+
491
+ const result = await serviceWithoutNH.setupDomain({
492
+ domain: 'example.com',
493
+ provider: 'sendgrid',
494
+ providerCredentials: { apiKey: 'test-key' },
495
+ dkim: { enabled: false },
496
+ spf: { enabled: true },
497
+ dmarc: { enabled: false }
498
+ });
499
+
500
+ expect(result.success).toBe(true);
501
+ // No errors thrown - graceful degradation
502
+ });
503
+ });
504
+
505
+ describe('Health Monitoring', () => {
506
+ it('should start health monitoring after successful verification', async () => {
507
+ // Mock successful verification
508
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
509
+ verified: true,
510
+ message: 'Domain verified'
511
+ });
512
+
513
+ const result = await service.verifyDomain(
514
+ 'example.com',
515
+ 'sendgrid',
516
+ { apiKey: 'test-key' }
517
+ );
518
+
519
+ expect(result.success).toBe(true);
520
+ expect(result.status).toBe('verified');
521
+
522
+ // Health monitoring should be started
523
+ const jobs = (service as any).healthCheckJobs;
524
+ expect(jobs.has('example.com')).toBe(true);
525
+ });
526
+
527
+ it('should stop health monitoring on shutdown', async () => {
528
+ // Mock successful verification
529
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
530
+ verified: true,
531
+ message: 'Domain verified'
532
+ });
533
+
534
+ await service.verifyDomain('example.com', 'sendgrid', { apiKey: 'test-key' });
535
+
536
+ const jobs = (service as any).healthCheckJobs;
537
+ const initialSize = jobs.size;
538
+
539
+ await service.shutdown();
540
+
541
+ expect(jobs.size).toBe(0);
542
+ expect(initialSize).toBeGreaterThan(0);
543
+ });
544
+
545
+ it('should allow manual stop of health monitoring', async () => {
546
+ // Mock successful verification
547
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
548
+ verified: true,
549
+ message: 'Domain verified'
550
+ });
551
+
552
+ await service.verifyDomain('example.com', 'sendgrid', { apiKey: 'test-key' });
553
+
554
+ const jobs = (service as any).healthCheckJobs;
555
+ expect(jobs.has('example.com')).toBe(true);
556
+
557
+ service.stopHealthMonitoring('example.com');
558
+
559
+ expect(jobs.has('example.com')).toBe(false);
560
+ });
561
+ });
562
+
563
+ describe('Shutdown', () => {
564
+ it('should cleanup all resources on shutdown', async () => {
565
+ // Start multiple health monitors
566
+ jest.spyOn(service as any, 'verifyWithProvider').mockResolvedValue({
567
+ verified: true,
568
+ message: 'Domain verified'
569
+ });
570
+
571
+ await service.verifyDomain('example1.com', 'sendgrid', { apiKey: 'test-key' });
572
+ await service.verifyDomain('example2.com', 'sendgrid', { apiKey: 'test-key' });
573
+
574
+ const jobs = (service as any).healthCheckJobs;
575
+ expect(jobs.size).toBe(2);
576
+
577
+ await service.shutdown();
578
+
579
+ expect(jobs.size).toBe(0);
580
+ });
581
+ });
582
+ });