@bernierllc/email-domain-verification 1.2.0 → 3.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.
@@ -1,998 +0,0 @@
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 { Logger, LogLevel, ConsoleTransport } from '@bernierllc/logger';
10
- import { generateUUIDKey } from '@bernierllc/crypto-utils';
11
- import { NeverHubAdapter } from '@bernierllc/neverhub-adapter';
12
- import { Resolver } from 'dns2';
13
- import axios, { AxiosInstance } from 'axios';
14
- import { CronJob } from 'cron';
15
- import { generateKeyPairSync } from 'crypto';
16
- import {
17
- DomainConfig,
18
- DomainVerificationResult,
19
- DNSPropagationResult,
20
- DomainHealthStatus,
21
- VerificationHistoryEntry,
22
- DNSRecord,
23
- DKIMConfig,
24
- VerificationStatus,
25
- EmailProvider
26
- } from './types';
27
- import { InvalidDomainError } from './errors';
28
-
29
- /**
30
- * Email Domain Verification Service
31
- *
32
- * Manages domain verification for email service providers including
33
- * DNS record generation, propagation monitoring, and health checks.
34
- */
35
- export class EmailDomainVerificationService {
36
- private logger: Logger;
37
- private neverhub?: NeverHubAdapter;
38
- private httpClient: AxiosInstance;
39
- private healthCheckJobs: Map<string, CronJob>;
40
- private verificationHistory: Map<string, VerificationHistoryEntry[]>;
41
-
42
- constructor(config?: {
43
- logger?: Logger;
44
- neverhub?: NeverHubAdapter;
45
- healthCheckInterval?: string; // Cron expression
46
- }) {
47
- this.logger = config?.logger || new Logger({
48
- level: LogLevel.INFO,
49
- transports: [new ConsoleTransport({ level: LogLevel.INFO })]
50
- });
51
- this.neverhub = config?.neverhub;
52
- this.httpClient = axios.create({
53
- timeout: 30000,
54
- headers: { 'User-Agent': '@bernierllc/email-domain-verification/1.0.0' }
55
- });
56
- this.healthCheckJobs = new Map();
57
- this.verificationHistory = new Map();
58
- }
59
-
60
- /**
61
- * Initialize service with NeverHub registration
62
- */
63
- async initialize(): Promise<void> {
64
- if (await NeverHubAdapter.detect()) {
65
- this.neverhub = new NeverHubAdapter();
66
- await this.registerWithNeverHub();
67
- }
68
-
69
- this.logger.info('Email Domain Verification Service initialized', {
70
- neverhubEnabled: !!this.neverhub
71
- });
72
- }
73
-
74
- /**
75
- * Setup domain verification for a provider
76
- *
77
- * @param config - Domain configuration
78
- * @returns Verification result with DNS records to configure
79
- */
80
- async setupDomain(config: DomainConfig): Promise<DomainVerificationResult> {
81
- this.logger.info('Setting up domain verification', {
82
- domain: config.domain,
83
- provider: config.provider
84
- });
85
-
86
- try {
87
- // Validate domain
88
- this.validateDomain(config.domain);
89
-
90
- // Generate DNS records based on provider and configuration
91
- const dnsRecords = await this.generateDNSRecords(config);
92
-
93
- // Generate DKIM keypair if enabled
94
- let dkimConfig: DKIMConfig | undefined;
95
- if (config.dkim.enabled) {
96
- dkimConfig = await this.generateDKIMConfig(config);
97
- dnsRecords.push(dkimConfig.dnsRecord);
98
- }
99
-
100
- // Register domain with provider
101
- const providerData = await this.registerWithProvider(config, dkimConfig);
102
-
103
- // Record verification initiation
104
- this.addHistoryEntry(config.domain, {
105
- eventType: 'initiated',
106
- details: `Domain verification initiated for ${config.provider}`,
107
- newStatus: 'pending'
108
- });
109
-
110
- // Publish event
111
- await this.publishEvent('domain.verification.initiated', {
112
- domain: config.domain,
113
- provider: config.provider,
114
- recordCount: dnsRecords.length
115
- });
116
-
117
- return {
118
- success: true,
119
- domain: config.domain,
120
- status: 'pending',
121
- dnsRecords,
122
- dkimConfig,
123
- providerData,
124
- estimatedPropagationTime: 3600, // 1 hour typical
125
- nextSteps: [
126
- 'Add the DNS records to your domain registrar',
127
- 'Wait for DNS propagation (typically 1-24 hours)',
128
- 'Use checkPropagation() to monitor DNS propagation status',
129
- 'Use verifyDomain() to complete verification with provider'
130
- ]
131
- };
132
- } catch (error) {
133
- const errorMessage = error instanceof Error ? error.message : String(error);
134
- this.logger.error(`Domain setup failed for ${config.domain}: ${errorMessage}`);
135
-
136
- return {
137
- success: false,
138
- domain: config.domain,
139
- status: 'failed',
140
- dnsRecords: [],
141
- errors: [error instanceof Error ? error.message : 'Unknown error']
142
- };
143
- }
144
- }
145
-
146
- /**
147
- * Check DNS propagation status for domain records
148
- *
149
- * @param domain - Domain name
150
- * @param records - DNS records to check
151
- * @returns Propagation results for each record
152
- */
153
- async checkPropagation(
154
- domain: string,
155
- records: DNSRecord[]
156
- ): Promise<DNSPropagationResult[]> {
157
- this.logger.info('Checking DNS propagation', {
158
- domain,
159
- recordCount: records.length
160
- });
161
-
162
- const results: DNSPropagationResult[] = [];
163
-
164
- for (const record of records) {
165
- try {
166
- const result = await this.checkRecordPropagation(domain, record);
167
- results.push(result);
168
-
169
- if (result.propagated && result.valuesMatch) {
170
- this.logger.info('DNS record propagated successfully', {
171
- domain,
172
- type: record.type,
173
- host: record.host
174
- });
175
- } else {
176
- this.logger.warn('DNS record not yet propagated', {
177
- domain,
178
- type: record.type,
179
- host: record.host,
180
- propagationPercentage: result.propagationPercentage
181
- });
182
- }
183
- } catch (error) {
184
- const errorMessage = error instanceof Error ? error.message : String(error);
185
- this.logger.error(`Propagation check failed for ${domain} ${record.type}: ${errorMessage}`);
186
-
187
- results.push({
188
- domain,
189
- record,
190
- propagated: false,
191
- nameservers: [],
192
- propagationPercentage: 0,
193
- expectedValue: record.value,
194
- valuesMatch: false,
195
- errors: [error instanceof Error ? error.message : 'Unknown error']
196
- });
197
- }
198
- }
199
-
200
- // Publish propagation status event
201
- const overallPropagation = results.reduce((acc, r) => acc + r.propagationPercentage, 0) / results.length;
202
- await this.publishEvent('domain.propagation.checked', {
203
- domain,
204
- propagationPercentage: overallPropagation,
205
- recordCount: records.length,
206
- propagatedCount: results.filter(r => r.propagated).length
207
- });
208
-
209
- return results;
210
- }
211
-
212
- /**
213
- * Verify domain with email provider
214
- *
215
- * @param domain - Domain name
216
- * @param provider - Email provider
217
- * @param providerCredentials - Provider API credentials
218
- * @returns Verification result
219
- */
220
- async verifyDomain(
221
- domain: string,
222
- provider: EmailProvider,
223
- providerCredentials: Record<string, unknown>
224
- ): Promise<DomainVerificationResult> {
225
- this.logger.info('Verifying domain with provider', { domain, provider });
226
-
227
- try {
228
- const providerData = await this.verifyWithProvider(domain, provider, providerCredentials);
229
-
230
- const status: VerificationStatus = providerData.verified ? 'verified' : 'failed';
231
- const detailsMessage = typeof providerData.message === 'string' ? providerData.message : 'Verification completed';
232
-
233
- this.addHistoryEntry(domain, {
234
- eventType: status === 'verified' ? 'verified' : 'failed',
235
- details: detailsMessage,
236
- newStatus: status,
237
- previousStatus: 'propagating'
238
- });
239
-
240
- await this.publishEvent('domain.verification.completed', {
241
- domain,
242
- provider,
243
- status,
244
- success: status === 'verified'
245
- });
246
-
247
- // Start health monitoring if verified
248
- if (status === 'verified') {
249
- this.startHealthMonitoring(domain, provider, providerCredentials);
250
- }
251
-
252
- const errorMessage = typeof providerData.message === 'string' ? providerData.message : 'Verification failed';
253
- return {
254
- success: status === 'verified',
255
- domain,
256
- status,
257
- dnsRecords: [],
258
- providerData,
259
- errors: status === 'failed' ? [errorMessage] : undefined
260
- };
261
- } catch (error) {
262
- const errMsg = error instanceof Error ? error.message : String(error);
263
- this.logger.error(`Domain verification failed for ${domain} with ${provider}: ${errMsg}`);
264
-
265
- return {
266
- success: false,
267
- domain,
268
- status: 'failed',
269
- dnsRecords: [],
270
- errors: [error instanceof Error ? error.message : 'Unknown error']
271
- };
272
- }
273
- }
274
-
275
- /**
276
- * Get domain health status
277
- *
278
- * @param domain - Domain name
279
- * @returns Health status
280
- */
281
- async getDomainHealth(domain: string): Promise<DomainHealthStatus> {
282
- this.logger.info('Checking domain health', { domain });
283
-
284
- // Implementation would check DNS records, provider status, etc.
285
- // Returning basic structure for type safety
286
- return {
287
- domain,
288
- healthy: true,
289
- verificationStatus: 'verified',
290
- lastChecked: new Date(),
291
- records: []
292
- };
293
- }
294
-
295
- /**
296
- * Get verification history for domain
297
- *
298
- * @param domain - Domain name
299
- * @param limit - Maximum number of entries to return
300
- * @returns History entries
301
- */
302
- getVerificationHistory(domain: string, limit = 50): VerificationHistoryEntry[] {
303
- const history = this.verificationHistory.get(domain) || [];
304
- return history.slice(-limit);
305
- }
306
-
307
- /**
308
- * Start automated health monitoring for domain
309
- *
310
- * @param domain - Domain name
311
- * @param provider - Email provider
312
- * @param credentials - Provider credentials
313
- */
314
- private startHealthMonitoring(
315
- domain: string,
316
- provider: EmailProvider,
317
- credentials: Record<string, unknown>
318
- ): void {
319
- // Stop existing job if any
320
- this.stopHealthMonitoring(domain);
321
-
322
- // Create new cron job (runs every 6 hours)
323
- const job = new CronJob('0 */6 * * *', async () => {
324
- try {
325
- const health = await this.performHealthCheck(domain, provider, credentials);
326
-
327
- if (!health.healthy) {
328
- this.logger.warn('Domain health check failed', { domain, warnings: health.warnings });
329
-
330
- await this.publishEvent('domain.health.unhealthy', {
331
- domain,
332
- provider,
333
- warnings: health.warnings
334
- });
335
- }
336
- } catch (error) {
337
- const errMsg = error instanceof Error ? error.message : String(error);
338
- this.logger.error(`Health check failed for ${domain}: ${errMsg}`);
339
- }
340
- });
341
-
342
- job.start();
343
- this.healthCheckJobs.set(domain, job);
344
-
345
- this.logger.info('Health monitoring started', { domain });
346
- }
347
-
348
- /**
349
- * Stop health monitoring for domain
350
- *
351
- * @param domain - Domain name
352
- */
353
- stopHealthMonitoring(domain: string): void {
354
- const job = this.healthCheckJobs.get(domain);
355
- if (job) {
356
- job.stop();
357
- this.healthCheckJobs.delete(domain);
358
- this.logger.info('Health monitoring stopped', { domain });
359
- }
360
- }
361
-
362
- /**
363
- * Perform health check for domain
364
- *
365
- * @param domain - Domain name
366
- * @param provider - Email provider
367
- * @param credentials - Provider credentials
368
- * @returns Health status
369
- */
370
- private async performHealthCheck(
371
- domain: string,
372
- _provider: EmailProvider,
373
- _credentials: Record<string, unknown>
374
- ): Promise<DomainHealthStatus> {
375
- // Implementation would check DNS records, provider API status, etc.
376
- // Returning basic structure for type safety
377
- return {
378
- domain,
379
- healthy: true,
380
- verificationStatus: 'verified',
381
- lastChecked: new Date(),
382
- records: []
383
- };
384
- }
385
-
386
- /**
387
- * Generate DNS records for domain configuration
388
- */
389
- private async generateDNSRecords(config: DomainConfig): Promise<DNSRecord[]> {
390
- const records: DNSRecord[] = [];
391
-
392
- // Generate SPF record
393
- if (config.spf.enabled) {
394
- records.push(this.generateSPFRecord(config));
395
- }
396
-
397
- // Generate DMARC record
398
- if (config.dmarc.enabled) {
399
- records.push(this.generateDMARCRecord(config));
400
- }
401
-
402
- // Generate provider-specific records
403
- const providerRecords = await this.generateProviderRecords(config);
404
- records.push(...providerRecords);
405
-
406
- return records;
407
- }
408
-
409
- /**
410
- * Generate SPF DNS record
411
- */
412
- private generateSPFRecord(config: DomainConfig): DNSRecord {
413
- const includes = config.spf.includes || [];
414
- const ipv4s = config.spf.ipv4Addresses || [];
415
- const ipv6s = config.spf.ipv6Addresses || [];
416
- const strict = config.spf.strict !== false;
417
-
418
- let spfValue = 'v=spf1';
419
-
420
- // Add provider-specific includes
421
- if (config.provider === 'sendgrid') {
422
- includes.push('include:sendgrid.net');
423
- } else if (config.provider === 'mailgun') {
424
- includes.push('include:mailgun.org');
425
- } else if (config.provider === 'ses') {
426
- includes.push('include:amazonses.com');
427
- } else if (config.provider === 'mandrill') {
428
- includes.push('include:spf.mandrillapp.com');
429
- } else if (config.provider === 'smtp2go') {
430
- includes.push('include:spf.smtp2go.com');
431
- } else if (config.provider === 'postmark') {
432
- includes.push('include:spf.mtasv.net');
433
- }
434
-
435
- includes.forEach(inc => {
436
- spfValue += ` ${inc}`;
437
- });
438
-
439
- ipv4s.forEach(ip => {
440
- spfValue += ` ip4:${ip}`;
441
- });
442
-
443
- ipv6s.forEach(ip => {
444
- spfValue += ` ip6:${ip}`;
445
- });
446
-
447
- spfValue += strict ? ' -all' : ' ~all';
448
-
449
- return {
450
- type: 'SPF',
451
- host: config.domain,
452
- value: spfValue,
453
- ttl: 3600,
454
- provider: config.provider,
455
- required: true,
456
- instructions: `Add this TXT record to your domain:\n\nHost: ${config.domain}\nValue: ${spfValue}\nTTL: 3600`
457
- };
458
- }
459
-
460
- /**
461
- * Generate DMARC DNS record
462
- */
463
- private generateDMARCRecord(config: DomainConfig): DNSRecord {
464
- const policy = config.dmarc.policy || 'none';
465
- const percentage = config.dmarc.percentage || 100;
466
- const reportEmail = config.dmarc.reportEmail;
467
- const aggregateEmail = config.dmarc.aggregateEmail;
468
- const subdomain = config.dmarc.subdomain || 'none';
469
-
470
- let dmarcValue = `v=DMARC1; p=${policy}; pct=${percentage}; sp=${subdomain};`;
471
-
472
- if (reportEmail) {
473
- dmarcValue += ` rua=mailto:${reportEmail};`;
474
- }
475
-
476
- if (aggregateEmail) {
477
- dmarcValue += ` ruf=mailto:${aggregateEmail};`;
478
- }
479
-
480
- return {
481
- type: 'DMARC',
482
- host: `_dmarc.${config.domain}`,
483
- value: dmarcValue,
484
- ttl: 3600,
485
- provider: config.provider,
486
- required: true,
487
- instructions: `Add this TXT record to your domain:\n\nHost: _dmarc.${config.domain}\nValue: ${dmarcValue}\nTTL: 3600`
488
- };
489
- }
490
-
491
- /**
492
- * Generate provider-specific DNS records
493
- */
494
- private async generateProviderRecords(config: DomainConfig): Promise<DNSRecord[]> {
495
- switch (config.provider) {
496
- case 'sendgrid':
497
- return this.generateSendGridRecords(config);
498
- case 'mailgun':
499
- return this.generateMailgunRecords(config);
500
- case 'ses':
501
- return this.generateSESRecords(config);
502
- default:
503
- return [];
504
- }
505
- }
506
-
507
- /**
508
- * Generate SendGrid-specific DNS records
509
- */
510
- private generateSendGridRecords(config: DomainConfig): DNSRecord[] {
511
- const records: DNSRecord[] = [];
512
-
513
- // SendGrid domain authentication requires CNAME records
514
- // These would normally come from SendGrid API
515
- records.push({
516
- type: 'CNAME',
517
- host: `em123.${config.domain}`,
518
- value: 'u123.wl.sendgrid.net',
519
- ttl: 3600,
520
- provider: 'sendgrid',
521
- required: true,
522
- instructions: 'Add this CNAME record (actual values will come from SendGrid API)'
523
- });
524
-
525
- return records;
526
- }
527
-
528
- /**
529
- * Generate Mailgun-specific DNS records
530
- */
531
- private generateMailgunRecords(config: DomainConfig): DNSRecord[] {
532
- const records: DNSRecord[] = [];
533
-
534
- // Mailgun requires specific TXT records
535
- records.push({
536
- type: 'TXT',
537
- host: config.domain,
538
- value: 'v=spf1 include:mailgun.org ~all',
539
- ttl: 3600,
540
- provider: 'mailgun',
541
- required: true,
542
- instructions: 'Add this TXT record for Mailgun SPF'
543
- });
544
-
545
- return records;
546
- }
547
-
548
- /**
549
- * Generate AWS SES-specific DNS records
550
- */
551
- private generateSESRecords(config: DomainConfig): DNSRecord[] {
552
- const records: DNSRecord[] = [];
553
-
554
- // SES requires TXT record for verification
555
- records.push({
556
- type: 'TXT',
557
- host: `_amazonses.${config.domain}`,
558
- value: 'verification-token-from-ses',
559
- ttl: 3600,
560
- provider: 'ses',
561
- required: true,
562
- instructions: 'Add this TXT record (actual token will come from AWS SES)'
563
- });
564
-
565
- return records;
566
- }
567
-
568
- /**
569
- * Generate DKIM configuration with keypair
570
- */
571
- private async generateDKIMConfig(config: DomainConfig): Promise<DKIMConfig> {
572
- const selector = config.dkim.selector || 'default';
573
- const keySize = config.dkim.keySize || 2048;
574
-
575
- // Generate DKIM keypair using Node.js crypto
576
- const { publicKey, privateKey } = generateKeyPairSync('rsa', {
577
- modulusLength: keySize,
578
- publicKeyEncoding: {
579
- type: 'spki',
580
- format: 'pem'
581
- },
582
- privateKeyEncoding: {
583
- type: 'pkcs8',
584
- format: 'pem'
585
- }
586
- });
587
-
588
- // Format public key for DNS (remove headers/newlines, base64)
589
- const publicKeyForDNS = publicKey
590
- .replace(/-----BEGIN PUBLIC KEY-----/, '')
591
- .replace(/-----END PUBLIC KEY-----/, '')
592
- .replace(/\n/g, '')
593
- .replace(/\r/g, '');
594
-
595
- const dkimValue = `v=DKIM1; k=rsa; p=${publicKeyForDNS}`;
596
-
597
- const dnsRecord: DNSRecord = {
598
- type: 'DKIM',
599
- host: `${selector}._domainkey.${config.domain}`,
600
- value: dkimValue,
601
- ttl: 3600,
602
- provider: config.provider,
603
- required: true,
604
- instructions: `Add this TXT record to your domain:\n\nHost: ${selector}._domainkey.${config.domain}\nValue: ${dkimValue}\nTTL: 3600`
605
- };
606
-
607
- return {
608
- selector,
609
- privateKey,
610
- publicKey,
611
- keySize,
612
- dnsRecord
613
- };
614
- }
615
-
616
- /**
617
- * Register domain with email provider
618
- */
619
- private async registerWithProvider(
620
- config: DomainConfig,
621
- _dkimConfig?: DKIMConfig
622
- ): Promise<Record<string, unknown>> {
623
- switch (config.provider) {
624
- case 'sendgrid':
625
- return this.registerWithSendGrid(config);
626
- case 'mailgun':
627
- return this.registerWithMailgun(config);
628
- case 'ses':
629
- return this.registerWithSES(config);
630
- default:
631
- return { registered: false, message: 'Provider not supported' };
632
- }
633
- }
634
-
635
- /**
636
- * Register domain with SendGrid
637
- */
638
- private async registerWithSendGrid(
639
- config: DomainConfig
640
- ): Promise<Record<string, unknown>> {
641
- try {
642
- const response = await this.httpClient.post(
643
- 'https://api.sendgrid.com/v3/whitelabel/domains',
644
- {
645
- domain: config.domain,
646
- subdomain: 'em',
647
- username: 'sendgrid',
648
- ips: [],
649
- custom_spf: false,
650
- default: true,
651
- automatic_security: true
652
- },
653
- {
654
- headers: {
655
- Authorization: `Bearer ${config.providerCredentials.apiKey as string}`
656
- }
657
- }
658
- );
659
-
660
- return {
661
- registered: true,
662
- domainId: response.data.id,
663
- dnsRecords: response.data.dns,
664
- message: 'Domain registered with SendGrid'
665
- };
666
- } catch (error) {
667
- const errMsg = error instanceof Error ? error.message : String(error);
668
- this.logger.error(`SendGrid registration failed for ${config.domain}: ${errMsg}`);
669
- throw error;
670
- }
671
- }
672
-
673
- /**
674
- * Register domain with Mailgun
675
- */
676
- private async registerWithMailgun(
677
- config: DomainConfig
678
- ): Promise<Record<string, unknown>> {
679
- try {
680
- const response = await this.httpClient.post(
681
- 'https://api.mailgun.net/v3/domains',
682
- new URLSearchParams({
683
- name: config.domain,
684
- smtp_password: (config.providerCredentials.smtpPassword as string) || '',
685
- spam_action: 'disabled',
686
- wildcard: 'false'
687
- }),
688
- {
689
- auth: {
690
- username: 'api',
691
- password: config.providerCredentials.apiKey as string
692
- }
693
- }
694
- );
695
-
696
- return {
697
- registered: true,
698
- domain: response.data.domain,
699
- dnsRecords: response.data.receiving_dns_records.concat(response.data.sending_dns_records),
700
- message: 'Domain registered with Mailgun'
701
- };
702
- } catch (error) {
703
- const errMsg = error instanceof Error ? error.message : String(error);
704
- this.logger.error(`Mailgun registration failed for ${config.domain}: ${errMsg}`);
705
- throw error;
706
- }
707
- }
708
-
709
- /**
710
- * Register domain with AWS SES
711
- */
712
- private async registerWithSES(
713
- config: DomainConfig
714
- ): Promise<Record<string, unknown>> {
715
- // AWS SES registration would use AWS SDK
716
- // This is a placeholder implementation
717
- this.logger.info('AWS SES registration not yet implemented', {
718
- domain: config.domain
719
- });
720
-
721
- return {
722
- registered: false,
723
- message: 'AWS SES registration requires AWS SDK integration'
724
- };
725
- }
726
-
727
- /**
728
- * Verify domain with email provider
729
- */
730
- private async verifyWithProvider(
731
- domain: string,
732
- provider: EmailProvider,
733
- credentials: Record<string, unknown>
734
- ): Promise<Record<string, unknown>> {
735
- switch (provider) {
736
- case 'sendgrid':
737
- return this.verifyWithSendGrid(domain, credentials);
738
- case 'mailgun':
739
- return this.verifyWithMailgun(domain, credentials);
740
- case 'ses':
741
- return this.verifyWithSES(domain, credentials);
742
- default:
743
- return { verified: false, message: 'Provider not supported' };
744
- }
745
- }
746
-
747
- /**
748
- * Verify domain with SendGrid
749
- */
750
- private async verifyWithSendGrid(
751
- domain: string,
752
- credentials: Record<string, unknown>
753
- ): Promise<Record<string, unknown>> {
754
- try {
755
- // Get domain ID from SendGrid
756
- const domainsResponse = await this.httpClient.get(
757
- 'https://api.sendgrid.com/v3/whitelabel/domains',
758
- {
759
- headers: {
760
- Authorization: `Bearer ${credentials.apiKey as string}`
761
- }
762
- }
763
- );
764
-
765
- const domainData = domainsResponse.data.find((d: { domain: string }) => d.domain === domain);
766
- if (!domainData) {
767
- return { verified: false, message: 'Domain not found in SendGrid' };
768
- }
769
-
770
- // Trigger verification
771
- const verifyResponse = await this.httpClient.post(
772
- `https://api.sendgrid.com/v3/whitelabel/domains/${domainData.id}/validate`,
773
- {},
774
- {
775
- headers: {
776
- Authorization: `Bearer ${credentials.apiKey as string}`
777
- }
778
- }
779
- );
780
-
781
- return {
782
- verified: verifyResponse.data.valid,
783
- validationResults: verifyResponse.data.validation_results,
784
- message: verifyResponse.data.valid ? 'Domain verified' : 'Verification failed'
785
- };
786
- } catch (error) {
787
- const errMsg = error instanceof Error ? error.message : String(error);
788
- this.logger.error(`SendGrid verification failed for ${domain}: ${errMsg}`);
789
- throw error;
790
- }
791
- }
792
-
793
- /**
794
- * Verify domain with Mailgun
795
- */
796
- private async verifyWithMailgun(
797
- domain: string,
798
- credentials: Record<string, unknown>
799
- ): Promise<Record<string, unknown>> {
800
- try {
801
- const response = await this.httpClient.get(
802
- `https://api.mailgun.net/v3/domains/${domain}`,
803
- {
804
- auth: {
805
- username: 'api',
806
- password: credentials.apiKey as string
807
- }
808
- }
809
- );
810
-
811
- return {
812
- verified: response.data.domain.state === 'active',
813
- state: response.data.domain.state,
814
- message: response.data.domain.state === 'active' ? 'Domain verified' : 'Verification pending'
815
- };
816
- } catch (error) {
817
- const errMsg = error instanceof Error ? error.message : String(error);
818
- this.logger.error(`Mailgun verification failed for ${domain}: ${errMsg}`);
819
- throw error;
820
- }
821
- }
822
-
823
- /**
824
- * Verify domain with AWS SES
825
- */
826
- private async verifyWithSES(
827
- domain: string,
828
- _credentials: Record<string, unknown>
829
- ): Promise<Record<string, unknown>> {
830
- // AWS SES verification would use AWS SDK
831
- this.logger.info('AWS SES verification not yet implemented', { domain });
832
-
833
- return {
834
- verified: false,
835
- message: 'AWS SES verification requires AWS SDK integration'
836
- };
837
- }
838
-
839
- /**
840
- * Check propagation for single DNS record
841
- */
842
- private async checkRecordPropagation(
843
- domain: string,
844
- record: DNSRecord
845
- ): Promise<DNSPropagationResult> {
846
- const nameservers = await this.getNameservers(domain);
847
- const results: { ns: string; value?: string; error?: string }[] = [];
848
-
849
- for (const ns of nameservers) {
850
- try {
851
- const resolver = new Resolver({ nameServers: [ns] });
852
- const response = await resolver.resolve(record.host, 'TXT');
853
- const value = response.answers
854
- .filter((a: { type: string }) => a.type === 'TXT')
855
- .map((a: { data: string }) => a.data)
856
- .join('');
857
-
858
- results.push({ ns, value });
859
- } catch (error) {
860
- results.push({
861
- ns,
862
- error: error instanceof Error ? error.message : 'Unknown error'
863
- });
864
- }
865
- }
866
-
867
- const propagatedCount = results.filter(
868
- r => r.value && r.value.includes(record.value.substring(0, 20))
869
- ).length;
870
-
871
- const propagationPercentage = (propagatedCount / nameservers.length) * 100;
872
- const propagated = propagationPercentage >= 80; // 80% threshold
873
- const actualValue = results.find(r => r.value)?.value;
874
- const valuesMatch = actualValue?.includes(record.value.substring(0, 20)) || false;
875
-
876
- return {
877
- domain,
878
- record,
879
- propagated,
880
- nameservers,
881
- propagationPercentage,
882
- actualValue,
883
- expectedValue: record.value,
884
- valuesMatch,
885
- errors: results.filter(r => r.error).map(r => r.error!)
886
- };
887
- }
888
-
889
- /**
890
- * Get nameservers for domain
891
- */
892
- private async getNameservers(domain: string): Promise<string[]> {
893
- try {
894
- const resolver = new Resolver();
895
- const response = await resolver.resolve(domain, 'NS');
896
- return response.answers
897
- .filter((a) => a.ns !== undefined)
898
- .map((a) => a.ns as string);
899
- } catch (error) {
900
- const errMsg = error instanceof Error ? error.message : String(error);
901
- this.logger.warn(`Failed to get nameservers for ${domain}, using defaults: ${errMsg}`);
902
-
903
- // Return common public DNS servers as fallback
904
- return [
905
- '8.8.8.8', // Google
906
- '1.1.1.1', // Cloudflare
907
- '208.67.222.222' // OpenDNS
908
- ];
909
- }
910
- }
911
-
912
- /**
913
- * Validate domain name format
914
- */
915
- private validateDomain(domain: string): void {
916
- const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i;
917
-
918
- if (!domainRegex.test(domain)) {
919
- throw new InvalidDomainError(`Invalid domain name: ${domain}`);
920
- }
921
- }
922
-
923
- /**
924
- * Add entry to verification history
925
- */
926
- private async addHistoryEntry(
927
- domain: string,
928
- entry: Omit<VerificationHistoryEntry, 'id' | 'domain' | 'timestamp'>
929
- ): Promise<void> {
930
- const history = this.verificationHistory.get(domain) || [];
931
-
932
- const result = await generateUUIDKey();
933
- history.push({
934
- id: result.key,
935
- domain,
936
- timestamp: new Date(),
937
- ...entry
938
- });
939
-
940
- this.verificationHistory.set(domain, history);
941
- }
942
-
943
- /**
944
- * Register with NeverHub
945
- */
946
- private async registerWithNeverHub(): Promise<void> {
947
- if (!this.neverhub) return;
948
-
949
- await this.neverhub.register({
950
- type: 'email-domain-verification',
951
- name: '@bernierllc/email-domain-verification',
952
- version: '1.0.0',
953
- capabilities: [
954
- { type: 'email', name: 'domain-verification', version: '1.0.0' },
955
- { type: 'dns', name: 'record-management', version: '1.0.0' },
956
- { type: 'monitoring', name: 'health-checks', version: '1.0.0' }
957
- ],
958
- dependencies: ['logging', 'crypto']
959
- });
960
-
961
- this.logger.info('Registered with NeverHub');
962
- }
963
-
964
- /**
965
- * Publish event to NeverHub
966
- */
967
- private async publishEvent(eventType: string, data: Record<string, unknown>): Promise<void> {
968
- if (!this.neverhub) return;
969
-
970
- try {
971
- await this.neverhub.publishEvent({
972
- type: eventType,
973
- data,
974
- source: '@bernierllc/email-domain-verification',
975
- timestamp: new Date().toISOString()
976
- });
977
- } catch (error) {
978
- this.logger.warn('Failed to publish event to NeverHub', {
979
- eventType,
980
- error: error instanceof Error ? error.message : String(error)
981
- });
982
- }
983
- }
984
-
985
- /**
986
- * Cleanup resources
987
- */
988
- async shutdown(): Promise<void> {
989
- // Stop all health monitoring jobs
990
- for (const [domain, job] of this.healthCheckJobs.entries()) {
991
- job.stop();
992
- this.logger.info('Stopped health monitoring', { domain });
993
- }
994
-
995
- this.healthCheckJobs.clear();
996
- this.logger.info('Email Domain Verification Service shut down');
997
- }
998
- }