@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.
- package/.eslintrc.cjs +29 -0
- package/README.md +397 -0
- package/__mocks__/@bernierllc/crypto-utils.ts +15 -0
- package/__mocks__/@bernierllc/logger.ts +42 -0
- package/__mocks__/@bernierllc/neverhub-adapter.ts +17 -0
- package/__tests__/EmailDomainVerificationService.test.ts +582 -0
- package/coverage/clover.xml +263 -0
- package/coverage/coverage-final.json +3 -0
- package/coverage/lcov-report/EmailDomainVerificationService.ts.html +3061 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/errors.ts.html +322 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +131 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +485 -0
- package/dist/EmailDomainVerificationService.d.ts +179 -0
- package/dist/EmailDomainVerificationService.js +822 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +66 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +28 -0
- package/dist/types.d.ts +209 -0
- package/dist/types.js +9 -0
- package/jest.config.cjs +44 -0
- package/jest.setup.js +19 -0
- package/package.json +72 -0
- package/src/EmailDomainVerificationService.ts +992 -0
- package/src/dns2.d.ts +29 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +11 -0
- package/src/types.ts +281 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,992 @@
|
|
|
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
|
+
}
|
|
428
|
+
|
|
429
|
+
includes.forEach(inc => {
|
|
430
|
+
spfValue += ` ${inc}`;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
ipv4s.forEach(ip => {
|
|
434
|
+
spfValue += ` ip4:${ip}`;
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
ipv6s.forEach(ip => {
|
|
438
|
+
spfValue += ` ip6:${ip}`;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
spfValue += strict ? ' -all' : ' ~all';
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
type: 'SPF',
|
|
445
|
+
host: config.domain,
|
|
446
|
+
value: spfValue,
|
|
447
|
+
ttl: 3600,
|
|
448
|
+
provider: config.provider,
|
|
449
|
+
required: true,
|
|
450
|
+
instructions: `Add this TXT record to your domain:\n\nHost: ${config.domain}\nValue: ${spfValue}\nTTL: 3600`
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Generate DMARC DNS record
|
|
456
|
+
*/
|
|
457
|
+
private generateDMARCRecord(config: DomainConfig): DNSRecord {
|
|
458
|
+
const policy = config.dmarc.policy || 'none';
|
|
459
|
+
const percentage = config.dmarc.percentage || 100;
|
|
460
|
+
const reportEmail = config.dmarc.reportEmail;
|
|
461
|
+
const aggregateEmail = config.dmarc.aggregateEmail;
|
|
462
|
+
const subdomain = config.dmarc.subdomain || 'none';
|
|
463
|
+
|
|
464
|
+
let dmarcValue = `v=DMARC1; p=${policy}; pct=${percentage}; sp=${subdomain};`;
|
|
465
|
+
|
|
466
|
+
if (reportEmail) {
|
|
467
|
+
dmarcValue += ` rua=mailto:${reportEmail};`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (aggregateEmail) {
|
|
471
|
+
dmarcValue += ` ruf=mailto:${aggregateEmail};`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
type: 'DMARC',
|
|
476
|
+
host: `_dmarc.${config.domain}`,
|
|
477
|
+
value: dmarcValue,
|
|
478
|
+
ttl: 3600,
|
|
479
|
+
provider: config.provider,
|
|
480
|
+
required: true,
|
|
481
|
+
instructions: `Add this TXT record to your domain:\n\nHost: _dmarc.${config.domain}\nValue: ${dmarcValue}\nTTL: 3600`
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Generate provider-specific DNS records
|
|
487
|
+
*/
|
|
488
|
+
private async generateProviderRecords(config: DomainConfig): Promise<DNSRecord[]> {
|
|
489
|
+
switch (config.provider) {
|
|
490
|
+
case 'sendgrid':
|
|
491
|
+
return this.generateSendGridRecords(config);
|
|
492
|
+
case 'mailgun':
|
|
493
|
+
return this.generateMailgunRecords(config);
|
|
494
|
+
case 'ses':
|
|
495
|
+
return this.generateSESRecords(config);
|
|
496
|
+
default:
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Generate SendGrid-specific DNS records
|
|
503
|
+
*/
|
|
504
|
+
private generateSendGridRecords(config: DomainConfig): DNSRecord[] {
|
|
505
|
+
const records: DNSRecord[] = [];
|
|
506
|
+
|
|
507
|
+
// SendGrid domain authentication requires CNAME records
|
|
508
|
+
// These would normally come from SendGrid API
|
|
509
|
+
records.push({
|
|
510
|
+
type: 'CNAME',
|
|
511
|
+
host: `em123.${config.domain}`,
|
|
512
|
+
value: 'u123.wl.sendgrid.net',
|
|
513
|
+
ttl: 3600,
|
|
514
|
+
provider: 'sendgrid',
|
|
515
|
+
required: true,
|
|
516
|
+
instructions: 'Add this CNAME record (actual values will come from SendGrid API)'
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
return records;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Generate Mailgun-specific DNS records
|
|
524
|
+
*/
|
|
525
|
+
private generateMailgunRecords(config: DomainConfig): DNSRecord[] {
|
|
526
|
+
const records: DNSRecord[] = [];
|
|
527
|
+
|
|
528
|
+
// Mailgun requires specific TXT records
|
|
529
|
+
records.push({
|
|
530
|
+
type: 'TXT',
|
|
531
|
+
host: config.domain,
|
|
532
|
+
value: 'v=spf1 include:mailgun.org ~all',
|
|
533
|
+
ttl: 3600,
|
|
534
|
+
provider: 'mailgun',
|
|
535
|
+
required: true,
|
|
536
|
+
instructions: 'Add this TXT record for Mailgun SPF'
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
return records;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Generate AWS SES-specific DNS records
|
|
544
|
+
*/
|
|
545
|
+
private generateSESRecords(config: DomainConfig): DNSRecord[] {
|
|
546
|
+
const records: DNSRecord[] = [];
|
|
547
|
+
|
|
548
|
+
// SES requires TXT record for verification
|
|
549
|
+
records.push({
|
|
550
|
+
type: 'TXT',
|
|
551
|
+
host: `_amazonses.${config.domain}`,
|
|
552
|
+
value: 'verification-token-from-ses',
|
|
553
|
+
ttl: 3600,
|
|
554
|
+
provider: 'ses',
|
|
555
|
+
required: true,
|
|
556
|
+
instructions: 'Add this TXT record (actual token will come from AWS SES)'
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return records;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Generate DKIM configuration with keypair
|
|
564
|
+
*/
|
|
565
|
+
private async generateDKIMConfig(config: DomainConfig): Promise<DKIMConfig> {
|
|
566
|
+
const selector = config.dkim.selector || 'default';
|
|
567
|
+
const keySize = config.dkim.keySize || 2048;
|
|
568
|
+
|
|
569
|
+
// Generate DKIM keypair using Node.js crypto
|
|
570
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
571
|
+
modulusLength: keySize,
|
|
572
|
+
publicKeyEncoding: {
|
|
573
|
+
type: 'spki',
|
|
574
|
+
format: 'pem'
|
|
575
|
+
},
|
|
576
|
+
privateKeyEncoding: {
|
|
577
|
+
type: 'pkcs8',
|
|
578
|
+
format: 'pem'
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Format public key for DNS (remove headers/newlines, base64)
|
|
583
|
+
const publicKeyForDNS = publicKey
|
|
584
|
+
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
|
585
|
+
.replace(/-----END PUBLIC KEY-----/, '')
|
|
586
|
+
.replace(/\n/g, '')
|
|
587
|
+
.replace(/\r/g, '');
|
|
588
|
+
|
|
589
|
+
const dkimValue = `v=DKIM1; k=rsa; p=${publicKeyForDNS}`;
|
|
590
|
+
|
|
591
|
+
const dnsRecord: DNSRecord = {
|
|
592
|
+
type: 'DKIM',
|
|
593
|
+
host: `${selector}._domainkey.${config.domain}`,
|
|
594
|
+
value: dkimValue,
|
|
595
|
+
ttl: 3600,
|
|
596
|
+
provider: config.provider,
|
|
597
|
+
required: true,
|
|
598
|
+
instructions: `Add this TXT record to your domain:\n\nHost: ${selector}._domainkey.${config.domain}\nValue: ${dkimValue}\nTTL: 3600`
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
selector,
|
|
603
|
+
privateKey,
|
|
604
|
+
publicKey,
|
|
605
|
+
keySize,
|
|
606
|
+
dnsRecord
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Register domain with email provider
|
|
612
|
+
*/
|
|
613
|
+
private async registerWithProvider(
|
|
614
|
+
config: DomainConfig,
|
|
615
|
+
_dkimConfig?: DKIMConfig
|
|
616
|
+
): Promise<Record<string, unknown>> {
|
|
617
|
+
switch (config.provider) {
|
|
618
|
+
case 'sendgrid':
|
|
619
|
+
return this.registerWithSendGrid(config);
|
|
620
|
+
case 'mailgun':
|
|
621
|
+
return this.registerWithMailgun(config);
|
|
622
|
+
case 'ses':
|
|
623
|
+
return this.registerWithSES(config);
|
|
624
|
+
default:
|
|
625
|
+
return { registered: false, message: 'Provider not supported' };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Register domain with SendGrid
|
|
631
|
+
*/
|
|
632
|
+
private async registerWithSendGrid(
|
|
633
|
+
config: DomainConfig
|
|
634
|
+
): Promise<Record<string, unknown>> {
|
|
635
|
+
try {
|
|
636
|
+
const response = await this.httpClient.post(
|
|
637
|
+
'https://api.sendgrid.com/v3/whitelabel/domains',
|
|
638
|
+
{
|
|
639
|
+
domain: config.domain,
|
|
640
|
+
subdomain: 'em',
|
|
641
|
+
username: 'sendgrid',
|
|
642
|
+
ips: [],
|
|
643
|
+
custom_spf: false,
|
|
644
|
+
default: true,
|
|
645
|
+
automatic_security: true
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
headers: {
|
|
649
|
+
Authorization: `Bearer ${config.providerCredentials.apiKey as string}`
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
registered: true,
|
|
656
|
+
domainId: response.data.id,
|
|
657
|
+
dnsRecords: response.data.dns,
|
|
658
|
+
message: 'Domain registered with SendGrid'
|
|
659
|
+
};
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
662
|
+
this.logger.error(`SendGrid registration failed for ${config.domain}: ${errMsg}`);
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Register domain with Mailgun
|
|
669
|
+
*/
|
|
670
|
+
private async registerWithMailgun(
|
|
671
|
+
config: DomainConfig
|
|
672
|
+
): Promise<Record<string, unknown>> {
|
|
673
|
+
try {
|
|
674
|
+
const response = await this.httpClient.post(
|
|
675
|
+
'https://api.mailgun.net/v3/domains',
|
|
676
|
+
new URLSearchParams({
|
|
677
|
+
name: config.domain,
|
|
678
|
+
smtp_password: (config.providerCredentials.smtpPassword as string) || '',
|
|
679
|
+
spam_action: 'disabled',
|
|
680
|
+
wildcard: 'false'
|
|
681
|
+
}),
|
|
682
|
+
{
|
|
683
|
+
auth: {
|
|
684
|
+
username: 'api',
|
|
685
|
+
password: config.providerCredentials.apiKey as string
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
registered: true,
|
|
692
|
+
domain: response.data.domain,
|
|
693
|
+
dnsRecords: response.data.receiving_dns_records.concat(response.data.sending_dns_records),
|
|
694
|
+
message: 'Domain registered with Mailgun'
|
|
695
|
+
};
|
|
696
|
+
} catch (error) {
|
|
697
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
698
|
+
this.logger.error(`Mailgun registration failed for ${config.domain}: ${errMsg}`);
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Register domain with AWS SES
|
|
705
|
+
*/
|
|
706
|
+
private async registerWithSES(
|
|
707
|
+
config: DomainConfig
|
|
708
|
+
): Promise<Record<string, unknown>> {
|
|
709
|
+
// AWS SES registration would use AWS SDK
|
|
710
|
+
// This is a placeholder implementation
|
|
711
|
+
this.logger.info('AWS SES registration not yet implemented', {
|
|
712
|
+
domain: config.domain
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
registered: false,
|
|
717
|
+
message: 'AWS SES registration requires AWS SDK integration'
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Verify domain with email provider
|
|
723
|
+
*/
|
|
724
|
+
private async verifyWithProvider(
|
|
725
|
+
domain: string,
|
|
726
|
+
provider: EmailProvider,
|
|
727
|
+
credentials: Record<string, unknown>
|
|
728
|
+
): Promise<Record<string, unknown>> {
|
|
729
|
+
switch (provider) {
|
|
730
|
+
case 'sendgrid':
|
|
731
|
+
return this.verifyWithSendGrid(domain, credentials);
|
|
732
|
+
case 'mailgun':
|
|
733
|
+
return this.verifyWithMailgun(domain, credentials);
|
|
734
|
+
case 'ses':
|
|
735
|
+
return this.verifyWithSES(domain, credentials);
|
|
736
|
+
default:
|
|
737
|
+
return { verified: false, message: 'Provider not supported' };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Verify domain with SendGrid
|
|
743
|
+
*/
|
|
744
|
+
private async verifyWithSendGrid(
|
|
745
|
+
domain: string,
|
|
746
|
+
credentials: Record<string, unknown>
|
|
747
|
+
): Promise<Record<string, unknown>> {
|
|
748
|
+
try {
|
|
749
|
+
// Get domain ID from SendGrid
|
|
750
|
+
const domainsResponse = await this.httpClient.get(
|
|
751
|
+
'https://api.sendgrid.com/v3/whitelabel/domains',
|
|
752
|
+
{
|
|
753
|
+
headers: {
|
|
754
|
+
Authorization: `Bearer ${credentials.apiKey as string}`
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const domainData = domainsResponse.data.find((d: { domain: string }) => d.domain === domain);
|
|
760
|
+
if (!domainData) {
|
|
761
|
+
return { verified: false, message: 'Domain not found in SendGrid' };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Trigger verification
|
|
765
|
+
const verifyResponse = await this.httpClient.post(
|
|
766
|
+
`https://api.sendgrid.com/v3/whitelabel/domains/${domainData.id}/validate`,
|
|
767
|
+
{},
|
|
768
|
+
{
|
|
769
|
+
headers: {
|
|
770
|
+
Authorization: `Bearer ${credentials.apiKey as string}`
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
verified: verifyResponse.data.valid,
|
|
777
|
+
validationResults: verifyResponse.data.validation_results,
|
|
778
|
+
message: verifyResponse.data.valid ? 'Domain verified' : 'Verification failed'
|
|
779
|
+
};
|
|
780
|
+
} catch (error) {
|
|
781
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
782
|
+
this.logger.error(`SendGrid verification failed for ${domain}: ${errMsg}`);
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Verify domain with Mailgun
|
|
789
|
+
*/
|
|
790
|
+
private async verifyWithMailgun(
|
|
791
|
+
domain: string,
|
|
792
|
+
credentials: Record<string, unknown>
|
|
793
|
+
): Promise<Record<string, unknown>> {
|
|
794
|
+
try {
|
|
795
|
+
const response = await this.httpClient.get(
|
|
796
|
+
`https://api.mailgun.net/v3/domains/${domain}`,
|
|
797
|
+
{
|
|
798
|
+
auth: {
|
|
799
|
+
username: 'api',
|
|
800
|
+
password: credentials.apiKey as string
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
verified: response.data.domain.state === 'active',
|
|
807
|
+
state: response.data.domain.state,
|
|
808
|
+
message: response.data.domain.state === 'active' ? 'Domain verified' : 'Verification pending'
|
|
809
|
+
};
|
|
810
|
+
} catch (error) {
|
|
811
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
812
|
+
this.logger.error(`Mailgun verification failed for ${domain}: ${errMsg}`);
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Verify domain with AWS SES
|
|
819
|
+
*/
|
|
820
|
+
private async verifyWithSES(
|
|
821
|
+
domain: string,
|
|
822
|
+
_credentials: Record<string, unknown>
|
|
823
|
+
): Promise<Record<string, unknown>> {
|
|
824
|
+
// AWS SES verification would use AWS SDK
|
|
825
|
+
this.logger.info('AWS SES verification not yet implemented', { domain });
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
verified: false,
|
|
829
|
+
message: 'AWS SES verification requires AWS SDK integration'
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Check propagation for single DNS record
|
|
835
|
+
*/
|
|
836
|
+
private async checkRecordPropagation(
|
|
837
|
+
domain: string,
|
|
838
|
+
record: DNSRecord
|
|
839
|
+
): Promise<DNSPropagationResult> {
|
|
840
|
+
const nameservers = await this.getNameservers(domain);
|
|
841
|
+
const results: { ns: string; value?: string; error?: string }[] = [];
|
|
842
|
+
|
|
843
|
+
for (const ns of nameservers) {
|
|
844
|
+
try {
|
|
845
|
+
const resolver = new Resolver({ nameServers: [ns] });
|
|
846
|
+
const response = await resolver.resolve(record.host, 'TXT');
|
|
847
|
+
const value = response.answers
|
|
848
|
+
.filter((a: { type: string }) => a.type === 'TXT')
|
|
849
|
+
.map((a: { data: string }) => a.data)
|
|
850
|
+
.join('');
|
|
851
|
+
|
|
852
|
+
results.push({ ns, value });
|
|
853
|
+
} catch (error) {
|
|
854
|
+
results.push({
|
|
855
|
+
ns,
|
|
856
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const propagatedCount = results.filter(
|
|
862
|
+
r => r.value && r.value.includes(record.value.substring(0, 20))
|
|
863
|
+
).length;
|
|
864
|
+
|
|
865
|
+
const propagationPercentage = (propagatedCount / nameservers.length) * 100;
|
|
866
|
+
const propagated = propagationPercentage >= 80; // 80% threshold
|
|
867
|
+
const actualValue = results.find(r => r.value)?.value;
|
|
868
|
+
const valuesMatch = actualValue?.includes(record.value.substring(0, 20)) || false;
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
domain,
|
|
872
|
+
record,
|
|
873
|
+
propagated,
|
|
874
|
+
nameservers,
|
|
875
|
+
propagationPercentage,
|
|
876
|
+
actualValue,
|
|
877
|
+
expectedValue: record.value,
|
|
878
|
+
valuesMatch,
|
|
879
|
+
errors: results.filter(r => r.error).map(r => r.error!)
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Get nameservers for domain
|
|
885
|
+
*/
|
|
886
|
+
private async getNameservers(domain: string): Promise<string[]> {
|
|
887
|
+
try {
|
|
888
|
+
const resolver = new Resolver();
|
|
889
|
+
const response = await resolver.resolve(domain, 'NS');
|
|
890
|
+
return response.answers
|
|
891
|
+
.filter((a) => a.ns !== undefined)
|
|
892
|
+
.map((a) => a.ns as string);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
895
|
+
this.logger.warn(`Failed to get nameservers for ${domain}, using defaults: ${errMsg}`);
|
|
896
|
+
|
|
897
|
+
// Return common public DNS servers as fallback
|
|
898
|
+
return [
|
|
899
|
+
'8.8.8.8', // Google
|
|
900
|
+
'1.1.1.1', // Cloudflare
|
|
901
|
+
'208.67.222.222' // OpenDNS
|
|
902
|
+
];
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Validate domain name format
|
|
908
|
+
*/
|
|
909
|
+
private validateDomain(domain: string): void {
|
|
910
|
+
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;
|
|
911
|
+
|
|
912
|
+
if (!domainRegex.test(domain)) {
|
|
913
|
+
throw new InvalidDomainError(`Invalid domain name: ${domain}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Add entry to verification history
|
|
919
|
+
*/
|
|
920
|
+
private async addHistoryEntry(
|
|
921
|
+
domain: string,
|
|
922
|
+
entry: Omit<VerificationHistoryEntry, 'id' | 'domain' | 'timestamp'>
|
|
923
|
+
): Promise<void> {
|
|
924
|
+
const history = this.verificationHistory.get(domain) || [];
|
|
925
|
+
|
|
926
|
+
const result = await generateUUIDKey();
|
|
927
|
+
history.push({
|
|
928
|
+
id: result.key,
|
|
929
|
+
domain,
|
|
930
|
+
timestamp: new Date(),
|
|
931
|
+
...entry
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
this.verificationHistory.set(domain, history);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Register with NeverHub
|
|
939
|
+
*/
|
|
940
|
+
private async registerWithNeverHub(): Promise<void> {
|
|
941
|
+
if (!this.neverhub) return;
|
|
942
|
+
|
|
943
|
+
await this.neverhub.register({
|
|
944
|
+
type: 'email-domain-verification',
|
|
945
|
+
name: '@bernierllc/email-domain-verification',
|
|
946
|
+
version: '1.0.0',
|
|
947
|
+
capabilities: [
|
|
948
|
+
{ type: 'email', name: 'domain-verification', version: '1.0.0' },
|
|
949
|
+
{ type: 'dns', name: 'record-management', version: '1.0.0' },
|
|
950
|
+
{ type: 'monitoring', name: 'health-checks', version: '1.0.0' }
|
|
951
|
+
],
|
|
952
|
+
dependencies: ['logging', 'crypto']
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
this.logger.info('Registered with NeverHub');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Publish event to NeverHub
|
|
960
|
+
*/
|
|
961
|
+
private async publishEvent(eventType: string, data: Record<string, unknown>): Promise<void> {
|
|
962
|
+
if (!this.neverhub) return;
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
await this.neverhub.publishEvent({
|
|
966
|
+
type: eventType,
|
|
967
|
+
data,
|
|
968
|
+
source: '@bernierllc/email-domain-verification',
|
|
969
|
+
timestamp: new Date().toISOString()
|
|
970
|
+
});
|
|
971
|
+
} catch (error) {
|
|
972
|
+
this.logger.warn('Failed to publish event to NeverHub', {
|
|
973
|
+
eventType,
|
|
974
|
+
error: error instanceof Error ? error.message : String(error)
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Cleanup resources
|
|
981
|
+
*/
|
|
982
|
+
async shutdown(): Promise<void> {
|
|
983
|
+
// Stop all health monitoring jobs
|
|
984
|
+
for (const [domain, job] of this.healthCheckJobs.entries()) {
|
|
985
|
+
job.stop();
|
|
986
|
+
this.logger.info('Stopped health monitoring', { domain });
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
this.healthCheckJobs.clear();
|
|
990
|
+
this.logger.info('Email Domain Verification Service shut down');
|
|
991
|
+
}
|
|
992
|
+
}
|