@bernierllc/sender-identity-verification 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.test +8 -0
- package/.eslintrc.js +30 -0
- package/README.md +376 -0
- package/__tests__/SenderIdentityVerification.test.ts +461 -0
- package/__tests__/__mocks__/fetch-mock.ts +156 -0
- package/__tests__/additional-coverage.test.ts +129 -0
- package/__tests__/additional-error-coverage.test.ts +483 -0
- package/__tests__/branch-coverage.test.ts +509 -0
- package/__tests__/config.test.ts +119 -0
- package/__tests__/error-handling.test.ts +321 -0
- package/__tests__/final-branch-coverage.test.ts +372 -0
- package/__tests__/integration.real-api.test.ts +295 -0
- package/__tests__/providers.test.ts +331 -0
- package/__tests__/service-coverage.test.ts +412 -0
- package/dist/SenderIdentityVerification.d.ts +72 -0
- package/dist/SenderIdentityVerification.js +643 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +38 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +61 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +21 -0
- package/dist/providers/MailgunProvider.d.ts +13 -0
- package/dist/providers/MailgunProvider.js +35 -0
- package/dist/providers/SESProvider.d.ts +12 -0
- package/dist/providers/SESProvider.js +47 -0
- package/dist/providers/SMTPProvider.d.ts +12 -0
- package/dist/providers/SMTPProvider.js +30 -0
- package/dist/providers/SendGridProvider.d.ts +19 -0
- package/dist/providers/SendGridProvider.js +98 -0
- package/dist/templates/verification-email.d.ts +9 -0
- package/dist/templates/verification-email.js +67 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.js +33 -0
- package/dist/utils/domain-extractor.d.ts +4 -0
- package/dist/utils/domain-extractor.js +20 -0
- package/jest.config.cjs +33 -0
- package/package.json +60 -0
- package/src/SenderIdentityVerification.ts +796 -0
- package/src/config.ts +81 -0
- package/src/errors.ts +64 -0
- package/src/global.d.ts +24 -0
- package/src/index.ts +24 -0
- package/src/providers/MailgunProvider.ts +35 -0
- package/src/providers/SESProvider.ts +51 -0
- package/src/providers/SMTPProvider.ts +29 -0
- package/src/providers/SendGridProvider.ts +108 -0
- package/src/templates/verification-email.ts +67 -0
- package/src/types.ts +163 -0
- package/src/utils/domain-extractor.ts +18 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
SenderIdentity,
|
|
11
|
+
SenderStatus,
|
|
12
|
+
EmailProvider,
|
|
13
|
+
CreateSenderInput,
|
|
14
|
+
UpdateSenderInput,
|
|
15
|
+
VerificationResult,
|
|
16
|
+
ComplianceCheckResult,
|
|
17
|
+
ListSendersOptions,
|
|
18
|
+
SenderIdentityResult,
|
|
19
|
+
IEmailDomainVerification
|
|
20
|
+
} from './types.js';
|
|
21
|
+
import { SenderIdentityConfig } from './config.js';
|
|
22
|
+
import { SendGridProvider } from './providers/SendGridProvider.js';
|
|
23
|
+
import { MailgunProvider } from './providers/MailgunProvider.js';
|
|
24
|
+
import { SESProvider } from './providers/SESProvider.js';
|
|
25
|
+
import { SMTPProvider } from './providers/SMTPProvider.js';
|
|
26
|
+
import { buildVerificationEmailHtml, buildVerificationEmailText } from './templates/verification-email.js';
|
|
27
|
+
import { extractDomain } from './utils/domain-extractor.js';
|
|
28
|
+
|
|
29
|
+
// Import types for dependencies (actual imports would be from npm packages)
|
|
30
|
+
type EmailSender = {
|
|
31
|
+
sendEmail(options: {
|
|
32
|
+
to: string;
|
|
33
|
+
subject: string;
|
|
34
|
+
html: string;
|
|
35
|
+
text: string;
|
|
36
|
+
from: string;
|
|
37
|
+
fromName: string;
|
|
38
|
+
}): Promise<{ success: boolean; error?: string }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type CryptoUtils = {
|
|
42
|
+
generateSecureToken(length: number): string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type Logger = {
|
|
46
|
+
info(message: string, meta?: Record<string, unknown>): void;
|
|
47
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
48
|
+
error(message: string, meta?: Record<string, unknown>): void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type EmailValidator = {
|
|
52
|
+
validate(email: string): { isValid: boolean; errors: string[] };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type NeverHubAdapter = {
|
|
56
|
+
register(config: {
|
|
57
|
+
type: string;
|
|
58
|
+
name: string;
|
|
59
|
+
version: string;
|
|
60
|
+
capabilities: Array<{
|
|
61
|
+
type: string;
|
|
62
|
+
name: string;
|
|
63
|
+
version: string;
|
|
64
|
+
metadata?: Record<string, unknown>;
|
|
65
|
+
}>;
|
|
66
|
+
dependencies: string[];
|
|
67
|
+
discoveryEnabled: boolean;
|
|
68
|
+
discoverySubscriptions: Array<{
|
|
69
|
+
subscriptionType: string;
|
|
70
|
+
filterCriteria: { capabilityTypes: string[] };
|
|
71
|
+
}>;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
publishEvent(event: { type: string; data: Record<string, unknown> }): Promise<void>;
|
|
74
|
+
subscribe(eventType: string, handler: (event: { data: Record<string, unknown> }) => Promise<void>): Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sender identity verification service
|
|
79
|
+
*/
|
|
80
|
+
export class SenderIdentityVerification {
|
|
81
|
+
private senders: Map<string, SenderIdentity> = new Map();
|
|
82
|
+
private emailSender?: EmailSender;
|
|
83
|
+
private domainVerification?: IEmailDomainVerification;
|
|
84
|
+
private cryptoUtils?: CryptoUtils;
|
|
85
|
+
private logger?: Logger;
|
|
86
|
+
private emailValidator?: EmailValidator;
|
|
87
|
+
private neverhub?: NeverHubAdapter;
|
|
88
|
+
|
|
89
|
+
constructor(private config: SenderIdentityConfig) {
|
|
90
|
+
// Initialize domain verification from config if provided
|
|
91
|
+
if (config.domainVerificationConfig?.instance) {
|
|
92
|
+
this.domainVerification = config.domainVerificationConfig.instance;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Initialize service and register with NeverHub
|
|
98
|
+
*/
|
|
99
|
+
async initialize(): Promise<void> {
|
|
100
|
+
// Initialize dependencies (would import from actual packages)
|
|
101
|
+
// For now, we'll use stubs since we're missing dependencies
|
|
102
|
+
await this.loadSendersFromDatabase();
|
|
103
|
+
|
|
104
|
+
this.logger?.info('Sender identity verification service initialized');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a new sender identity
|
|
109
|
+
*/
|
|
110
|
+
async createSender(input: CreateSenderInput): Promise<SenderIdentityResult<SenderIdentity>> {
|
|
111
|
+
try {
|
|
112
|
+
// 1. Validate email format
|
|
113
|
+
if (!this.isValidEmail(input.email)) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
error: 'Invalid email format'
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. Extract domain
|
|
121
|
+
const domain = extractDomain(input.email);
|
|
122
|
+
|
|
123
|
+
// 3. Check if domain is verified (stub for missing dependency)
|
|
124
|
+
if (this.domainVerification) {
|
|
125
|
+
const domainStatus = await this.domainVerification.getDomainStatus(domain);
|
|
126
|
+
if (!domainStatus.isVerified) {
|
|
127
|
+
return {
|
|
128
|
+
success: false,
|
|
129
|
+
error: `Domain ${domain} is not verified. Please verify the domain first.`,
|
|
130
|
+
errors: [`Domain verification required`]
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. Check for duplicate sender
|
|
136
|
+
const existing = await this.findSenderByEmail(input.email);
|
|
137
|
+
if (existing) {
|
|
138
|
+
return {
|
|
139
|
+
success: false,
|
|
140
|
+
error: `Sender with email ${input.email} already exists (ID: ${existing.id})`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5. Create sender record
|
|
145
|
+
const sender: SenderIdentity = {
|
|
146
|
+
id: this.generateSenderId(),
|
|
147
|
+
email: input.email,
|
|
148
|
+
name: input.name,
|
|
149
|
+
replyToEmail: input.replyToEmail || input.email,
|
|
150
|
+
replyToName: input.replyToName || input.name,
|
|
151
|
+
domain,
|
|
152
|
+
provider: input.provider,
|
|
153
|
+
status: input.skipVerification ? SenderStatus.VERIFIED : SenderStatus.PENDING,
|
|
154
|
+
isDefault: input.isDefault || false,
|
|
155
|
+
isActive: true,
|
|
156
|
+
verificationAttempts: 0,
|
|
157
|
+
createdAt: new Date(),
|
|
158
|
+
updatedAt: new Date(),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// If setting as default, unset other defaults for this provider
|
|
162
|
+
if (input.isDefault) {
|
|
163
|
+
await this.unsetOtherDefaults(input.provider);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 6. Persist to database
|
|
167
|
+
await this.saveSenderToDatabase(sender);
|
|
168
|
+
this.senders.set(sender.id, sender);
|
|
169
|
+
|
|
170
|
+
// 7. Send verification email (unless skipped)
|
|
171
|
+
if (!input.skipVerification) {
|
|
172
|
+
await this.sendVerificationEmail(sender);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 8. Publish event
|
|
176
|
+
if (this.neverhub) {
|
|
177
|
+
await this.neverhub.publishEvent({
|
|
178
|
+
type: 'sender-identity.created',
|
|
179
|
+
data: { senderId: sender.id, email: sender.email, provider: sender.provider }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.logger?.info('Sender identity created', { senderId: sender.id, email: sender.email });
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
success: true,
|
|
187
|
+
data: sender
|
|
188
|
+
};
|
|
189
|
+
} catch (error) {
|
|
190
|
+
this.logger?.error('Failed to create sender identity', { error, input });
|
|
191
|
+
return {
|
|
192
|
+
success: false,
|
|
193
|
+
error: error instanceof Error ? error.message : 'Unknown error creating sender'
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Send verification email to sender
|
|
200
|
+
*/
|
|
201
|
+
async sendVerificationEmail(sender: SenderIdentity): Promise<SenderIdentityResult<void>> {
|
|
202
|
+
try {
|
|
203
|
+
// 1. Generate verification token
|
|
204
|
+
const token = this.cryptoUtils?.generateSecureToken(32) || this.fallbackGenerateToken(32);
|
|
205
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
206
|
+
|
|
207
|
+
// 2. Update sender record
|
|
208
|
+
sender.verificationToken = token;
|
|
209
|
+
sender.verificationSentAt = new Date();
|
|
210
|
+
sender.verificationExpiresAt = expiresAt;
|
|
211
|
+
sender.status = SenderStatus.VERIFICATION_SENT;
|
|
212
|
+
sender.verificationAttempts++;
|
|
213
|
+
sender.updatedAt = new Date();
|
|
214
|
+
|
|
215
|
+
await this.saveSenderToDatabase(sender);
|
|
216
|
+
|
|
217
|
+
// 3. Build verification URL
|
|
218
|
+
const verificationUrl = `${this.config.verificationBaseUrl}/verify-sender?token=${token}`;
|
|
219
|
+
|
|
220
|
+
// 4. Send verification email
|
|
221
|
+
if (this.emailSender) {
|
|
222
|
+
const emailResult = await this.emailSender.sendEmail({
|
|
223
|
+
to: sender.email,
|
|
224
|
+
subject: 'Verify your sender email address',
|
|
225
|
+
html: buildVerificationEmailHtml(sender, verificationUrl),
|
|
226
|
+
text: buildVerificationEmailText(sender, verificationUrl),
|
|
227
|
+
from: this.config.verificationFromEmail,
|
|
228
|
+
fromName: this.config.verificationFromName,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!emailResult.success) {
|
|
232
|
+
throw new Error(`Failed to send verification email: ${emailResult.error}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 5. Publish event
|
|
237
|
+
if (this.neverhub) {
|
|
238
|
+
await this.neverhub.publishEvent({
|
|
239
|
+
type: 'sender-identity.verification-sent',
|
|
240
|
+
data: { senderId: sender.id, email: sender.email, expiresAt }
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.logger?.info('Verification email sent', { senderId: sender.id, email: sender.email });
|
|
245
|
+
|
|
246
|
+
return { success: true };
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.logger?.error('Failed to send verification email', { error, senderId: sender.id });
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
error: error instanceof Error ? error.message : 'Failed to send verification email'
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Verify sender email with token
|
|
258
|
+
*/
|
|
259
|
+
async verifySender(token: string): Promise<VerificationResult> {
|
|
260
|
+
try {
|
|
261
|
+
// 1. Find sender by token
|
|
262
|
+
const sender = await this.findSenderByToken(token);
|
|
263
|
+
if (!sender) {
|
|
264
|
+
return {
|
|
265
|
+
success: false,
|
|
266
|
+
senderId: '',
|
|
267
|
+
status: SenderStatus.FAILED,
|
|
268
|
+
message: 'Invalid verification token',
|
|
269
|
+
errors: ['Token not found or already used']
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. Check if already verified
|
|
274
|
+
if (sender.status === SenderStatus.VERIFIED) {
|
|
275
|
+
return {
|
|
276
|
+
success: true,
|
|
277
|
+
senderId: sender.id,
|
|
278
|
+
status: sender.status,
|
|
279
|
+
message: 'Sender already verified',
|
|
280
|
+
verifiedAt: sender.verifiedAt
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3. Check if token expired
|
|
285
|
+
if (sender.verificationExpiresAt && sender.verificationExpiresAt < new Date()) {
|
|
286
|
+
sender.status = SenderStatus.EXPIRED;
|
|
287
|
+
sender.updatedAt = new Date();
|
|
288
|
+
await this.saveSenderToDatabase(sender);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
success: false,
|
|
292
|
+
senderId: sender.id,
|
|
293
|
+
status: SenderStatus.EXPIRED,
|
|
294
|
+
message: 'Verification token expired. Please request a new verification email.',
|
|
295
|
+
errors: ['Token expired']
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 4. Check if locked
|
|
300
|
+
if (sender.status === SenderStatus.LOCKED) {
|
|
301
|
+
return {
|
|
302
|
+
success: false,
|
|
303
|
+
senderId: sender.id,
|
|
304
|
+
status: SenderStatus.LOCKED,
|
|
305
|
+
message: 'Sender locked due to too many failed verification attempts',
|
|
306
|
+
errors: ['Account locked']
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 5. Verify with provider
|
|
311
|
+
const providerResult = await this.verifyWithProvider(sender);
|
|
312
|
+
if (!providerResult.success) {
|
|
313
|
+
sender.status = SenderStatus.FAILED;
|
|
314
|
+
sender.validationErrors = providerResult.errors || [providerResult.error || 'Provider verification failed'];
|
|
315
|
+
sender.updatedAt = new Date();
|
|
316
|
+
await this.saveSenderToDatabase(sender);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
success: false,
|
|
320
|
+
senderId: sender.id,
|
|
321
|
+
status: SenderStatus.FAILED,
|
|
322
|
+
message: 'Provider verification failed',
|
|
323
|
+
errors: sender.validationErrors
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 6. Mark as verified
|
|
328
|
+
sender.status = SenderStatus.VERIFIED;
|
|
329
|
+
sender.verifiedAt = new Date();
|
|
330
|
+
sender.verificationToken = undefined;
|
|
331
|
+
sender.providerSenderId = providerResult.data?.providerId as string | undefined;
|
|
332
|
+
sender.providerMetadata = providerResult.data?.metadata as Record<string, unknown> | undefined;
|
|
333
|
+
sender.lastValidated = new Date();
|
|
334
|
+
sender.validationErrors = undefined;
|
|
335
|
+
sender.updatedAt = new Date();
|
|
336
|
+
|
|
337
|
+
await this.saveSenderToDatabase(sender);
|
|
338
|
+
|
|
339
|
+
// 7. Publish event
|
|
340
|
+
if (this.neverhub) {
|
|
341
|
+
await this.neverhub.publishEvent({
|
|
342
|
+
type: 'sender-identity.verified',
|
|
343
|
+
data: {
|
|
344
|
+
senderId: sender.id,
|
|
345
|
+
email: sender.email,
|
|
346
|
+
provider: sender.provider,
|
|
347
|
+
verifiedAt: sender.verifiedAt
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.logger?.info('Sender verified successfully', { senderId: sender.id, email: sender.email });
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
success: true,
|
|
356
|
+
senderId: sender.id,
|
|
357
|
+
status: sender.status,
|
|
358
|
+
message: 'Sender verified successfully',
|
|
359
|
+
verifiedAt: sender.verifiedAt
|
|
360
|
+
};
|
|
361
|
+
} catch (error) {
|
|
362
|
+
this.logger?.error('Failed to verify sender', { error, token });
|
|
363
|
+
return {
|
|
364
|
+
success: false,
|
|
365
|
+
senderId: '',
|
|
366
|
+
status: SenderStatus.FAILED,
|
|
367
|
+
message: error instanceof Error ? error.message : 'Verification failed',
|
|
368
|
+
errors: [error instanceof Error ? error.message : 'Unknown error']
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Verify sender with email provider
|
|
375
|
+
*/
|
|
376
|
+
private async verifyWithProvider(sender: SenderIdentity): Promise<SenderIdentityResult<{
|
|
377
|
+
providerId?: string;
|
|
378
|
+
metadata?: Record<string, unknown>;
|
|
379
|
+
}>> {
|
|
380
|
+
switch (sender.provider) {
|
|
381
|
+
case EmailProvider.SENDGRID:
|
|
382
|
+
if (this.config.sendgridApiKey) {
|
|
383
|
+
const provider = new SendGridProvider(this.config.sendgridApiKey);
|
|
384
|
+
return provider.verifySender(sender);
|
|
385
|
+
}
|
|
386
|
+
return { success: false, error: 'SendGrid API key not configured' };
|
|
387
|
+
|
|
388
|
+
case EmailProvider.MAILGUN:
|
|
389
|
+
if (this.config.mailgunApiKey) {
|
|
390
|
+
const provider = new MailgunProvider(this.config.mailgunApiKey);
|
|
391
|
+
return provider.verifySender(sender);
|
|
392
|
+
}
|
|
393
|
+
return { success: true, data: {} }; // Mailgun doesn't require verification
|
|
394
|
+
|
|
395
|
+
case EmailProvider.SES:
|
|
396
|
+
if (this.config.sesAccessKey && this.config.sesSecretKey && this.config.sesRegion) {
|
|
397
|
+
const provider = new SESProvider(
|
|
398
|
+
this.config.sesAccessKey,
|
|
399
|
+
this.config.sesSecretKey,
|
|
400
|
+
this.config.sesRegion
|
|
401
|
+
);
|
|
402
|
+
return provider.verifySender(sender);
|
|
403
|
+
}
|
|
404
|
+
return { success: false, error: 'SES credentials not configured' };
|
|
405
|
+
|
|
406
|
+
case EmailProvider.SMTP: {
|
|
407
|
+
const provider = new SMTPProvider();
|
|
408
|
+
return provider.verifySender(sender);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
default:
|
|
412
|
+
return {
|
|
413
|
+
success: false,
|
|
414
|
+
error: `Unsupported provider: ${sender.provider}`
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get sender by ID
|
|
421
|
+
*/
|
|
422
|
+
async getSender(senderId: string): Promise<SenderIdentityResult<SenderIdentity>> {
|
|
423
|
+
const sender = this.senders.get(senderId);
|
|
424
|
+
if (!sender) {
|
|
425
|
+
return {
|
|
426
|
+
success: false,
|
|
427
|
+
error: `Sender not found: ${senderId}`
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
success: true,
|
|
433
|
+
data: sender
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* List senders
|
|
439
|
+
*/
|
|
440
|
+
async listSenders(options: ListSendersOptions = {}): Promise<SenderIdentityResult<SenderIdentity[]>> {
|
|
441
|
+
try {
|
|
442
|
+
let senders = Array.from(this.senders.values());
|
|
443
|
+
|
|
444
|
+
// Filter by provider
|
|
445
|
+
if (options.provider) {
|
|
446
|
+
senders = senders.filter(s => s.provider === options.provider);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Filter by status
|
|
450
|
+
if (options.status) {
|
|
451
|
+
senders = senders.filter(s => s.status === options.status);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Filter by active
|
|
455
|
+
if (options.isActive !== undefined) {
|
|
456
|
+
senders = senders.filter(s => s.isActive === options.isActive);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Filter by domain
|
|
460
|
+
if (options.domain) {
|
|
461
|
+
senders = senders.filter(s => s.domain === options.domain);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Sort by created date (newest first)
|
|
465
|
+
senders.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
466
|
+
|
|
467
|
+
// Pagination
|
|
468
|
+
const limit = options.limit || 50;
|
|
469
|
+
const offset = options.offset || 0;
|
|
470
|
+
senders = senders.slice(offset, offset + limit);
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
success: true,
|
|
474
|
+
data: senders
|
|
475
|
+
};
|
|
476
|
+
} catch (error) {
|
|
477
|
+
return {
|
|
478
|
+
success: false,
|
|
479
|
+
error: error instanceof Error ? error.message : 'Failed to list senders'
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Update sender
|
|
486
|
+
*/
|
|
487
|
+
async updateSender(senderId: string, input: UpdateSenderInput): Promise<SenderIdentityResult<SenderIdentity>> {
|
|
488
|
+
try {
|
|
489
|
+
const sender = this.senders.get(senderId);
|
|
490
|
+
if (!sender) {
|
|
491
|
+
return {
|
|
492
|
+
success: false,
|
|
493
|
+
error: `Sender not found: ${senderId}`
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Update fields
|
|
498
|
+
if (input.name !== undefined) sender.name = input.name;
|
|
499
|
+
if (input.replyToEmail !== undefined) sender.replyToEmail = input.replyToEmail;
|
|
500
|
+
if (input.replyToName !== undefined) sender.replyToName = input.replyToName;
|
|
501
|
+
if (input.isDefault !== undefined) {
|
|
502
|
+
// If setting as default, unset other defaults
|
|
503
|
+
if (input.isDefault) {
|
|
504
|
+
await this.unsetOtherDefaults(sender.provider);
|
|
505
|
+
}
|
|
506
|
+
sender.isDefault = input.isDefault;
|
|
507
|
+
}
|
|
508
|
+
if (input.isActive !== undefined) sender.isActive = input.isActive;
|
|
509
|
+
|
|
510
|
+
sender.updatedAt = new Date();
|
|
511
|
+
|
|
512
|
+
// If changing email-related fields, require re-verification
|
|
513
|
+
if (input.replyToEmail && input.replyToEmail !== sender.email) {
|
|
514
|
+
sender.status = SenderStatus.PENDING;
|
|
515
|
+
sender.verifiedAt = undefined;
|
|
516
|
+
sender.lastValidated = undefined;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
await this.saveSenderToDatabase(sender);
|
|
520
|
+
|
|
521
|
+
// Publish event
|
|
522
|
+
if (this.neverhub) {
|
|
523
|
+
await this.neverhub.publishEvent({
|
|
524
|
+
type: 'sender-identity.updated',
|
|
525
|
+
data: { senderId: sender.id, updates: input }
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
this.logger?.info('Sender updated', { senderId, updates: input });
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
success: true,
|
|
533
|
+
data: sender
|
|
534
|
+
};
|
|
535
|
+
} catch (error) {
|
|
536
|
+
this.logger?.error('Failed to update sender', { error, senderId, input });
|
|
537
|
+
return {
|
|
538
|
+
success: false,
|
|
539
|
+
error: error instanceof Error ? error.message : 'Failed to update sender'
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Delete sender
|
|
546
|
+
*/
|
|
547
|
+
async deleteSender(senderId: string): Promise<SenderIdentityResult<void>> {
|
|
548
|
+
try {
|
|
549
|
+
const sender = this.senders.get(senderId);
|
|
550
|
+
if (!sender) {
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
error: `Sender not found: ${senderId}`
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Soft delete
|
|
558
|
+
sender.deletedAt = new Date();
|
|
559
|
+
sender.isActive = false;
|
|
560
|
+
sender.updatedAt = new Date();
|
|
561
|
+
|
|
562
|
+
await this.saveSenderToDatabase(sender);
|
|
563
|
+
this.senders.delete(senderId);
|
|
564
|
+
|
|
565
|
+
// Publish event
|
|
566
|
+
if (this.neverhub) {
|
|
567
|
+
await this.neverhub.publishEvent({
|
|
568
|
+
type: 'sender-identity.deleted',
|
|
569
|
+
data: { senderId, email: sender.email }
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this.logger?.info('Sender deleted', { senderId, email: sender.email });
|
|
574
|
+
|
|
575
|
+
return { success: true };
|
|
576
|
+
} catch (error) {
|
|
577
|
+
this.logger?.error('Failed to delete sender', { error, senderId });
|
|
578
|
+
return {
|
|
579
|
+
success: false,
|
|
580
|
+
error: error instanceof Error ? error.message : 'Failed to delete sender'
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Check provider compliance for sender
|
|
587
|
+
*/
|
|
588
|
+
async checkCompliance(senderId: string): Promise<SenderIdentityResult<ComplianceCheckResult>> {
|
|
589
|
+
try {
|
|
590
|
+
const sender = this.senders.get(senderId);
|
|
591
|
+
if (!sender) {
|
|
592
|
+
return {
|
|
593
|
+
success: false,
|
|
594
|
+
error: `Sender not found: ${senderId}`
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const errors: string[] = [];
|
|
599
|
+
const warnings: string[] = [];
|
|
600
|
+
|
|
601
|
+
// 1. Check email format
|
|
602
|
+
const emailFormat = this.isValidEmail(sender.email);
|
|
603
|
+
if (!emailFormat) {
|
|
604
|
+
errors.push('Invalid email format');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 2. Check domain verification (stub)
|
|
608
|
+
let domainVerified = true;
|
|
609
|
+
let spfValid = true;
|
|
610
|
+
let dkimValid = true;
|
|
611
|
+
|
|
612
|
+
if (this.domainVerification) {
|
|
613
|
+
const domainStatus = await this.domainVerification.getDomainStatus(sender.domain);
|
|
614
|
+
domainVerified = domainStatus.isVerified;
|
|
615
|
+
spfValid = domainStatus.dnsRecords?.spf?.isValid || false;
|
|
616
|
+
dkimValid = domainStatus.dnsRecords?.dkim?.isValid || false;
|
|
617
|
+
|
|
618
|
+
if (!domainVerified) {
|
|
619
|
+
errors.push(`Domain ${sender.domain} is not verified`);
|
|
620
|
+
}
|
|
621
|
+
if (!spfValid) {
|
|
622
|
+
errors.push('SPF record not configured or invalid');
|
|
623
|
+
}
|
|
624
|
+
if (!dkimValid) {
|
|
625
|
+
errors.push('DKIM record not configured or invalid');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 3. Provider-specific checks
|
|
630
|
+
if (sender.provider === EmailProvider.SENDGRID) {
|
|
631
|
+
if (!sender.providerSenderId) {
|
|
632
|
+
warnings.push('SendGrid sender ID not set - may need re-verification');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const isCompliant = errors.length === 0;
|
|
637
|
+
|
|
638
|
+
const result: ComplianceCheckResult = {
|
|
639
|
+
isCompliant,
|
|
640
|
+
checks: {
|
|
641
|
+
domainVerified,
|
|
642
|
+
spfValid,
|
|
643
|
+
dkimValid,
|
|
644
|
+
emailFormat
|
|
645
|
+
},
|
|
646
|
+
errors,
|
|
647
|
+
warnings
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// Update sender validation
|
|
651
|
+
sender.lastValidated = new Date();
|
|
652
|
+
sender.validationErrors = errors.length > 0 ? errors : undefined;
|
|
653
|
+
await this.saveSenderToDatabase(sender);
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
success: true,
|
|
657
|
+
data: result
|
|
658
|
+
};
|
|
659
|
+
} catch (error) {
|
|
660
|
+
this.logger?.error('Failed to check compliance', { error, senderId });
|
|
661
|
+
return {
|
|
662
|
+
success: false,
|
|
663
|
+
error: error instanceof Error ? error.message : 'Failed to check compliance'
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Get default sender for provider
|
|
670
|
+
*/
|
|
671
|
+
async getDefaultSender(provider: EmailProvider): Promise<SenderIdentityResult<SenderIdentity>> {
|
|
672
|
+
const senders = Array.from(this.senders.values());
|
|
673
|
+
const defaultSender = senders.find(s =>
|
|
674
|
+
s.provider === provider &&
|
|
675
|
+
s.isDefault &&
|
|
676
|
+
s.status === SenderStatus.VERIFIED &&
|
|
677
|
+
s.isActive
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
if (!defaultSender) {
|
|
681
|
+
return {
|
|
682
|
+
success: false,
|
|
683
|
+
error: `No default sender found for provider ${provider}`
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
success: true,
|
|
689
|
+
data: defaultSender
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Resend verification email
|
|
695
|
+
*/
|
|
696
|
+
async resendVerification(senderId: string): Promise<SenderIdentityResult<void>> {
|
|
697
|
+
try {
|
|
698
|
+
const sender = this.senders.get(senderId);
|
|
699
|
+
if (!sender) {
|
|
700
|
+
return {
|
|
701
|
+
success: false,
|
|
702
|
+
error: `Sender not found: ${senderId}`
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (sender.status === SenderStatus.VERIFIED) {
|
|
707
|
+
return {
|
|
708
|
+
success: false,
|
|
709
|
+
error: 'Sender already verified'
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (sender.status === SenderStatus.LOCKED) {
|
|
714
|
+
return {
|
|
715
|
+
success: false,
|
|
716
|
+
error: 'Sender locked due to too many attempts'
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Check rate limiting (max 3 emails per hour)
|
|
721
|
+
if (sender.verificationSentAt) {
|
|
722
|
+
const hourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
|
723
|
+
if (sender.verificationSentAt > hourAgo && sender.verificationAttempts >= 3) {
|
|
724
|
+
return {
|
|
725
|
+
success: false,
|
|
726
|
+
error: 'Too many verification emails sent. Please try again later.'
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return this.sendVerificationEmail(sender);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
this.logger?.error('Failed to resend verification', { error, senderId });
|
|
734
|
+
return {
|
|
735
|
+
success: false,
|
|
736
|
+
error: error instanceof Error ? error.message : 'Failed to resend verification'
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ============================================
|
|
742
|
+
// Private Helper Methods
|
|
743
|
+
// ============================================
|
|
744
|
+
|
|
745
|
+
private generateSenderId(): string {
|
|
746
|
+
return this.cryptoUtils?.generateSecureToken(16) || this.fallbackGenerateToken(16);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private fallbackGenerateToken(length: number): string {
|
|
750
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
751
|
+
let result = '';
|
|
752
|
+
for (let i = 0; i < length; i++) {
|
|
753
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
754
|
+
}
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private isValidEmail(email: string): boolean {
|
|
759
|
+
if (this.emailValidator) {
|
|
760
|
+
return this.emailValidator.validate(email).isValid;
|
|
761
|
+
}
|
|
762
|
+
// Fallback basic validation
|
|
763
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
764
|
+
return emailRegex.test(email);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private async findSenderByEmail(email: string): Promise<SenderIdentity | undefined> {
|
|
768
|
+
return Array.from(this.senders.values()).find(s => s.email === email && !s.deletedAt);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private async findSenderByToken(token: string): Promise<SenderIdentity | undefined> {
|
|
772
|
+
return Array.from(this.senders.values()).find(s => s.verificationToken === token);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private async unsetOtherDefaults(provider: EmailProvider): Promise<void> {
|
|
776
|
+
const senders = Array.from(this.senders.values())
|
|
777
|
+
.filter(s => s.provider === provider && s.isDefault);
|
|
778
|
+
|
|
779
|
+
for (const sender of senders) {
|
|
780
|
+
sender.isDefault = false;
|
|
781
|
+
sender.updatedAt = new Date();
|
|
782
|
+
await this.saveSenderToDatabase(sender);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private async loadSendersFromDatabase(): Promise<void> {
|
|
787
|
+
// TODO: Implement database loading
|
|
788
|
+
// For now, no-op
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private async saveSenderToDatabase(sender: SenderIdentity): Promise<void> {
|
|
792
|
+
// TODO: Implement database persistence
|
|
793
|
+
// For now, just update in-memory map
|
|
794
|
+
this.senders.set(sender.id, sender);
|
|
795
|
+
}
|
|
796
|
+
}
|