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