@push.rocks/smartmta 7.0.0 → 8.0.1
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/changelog.md +36 -0
- package/dist_rust/mailer-bin_linux_amd64 +0 -0
- package/dist_rust/mailer-bin_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/functions.errors.d.ts +3 -0
- package/dist_ts/functions.errors.js +8 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/mail/core/classes.bouncemanager.d.ts +11 -0
- package/dist_ts/mail/core/classes.bouncemanager.js +120 -38
- package/dist_ts/mail/core/classes.email.js +14 -12
- package/dist_ts/mail/core/classes.emailvalidator.d.ts +3 -3
- package/dist_ts/mail/core/classes.emailvalidator.js +7 -5
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +77 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +551 -46
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
- package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
- package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
- package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
- package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
- package/dist_ts/mail/delivery/index.d.ts +1 -0
- package/dist_ts/mail/delivery/index.js +2 -1
- package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
- package/dist_ts/mail/delivery/interfaces.js +1 -1
- package/dist_ts/mail/index.d.ts +1 -0
- package/dist_ts/mail/index.js +1 -1
- package/dist_ts/mail/routing/classes.dkim.manager.d.ts +8 -4
- package/dist_ts/mail/routing/classes.dkim.manager.js +46 -29
- package/dist_ts/mail/routing/classes.dns.manager.d.ts +5 -3
- package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
- package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
- package/dist_ts/mail/routing/classes.email.router.d.ts +3 -0
- package/dist_ts/mail/routing/classes.email.router.js +15 -9
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +4 -0
- package/dist_ts/mail/routing/classes.unified.email.server.js +61 -40
- package/dist_ts/mail/security/classes.dkimcreator.d.ts +7 -0
- package/dist_ts/mail/security/classes.dkimcreator.js +33 -8
- package/dist_ts/mail/security/classes.spfverifier.js +5 -3
- package/dist_ts/security/classes.contentscanner.js +14 -11
- package/dist_ts/security/classes.ipreputationchecker.d.ts +3 -0
- package/dist_ts/security/classes.ipreputationchecker.js +20 -11
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +49 -1
- package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
- package/dist_ts/security/classes.securitylogger.js +7 -5
- package/dist_ts/security/index.d.ts +1 -1
- package/dist_ts/security/index.js +2 -2
- package/package.json +8 -8
- package/readme.hints.md +4 -3
- package/readme.md +41 -13
- package/readme.plan.md +6 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/functions.errors.ts +8 -0
- package/ts/index.ts +3 -0
- package/ts/mail/core/classes.bouncemanager.ts +157 -45
- package/ts/mail/core/classes.email.ts +19 -13
- package/ts/mail/core/classes.emailvalidator.ts +9 -7
- package/ts/mail/delivery/classes.delivery.queue.ts +740 -58
- package/ts/mail/delivery/classes.delivery.system.ts +583 -170
- package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
- package/ts/mail/delivery/functions.safe-observers.ts +45 -0
- package/ts/mail/delivery/index.ts +1 -0
- package/ts/mail/delivery/interfaces.ts +27 -1
- package/ts/mail/index.ts +1 -0
- package/ts/mail/routing/classes.dkim.manager.ts +62 -37
- package/ts/mail/routing/classes.dns.manager.ts +36 -13
- package/ts/mail/routing/classes.email.action.executor.ts +64 -17
- package/ts/mail/routing/classes.email.router.ts +15 -8
- package/ts/mail/routing/classes.unified.email.server.ts +90 -44
- package/ts/mail/security/classes.dkimcreator.ts +50 -7
- package/ts/mail/security/classes.spfverifier.ts +4 -2
- package/ts/security/classes.contentscanner.ts +14 -11
- package/ts/security/classes.ipreputationchecker.ts +21 -10
- package/ts/security/classes.rustsecuritybridge.ts +269 -3
- package/ts/security/classes.securitylogger.ts +6 -4
- package/ts/security/index.ts +6 -1
|
@@ -6,10 +6,20 @@ import {
|
|
|
6
6
|
SecurityLogLevel,
|
|
7
7
|
SecurityEventType
|
|
8
8
|
} from '../../security/index.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
UnifiedDeliveryQueue,
|
|
11
|
+
type IQueueItem,
|
|
12
|
+
type IRecipientPermanentFailure,
|
|
13
|
+
} from './classes.delivery.queue.js';
|
|
10
14
|
import { Email } from '../core/classes.email.js';
|
|
11
15
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
|
12
|
-
import {
|
|
16
|
+
import { SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
|
|
17
|
+
import type {
|
|
18
|
+
ISmtpRecipientResult,
|
|
19
|
+
ISmtpSendResult,
|
|
20
|
+
} from '../../security/classes.rustsecuritybridge.js';
|
|
21
|
+
import type { ISmtpTransactionAttempt } from './interfaces.js';
|
|
22
|
+
import { callObserverSafely, emitSafely } from './functions.safe-observers.js';
|
|
13
23
|
|
|
14
24
|
const dns = plugins.dns;
|
|
15
25
|
|
|
@@ -100,9 +110,16 @@ export interface IDeliveryStats {
|
|
|
100
110
|
/**
|
|
101
111
|
* Handles delivery for all email processing modes
|
|
102
112
|
*/
|
|
113
|
+
type TResolvedMultiModeDeliveryOptions = Omit<
|
|
114
|
+
Required<IMultiModeDeliveryOptions>,
|
|
115
|
+
'bounceHandler'
|
|
116
|
+
> & {
|
|
117
|
+
bounceHandler?: NonNullable<IMultiModeDeliveryOptions['bounceHandler']>;
|
|
118
|
+
};
|
|
119
|
+
|
|
103
120
|
export class MultiModeDeliverySystem extends EventEmitter {
|
|
104
121
|
private queue: UnifiedDeliveryQueue;
|
|
105
|
-
private options:
|
|
122
|
+
private options: TResolvedMultiModeDeliveryOptions;
|
|
106
123
|
private stats: IDeliveryStats;
|
|
107
124
|
private deliveryTimes: number[] = [];
|
|
108
125
|
private activeDeliveries: Set<string> = new Set();
|
|
@@ -112,6 +129,8 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
112
129
|
private rateLimitLastCheck: number = Date.now();
|
|
113
130
|
private rateLimitCounter: number = 0;
|
|
114
131
|
private emailServer?: UnifiedEmailServer;
|
|
132
|
+
private bounceDrainTimer?: NodeJS.Timeout;
|
|
133
|
+
private bounceDrainPromise?: Promise<void>;
|
|
115
134
|
|
|
116
135
|
/**
|
|
117
136
|
* Create a new multi-mode delivery system
|
|
@@ -145,7 +164,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
145
164
|
globalRateLimit: options.globalRateLimit || 100, // 100 emails per minute
|
|
146
165
|
perPatternRateLimit: options.perPatternRateLimit || {},
|
|
147
166
|
processBounces: options.processBounces !== false, // Default to true
|
|
148
|
-
bounceHandler: options.bounceHandler
|
|
167
|
+
bounceHandler: options.bounceHandler,
|
|
149
168
|
onDeliveryStart: options.onDeliveryStart || (async () => {}),
|
|
150
169
|
onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
|
|
151
170
|
onDeliveryFailed: options.onDeliveryFailed || (async () => {})
|
|
@@ -181,7 +200,169 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
181
200
|
// Set up event listeners
|
|
182
201
|
this.queue.on('itemsReady', this.processItems.bind(this));
|
|
183
202
|
}
|
|
184
|
-
|
|
203
|
+
|
|
204
|
+
private createEmailForRecipients(email: Email, recipients: string[]): Email {
|
|
205
|
+
const recipientSet = new Set(recipients.map((recipient) => recipient.toLowerCase()));
|
|
206
|
+
return new Email({
|
|
207
|
+
...email.toEmailOptions(),
|
|
208
|
+
to: email.to.filter((recipient) => recipientSet.has(recipient.toLowerCase())),
|
|
209
|
+
cc: (email.cc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
|
|
210
|
+
bcc: (email.bcc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
|
|
211
|
+
})
|
|
212
|
+
.setMessageId(email.getMessageId())
|
|
213
|
+
.setEnvelopeFrom(email.getEnvelopeFrom());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private contextualizeSmtpError(
|
|
217
|
+
errorArg: unknown,
|
|
218
|
+
recipients: string[],
|
|
219
|
+
recipientDomain?: string,
|
|
220
|
+
): SmtpDeliveryError {
|
|
221
|
+
const error = SmtpDeliveryError.from(errorArg);
|
|
222
|
+
return new SmtpDeliveryError({
|
|
223
|
+
message: error.message,
|
|
224
|
+
errorType: error.errorType,
|
|
225
|
+
retryable: error.retryable,
|
|
226
|
+
...(error.smtpCode !== undefined ? { smtpCode: error.smtpCode } : {}),
|
|
227
|
+
phase: error.phase,
|
|
228
|
+
transactionLog: error.transactionLog,
|
|
229
|
+
recipients,
|
|
230
|
+
recipientResults: error.recipientResults,
|
|
231
|
+
...(recipientDomain ? { recipientDomain } : {}),
|
|
232
|
+
}, error);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async persistSmtpAttempt(optionsArg: {
|
|
236
|
+
item: IQueueItem;
|
|
237
|
+
targetHost: string;
|
|
238
|
+
targetPort: number;
|
|
239
|
+
recipients: string[];
|
|
240
|
+
recipientDomain?: string;
|
|
241
|
+
startedAt: Date;
|
|
242
|
+
result?: ISmtpSendResult;
|
|
243
|
+
error?: SmtpDeliveryError;
|
|
244
|
+
}): Promise<ISmtpTransactionAttempt> {
|
|
245
|
+
const completedAt = new Date();
|
|
246
|
+
const error = optionsArg.error;
|
|
247
|
+
const attempt: ISmtpTransactionAttempt = {
|
|
248
|
+
id: plugins.uuid.v4(),
|
|
249
|
+
queueItemId: optionsArg.item.id,
|
|
250
|
+
queueAttempt: optionsArg.item.attempts,
|
|
251
|
+
targetHost: optionsArg.targetHost,
|
|
252
|
+
targetPort: optionsArg.targetPort,
|
|
253
|
+
...(optionsArg.recipientDomain
|
|
254
|
+
? { recipientDomain: optionsArg.recipientDomain }
|
|
255
|
+
: {}),
|
|
256
|
+
recipients: [...optionsArg.recipients],
|
|
257
|
+
startedAt: optionsArg.startedAt.toISOString(),
|
|
258
|
+
completedAt: completedAt.toISOString(),
|
|
259
|
+
durationMs: completedAt.getTime() - optionsArg.startedAt.getTime(),
|
|
260
|
+
outcome: error ? 'failed' : 'succeeded',
|
|
261
|
+
retryable: error?.retryable ?? false,
|
|
262
|
+
...(error?.errorType ? { errorType: error.errorType } : {}),
|
|
263
|
+
...(error?.smtpCode !== undefined ? { smtpCode: error.smtpCode } : {}),
|
|
264
|
+
recipientResults: error?.recipientResults || optionsArg.result?.recipientResults,
|
|
265
|
+
...(error ? { failurePhase: error.phase, error: error.message } : {}),
|
|
266
|
+
transcript: error?.transactionLog || optionsArg.result?.transactionLog || [],
|
|
267
|
+
};
|
|
268
|
+
await this.queue.appendSmtpTransaction(optionsArg.item.id, attempt);
|
|
269
|
+
await emitSafely(this, 'smtpTransactionCompleted', attempt);
|
|
270
|
+
return attempt;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private splitRecipientResults(
|
|
274
|
+
results: ISmtpRecipientResult[] | undefined,
|
|
275
|
+
rejectedFallback: string[] = [],
|
|
276
|
+
): { accepted: string[]; permanent: ISmtpRecipientResult[]; transient: string[] } {
|
|
277
|
+
if (!results || results.length === 0) {
|
|
278
|
+
return { accepted: [], permanent: [], transient: [...rejectedFallback] };
|
|
279
|
+
}
|
|
280
|
+
const representedRecipients = new Set(
|
|
281
|
+
results.map((result) => result.recipient.toLowerCase()),
|
|
282
|
+
);
|
|
283
|
+
return {
|
|
284
|
+
accepted: results.filter((result) => result.accepted).map((result) => result.recipient),
|
|
285
|
+
permanent: results.filter(
|
|
286
|
+
(result) => !result.accepted && result.responseCode >= 500,
|
|
287
|
+
),
|
|
288
|
+
transient: [
|
|
289
|
+
...results
|
|
290
|
+
.filter((result) => !result.accepted && result.responseCode < 500)
|
|
291
|
+
.map((result) => result.recipient),
|
|
292
|
+
...rejectedFallback.filter(
|
|
293
|
+
(recipient) => !representedRecipients.has(recipient.toLowerCase()),
|
|
294
|
+
),
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async checkpointPermanentRecipientFailures(
|
|
300
|
+
item: IQueueItem,
|
|
301
|
+
results: ISmtpRecipientResult[],
|
|
302
|
+
): Promise<string[]> {
|
|
303
|
+
const failures: IRecipientPermanentFailure[] = results.map((result) => ({
|
|
304
|
+
recipient: result.recipient,
|
|
305
|
+
smtpCode: result.responseCode,
|
|
306
|
+
smtpResponse: `SMTP recipient rejected with ${result.responseCode}`,
|
|
307
|
+
}));
|
|
308
|
+
const newlyTerminal = await this.queue.markRecipientsTerminal(item.id, failures);
|
|
309
|
+
if (newlyTerminal.length > 0 && this.running) {
|
|
310
|
+
void this.drainBounceOutbox();
|
|
311
|
+
}
|
|
312
|
+
return newlyTerminal;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Drain durable bounce work with one in-process flight. Custom handlers
|
|
317
|
+
* receive operationId and must use it for their own idempotency.
|
|
318
|
+
*/
|
|
319
|
+
public async drainBounceOutbox(): Promise<void> {
|
|
320
|
+
if (this.bounceDrainPromise) return this.bounceDrainPromise;
|
|
321
|
+
const drain = (async () => {
|
|
322
|
+
if (!this.options.processBounces || !this.options.bounceHandler) return;
|
|
323
|
+
for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
|
|
324
|
+
const item = this.queue.getItem(itemId);
|
|
325
|
+
if (!item) continue;
|
|
326
|
+
const email = item.processingResult as Email;
|
|
327
|
+
try {
|
|
328
|
+
await this.options.bounceHandler.processSmtpFailure(
|
|
329
|
+
operation.recipient,
|
|
330
|
+
operation.smtpResponse,
|
|
331
|
+
{
|
|
332
|
+
operationId: operation.operationId,
|
|
333
|
+
sender: email.from,
|
|
334
|
+
originalEmailId: item.id,
|
|
335
|
+
headers: email.headers,
|
|
336
|
+
statusCode: String(operation.smtpCode),
|
|
337
|
+
},
|
|
338
|
+
);
|
|
339
|
+
await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
logger.log(
|
|
342
|
+
'error',
|
|
343
|
+
`Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
|
|
344
|
+
);
|
|
345
|
+
try {
|
|
346
|
+
await this.queue.markBounceOperationFailed(
|
|
347
|
+
itemId,
|
|
348
|
+
operation.operationId,
|
|
349
|
+
(error as Error).message,
|
|
350
|
+
);
|
|
351
|
+
} catch (checkpointError) {
|
|
352
|
+
logger.log(
|
|
353
|
+
'error',
|
|
354
|
+
`Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
})();
|
|
360
|
+
this.bounceDrainPromise = drain.finally(() => {
|
|
361
|
+
this.bounceDrainPromise = undefined;
|
|
362
|
+
});
|
|
363
|
+
return this.bounceDrainPromise;
|
|
364
|
+
}
|
|
365
|
+
|
|
185
366
|
/**
|
|
186
367
|
* Start the delivery system
|
|
187
368
|
*/
|
|
@@ -194,9 +375,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
194
375
|
}
|
|
195
376
|
|
|
196
377
|
this.running = true;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
378
|
+
await this.drainBounceOutbox();
|
|
379
|
+
this.bounceDrainTimer = setInterval(() => {
|
|
380
|
+
void this.drainBounceOutbox();
|
|
381
|
+
}, 30_000);
|
|
382
|
+
|
|
383
|
+
await emitSafely(this, 'started');
|
|
200
384
|
logger.log('info', 'MultiModeDeliverySystem started successfully');
|
|
201
385
|
}
|
|
202
386
|
|
|
@@ -212,6 +396,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
212
396
|
}
|
|
213
397
|
|
|
214
398
|
this.running = false;
|
|
399
|
+
if (this.bounceDrainTimer) {
|
|
400
|
+
clearInterval(this.bounceDrainTimer);
|
|
401
|
+
this.bounceDrainTimer = undefined;
|
|
402
|
+
}
|
|
215
403
|
|
|
216
404
|
// Clear throttle reset timer to prevent it firing after stop
|
|
217
405
|
if (this.throttleResetTimer) {
|
|
@@ -241,8 +429,8 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
241
429
|
});
|
|
242
430
|
}
|
|
243
431
|
|
|
244
|
-
|
|
245
|
-
this
|
|
432
|
+
await this.bounceDrainPromise;
|
|
433
|
+
await emitSafely(this, 'stopped');
|
|
246
434
|
logger.log('info', 'MultiModeDeliverySystem stopped successfully');
|
|
247
435
|
}
|
|
248
436
|
|
|
@@ -267,33 +455,46 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
267
455
|
return;
|
|
268
456
|
}
|
|
269
457
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
logger.log('info', `Processing ${itemsToProcess.length} items for delivery`);
|
|
279
|
-
|
|
280
|
-
// Process each item
|
|
281
|
-
for (const item of itemsToProcess) {
|
|
282
|
-
// Mark as processing
|
|
283
|
-
await this.queue.markProcessing(item.id);
|
|
284
|
-
|
|
285
|
-
// Add to active deliveries
|
|
458
|
+
let startedDeliveries = 0;
|
|
459
|
+
|
|
460
|
+
for (const item of items) {
|
|
461
|
+
if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
|
|
462
|
+
if (this.activeDeliveries.has(item.id)) continue;
|
|
463
|
+
|
|
464
|
+
// Reserve synchronously before the first await so overlapping queue events
|
|
465
|
+
// cannot dispatch the same item while its durable claim is in flight.
|
|
286
466
|
this.activeDeliveries.add(item.id);
|
|
287
467
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
288
|
-
|
|
468
|
+
|
|
469
|
+
let claimed = false;
|
|
470
|
+
try {
|
|
471
|
+
claimed = await this.queue.markProcessing(item.id);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
this.activeDeliveries.delete(item.id);
|
|
474
|
+
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
475
|
+
logger.log('error', `Failed to claim queue item ${item.id}: ${(error as Error).message}`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!claimed) {
|
|
480
|
+
this.activeDeliveries.delete(item.id);
|
|
481
|
+
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
startedDeliveries++;
|
|
289
486
|
// Deliver asynchronously
|
|
290
487
|
this.deliverItem(item).catch(err => {
|
|
291
488
|
logger.log('error', `Unhandled error in delivery: ${err.message}`);
|
|
292
489
|
});
|
|
293
490
|
}
|
|
491
|
+
|
|
492
|
+
if (startedDeliveries > 0) {
|
|
493
|
+
logger.log('info', `Processing ${startedDeliveries} items for delivery`);
|
|
494
|
+
}
|
|
294
495
|
|
|
295
496
|
// Update statistics
|
|
296
|
-
this
|
|
497
|
+
await emitSafely(this, 'statsUpdated', this.stats);
|
|
297
498
|
}
|
|
298
499
|
|
|
299
500
|
/**
|
|
@@ -305,10 +506,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
305
506
|
|
|
306
507
|
try {
|
|
307
508
|
// Call delivery start hook
|
|
308
|
-
await this.options.onDeliveryStart
|
|
509
|
+
await callObserverSafely('delivery start hook', this.options.onDeliveryStart, item);
|
|
309
510
|
|
|
310
511
|
// Emit delivery start event
|
|
311
|
-
this
|
|
512
|
+
await emitSafely(this, 'deliveryStart', item);
|
|
312
513
|
logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
|
|
313
514
|
|
|
314
515
|
// Choose the appropriate handler based on mode
|
|
@@ -331,8 +532,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
331
532
|
throw new Error(`Unknown processing mode: ${item.processingMode}`);
|
|
332
533
|
}
|
|
333
534
|
|
|
334
|
-
//
|
|
335
|
-
|
|
535
|
+
// Built-in SMTP handlers checkpoint every recipient before returning.
|
|
536
|
+
// A partial success is terminal-success only when no recipient remains
|
|
537
|
+
// unresolved; all-permanent with no delivery is terminal failure.
|
|
538
|
+
if (result?.recipientDispositionManaged) {
|
|
539
|
+
const disposition = this.queue.getRecipientDisposition(item.id);
|
|
540
|
+
if (disposition.unresolved.length > 0) {
|
|
541
|
+
throw new SmtpDeliveryError({
|
|
542
|
+
message: `SMTP delivery left ${disposition.unresolved.length} recipient(s) unresolved`,
|
|
543
|
+
errorType: 'smtp_aggregate',
|
|
544
|
+
retryable: true,
|
|
545
|
+
phase: 'rcpt_to',
|
|
546
|
+
transactionLog: [],
|
|
547
|
+
recipients: disposition.unresolved,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (disposition.delivered.length === 0 && disposition.permanentFailures.length > 0) {
|
|
551
|
+
throw new SmtpDeliveryError({
|
|
552
|
+
message: 'All recipients were permanently rejected',
|
|
553
|
+
errorType: 'protocol',
|
|
554
|
+
retryable: false,
|
|
555
|
+
smtpCode: 550,
|
|
556
|
+
phase: 'rcpt_to',
|
|
557
|
+
transactionLog: [],
|
|
558
|
+
recipients: disposition.permanentFailures,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const deliveryTransitioned = await this.queue.markDelivered(item.id);
|
|
564
|
+
if (!deliveryTransitioned) return;
|
|
336
565
|
|
|
337
566
|
// Update statistics
|
|
338
567
|
this.stats.totalSuccessful++;
|
|
@@ -343,14 +572,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
343
572
|
this.deliveryTimes.push(deliveryTime);
|
|
344
573
|
this.updateDeliveryTimeStats();
|
|
345
574
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
575
|
+
await callObserverSafely(
|
|
576
|
+
'delivery success hook',
|
|
577
|
+
this.options.onDeliverySuccess,
|
|
578
|
+
item,
|
|
579
|
+
result,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
await emitSafely(this, 'deliverySuccess', item, result);
|
|
351
583
|
logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
|
|
352
584
|
|
|
353
|
-
SecurityLogger.getInstance().logEvent({
|
|
585
|
+
await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
|
|
354
586
|
level: SecurityLogLevel.INFO,
|
|
355
587
|
type: SecurityEventType.EMAIL_DELIVERY,
|
|
356
588
|
message: 'Email delivery successful',
|
|
@@ -361,75 +593,85 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
361
593
|
deliveryTime
|
|
362
594
|
},
|
|
363
595
|
success: true
|
|
364
|
-
});
|
|
365
|
-
} catch (error:
|
|
596
|
+
}));
|
|
597
|
+
} catch (error: unknown) {
|
|
366
598
|
// Calculate delivery attempt time even for failures
|
|
367
599
|
const deliveryTime = Date.now() - startTime;
|
|
600
|
+
const deliveryError = SmtpDeliveryError.from(error);
|
|
368
601
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
602
|
+
if (
|
|
603
|
+
deliveryError.phase === 'rcpt_to'
|
|
604
|
+
&& typeof deliveryError.smtpCode === 'number'
|
|
605
|
+
&& deliveryError.smtpCode >= 500
|
|
606
|
+
) {
|
|
607
|
+
const permanentRecipients = deliveryError.recipients.length > 0
|
|
608
|
+
? deliveryError.recipients
|
|
609
|
+
: item.processingResult.getAllRecipients();
|
|
610
|
+
await this.queue.markRecipientsTerminal(
|
|
611
|
+
item.id,
|
|
612
|
+
permanentRecipients.map((recipient) => ({
|
|
613
|
+
recipient,
|
|
614
|
+
smtpCode: deliveryError.smtpCode!,
|
|
615
|
+
smtpResponse: deliveryError.message,
|
|
616
|
+
})),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
|
|
621
|
+
retryable: deliveryError.retryable,
|
|
622
|
+
});
|
|
623
|
+
if (this.running) void this.drainBounceOutbox();
|
|
624
|
+
if (!transition.updated) return;
|
|
378
625
|
|
|
379
|
-
//
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
logger.log('info', `Processing delivery failure as bounce for recipient ${recipient}`);
|
|
390
|
-
|
|
391
|
-
// Process SMTP failure through bounce handler
|
|
392
|
-
await this.options.bounceHandler.processSmtpFailure(
|
|
393
|
-
recipient,
|
|
394
|
-
error.message,
|
|
395
|
-
{
|
|
396
|
-
sender: email.from,
|
|
397
|
-
originalEmailId: item.id,
|
|
398
|
-
headers: email.headers
|
|
399
|
-
}
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
logger.log('info', `Bounce record created for failed delivery to ${recipient}`);
|
|
403
|
-
}
|
|
404
|
-
} catch (bounceError) {
|
|
405
|
-
logger.log('error', `Failed to process bounce: ${bounceError.message}`);
|
|
406
|
-
}
|
|
626
|
+
// Intermediate retry attempts are deferrals, not failed deliveries.
|
|
627
|
+
if (transition.terminal) {
|
|
628
|
+
this.stats.totalFailed++;
|
|
629
|
+
this.stats.byMode[item.processingMode].failed++;
|
|
630
|
+
await callObserverSafely(
|
|
631
|
+
'delivery failure hook',
|
|
632
|
+
this.options.onDeliveryFailed,
|
|
633
|
+
item,
|
|
634
|
+
deliveryError.message,
|
|
635
|
+
);
|
|
407
636
|
}
|
|
408
637
|
|
|
409
638
|
// Emit delivery failed event
|
|
410
|
-
|
|
411
|
-
|
|
639
|
+
if (transition.terminal) {
|
|
640
|
+
await emitSafely(this, 'deliveryFailed', item, deliveryError);
|
|
641
|
+
await emitSafely(this, 'deliveryTerminalFailure', item, deliveryError, transition);
|
|
642
|
+
} else {
|
|
643
|
+
await emitSafely(this, 'deliveryDeferred', item, deliveryError, transition);
|
|
644
|
+
}
|
|
645
|
+
logger.log(
|
|
646
|
+
transition.terminal ? 'error' : 'warn',
|
|
647
|
+
`Item ${item.id} delivery ${transition.terminal ? 'failed' : 'deferred'}: ${deliveryError.message}`,
|
|
648
|
+
);
|
|
412
649
|
|
|
413
|
-
SecurityLogger.getInstance().logEvent({
|
|
414
|
-
level: SecurityLogLevel.ERROR,
|
|
650
|
+
await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
|
|
651
|
+
level: transition.terminal ? SecurityLogLevel.ERROR : SecurityLogLevel.WARN,
|
|
415
652
|
type: SecurityEventType.EMAIL_DELIVERY,
|
|
416
|
-
message: 'Email delivery failed',
|
|
653
|
+
message: transition.terminal ? 'Email delivery failed' : 'Email delivery deferred',
|
|
417
654
|
details: {
|
|
418
655
|
itemId: item.id,
|
|
419
656
|
mode: item.processingMode,
|
|
420
657
|
routeName: item.route?.name || 'unknown',
|
|
421
|
-
error:
|
|
658
|
+
error: deliveryError.message,
|
|
659
|
+
errorType: deliveryError.errorType,
|
|
660
|
+
retryable: deliveryError.retryable,
|
|
661
|
+
phase: deliveryError.phase,
|
|
662
|
+
smtpCode: deliveryError.smtpCode,
|
|
663
|
+
queueStatus: transition.status,
|
|
422
664
|
deliveryTime
|
|
423
665
|
},
|
|
424
666
|
success: false
|
|
425
|
-
});
|
|
667
|
+
}));
|
|
426
668
|
} finally {
|
|
427
669
|
// Remove from active deliveries
|
|
428
670
|
this.activeDeliveries.delete(item.id);
|
|
429
671
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
430
672
|
|
|
431
673
|
// Update statistics
|
|
432
|
-
this
|
|
674
|
+
await emitSafely(this, 'statsUpdated', this.stats);
|
|
433
675
|
}
|
|
434
676
|
}
|
|
435
677
|
|
|
@@ -451,8 +693,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
451
693
|
throw new Error('No target server configured for forward mode');
|
|
452
694
|
}
|
|
453
695
|
|
|
696
|
+
const deliveredRecipients = new Set(
|
|
697
|
+
(item.deliveredRecipients || []).map((recipient) => recipient.toLowerCase()),
|
|
698
|
+
);
|
|
699
|
+
const recipients = email
|
|
700
|
+
.getAllRecipients()
|
|
701
|
+
.filter((recipient) => !deliveredRecipients.has(recipient.toLowerCase()));
|
|
702
|
+
if (recipients.length === 0) {
|
|
703
|
+
return { targetServer, targetPort, recipients: 0, rejectedRecipients: [] };
|
|
704
|
+
}
|
|
454
705
|
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}`);
|
|
455
706
|
|
|
707
|
+
const startedAt = new Date();
|
|
456
708
|
try {
|
|
457
709
|
if (!this.emailServer) {
|
|
458
710
|
throw new Error('No email server available for forward delivery');
|
|
@@ -460,7 +712,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
460
712
|
|
|
461
713
|
// Build DKIM options from route config
|
|
462
714
|
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
|
|
463
|
-
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.
|
|
715
|
+
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.getFromDomain() || undefined)
|
|
464
716
|
: undefined;
|
|
465
717
|
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
|
|
466
718
|
|
|
@@ -468,10 +720,44 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
468
720
|
const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
|
|
469
721
|
|
|
470
722
|
// Send via Rust SMTP client
|
|
471
|
-
const result = await this.emailServer.sendOutboundEmail(
|
|
723
|
+
const result = await this.emailServer.sendOutboundEmail(
|
|
724
|
+
targetServer,
|
|
725
|
+
targetPort,
|
|
726
|
+
this.createEmailForRecipients(email, recipients),
|
|
727
|
+
{
|
|
472
728
|
auth,
|
|
473
729
|
dkimDomain,
|
|
474
730
|
dkimSelector,
|
|
731
|
+
},
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
await this.queue.markRecipientsDelivered(item.id, result.accepted);
|
|
735
|
+
if (result.rejected.length > 0) {
|
|
736
|
+
const partialError = new SmtpDeliveryError({
|
|
737
|
+
message: `SMTP target rejected ${result.rejected.length} recipient(s) without a retained response code`,
|
|
738
|
+
errorType: 'partial_recipient_rejection',
|
|
739
|
+
retryable: true,
|
|
740
|
+
phase: 'rcpt_to',
|
|
741
|
+
transactionLog: result.transactionLog,
|
|
742
|
+
recipients: result.rejected,
|
|
743
|
+
});
|
|
744
|
+
await this.persistSmtpAttempt({
|
|
745
|
+
item,
|
|
746
|
+
targetHost: targetServer,
|
|
747
|
+
targetPort,
|
|
748
|
+
recipients,
|
|
749
|
+
startedAt,
|
|
750
|
+
error: partialError,
|
|
751
|
+
});
|
|
752
|
+
throw partialError;
|
|
753
|
+
}
|
|
754
|
+
await this.persistSmtpAttempt({
|
|
755
|
+
item,
|
|
756
|
+
targetHost: targetServer,
|
|
757
|
+
targetPort,
|
|
758
|
+
recipients,
|
|
759
|
+
startedAt,
|
|
760
|
+
result,
|
|
475
761
|
});
|
|
476
762
|
|
|
477
763
|
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
|
|
@@ -483,9 +769,20 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
483
769
|
messageId: result.messageId,
|
|
484
770
|
rejectedRecipients: result.rejected,
|
|
485
771
|
};
|
|
486
|
-
} catch (error:
|
|
487
|
-
|
|
488
|
-
|
|
772
|
+
} catch (error: unknown) {
|
|
773
|
+
const smtpError = this.contextualizeSmtpError(error, recipients);
|
|
774
|
+
if (!(error instanceof SmtpDeliveryError && error.errorType === 'partial_recipient_rejection')) {
|
|
775
|
+
await this.persistSmtpAttempt({
|
|
776
|
+
item,
|
|
777
|
+
targetHost: targetServer,
|
|
778
|
+
targetPort,
|
|
779
|
+
recipients,
|
|
780
|
+
startedAt,
|
|
781
|
+
error: smtpError,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
logger.log('error', `Failed to forward email: ${smtpError.message}`);
|
|
785
|
+
throw smtpError;
|
|
489
786
|
}
|
|
490
787
|
}
|
|
491
788
|
|
|
@@ -519,6 +816,39 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
519
816
|
return groups;
|
|
520
817
|
}
|
|
521
818
|
|
|
819
|
+
private aggregateSmtpFailures(
|
|
820
|
+
failures: SmtpDeliveryError[],
|
|
821
|
+
recipients: string[],
|
|
822
|
+
recipientDomain?: string,
|
|
823
|
+
): SmtpDeliveryError {
|
|
824
|
+
const normalized = failures.length > 0
|
|
825
|
+
? failures
|
|
826
|
+
: [SmtpDeliveryError.from(new Error('SMTP delivery failed without error details'))];
|
|
827
|
+
const retryable = normalized.some((failure) => failure.retryable);
|
|
828
|
+
const allPermanentRecipientRejections = normalized.every((failure) => (
|
|
829
|
+
!failure.retryable
|
|
830
|
+
&& failure.errorType === 'protocol'
|
|
831
|
+
&& failure.phase === 'rcpt_to'
|
|
832
|
+
&& typeof failure.smtpCode === 'number'
|
|
833
|
+
&& failure.smtpCode >= 500
|
|
834
|
+
));
|
|
835
|
+
const selected = normalized.find((failure) => failure.retryable)
|
|
836
|
+
|| normalized[normalized.length - 1];
|
|
837
|
+
const smtpCode = allPermanentRecipientRejections
|
|
838
|
+
? Math.max(...normalized.map((failure) => failure.smtpCode || 500))
|
|
839
|
+
: selected.smtpCode;
|
|
840
|
+
return new SmtpDeliveryError({
|
|
841
|
+
message: normalized.map((failure) => failure.message).join('; '),
|
|
842
|
+
errorType: allPermanentRecipientRejections ? 'protocol' : 'smtp_aggregate',
|
|
843
|
+
retryable,
|
|
844
|
+
...(smtpCode !== undefined ? { smtpCode } : {}),
|
|
845
|
+
phase: allPermanentRecipientRejections ? 'rcpt_to' : selected.phase,
|
|
846
|
+
transactionLog: [],
|
|
847
|
+
recipients,
|
|
848
|
+
...(recipientDomain ? { recipientDomain } : {}),
|
|
849
|
+
}, selected);
|
|
850
|
+
}
|
|
851
|
+
|
|
522
852
|
/**
|
|
523
853
|
* Default handler for MTA mode delivery
|
|
524
854
|
* @param item Queue item
|
|
@@ -533,8 +863,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
533
863
|
}
|
|
534
864
|
|
|
535
865
|
// Build DKIM options from route config
|
|
536
|
-
const
|
|
537
|
-
|
|
866
|
+
const effectiveDkimSigning = Boolean(
|
|
867
|
+
item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim,
|
|
868
|
+
);
|
|
869
|
+
const dkimDomain = effectiveDkimSigning
|
|
870
|
+
? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
|
|
538
871
|
: undefined;
|
|
539
872
|
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
|
|
540
873
|
|
|
@@ -543,8 +876,30 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
543
876
|
throw new Error('No recipients specified for MTA delivery');
|
|
544
877
|
}
|
|
545
878
|
|
|
546
|
-
const
|
|
879
|
+
const deliveredRecipients = new Set(
|
|
880
|
+
(item.deliveredRecipients || []).map((recipient) => recipient.toLowerCase()),
|
|
881
|
+
);
|
|
882
|
+
const terminalRecipients = new Set(
|
|
883
|
+
(item.terminalRecipients || []).map((recipient) => recipient.toLowerCase()),
|
|
884
|
+
);
|
|
885
|
+
const pendingRecipients = allRecipients.filter(
|
|
886
|
+
(recipient) => (
|
|
887
|
+
!deliveredRecipients.has(recipient.toLowerCase())
|
|
888
|
+
&& !terminalRecipients.has(recipient.toLowerCase())
|
|
889
|
+
),
|
|
890
|
+
);
|
|
891
|
+
if (pendingRecipients.length === 0) {
|
|
892
|
+
return {
|
|
893
|
+
recipientDispositionManaged: true,
|
|
894
|
+
recipients: allRecipients.length,
|
|
895
|
+
domainResults: [],
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const domainGroups = this.groupRecipientsByDomain(pendingRecipients);
|
|
547
900
|
const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = [];
|
|
901
|
+
const unresolvedFailures: SmtpDeliveryError[] = [];
|
|
902
|
+
const unresolvedRecipients: string[] = [];
|
|
548
903
|
|
|
549
904
|
for (const [domain, recipients] of domainGroups) {
|
|
550
905
|
const allMxHosts = await this.resolveMxForDomain(domain);
|
|
@@ -555,66 +910,172 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
555
910
|
const mxHosts = allMxHosts.filter(mx => !this.emailServer!.isSelfHostname(mx.exchange));
|
|
556
911
|
if (mxHosts.length === 0) {
|
|
557
912
|
const selfMx = allMxHosts.map(mx => mx.exchange).join(', ');
|
|
913
|
+
const loopError = new SmtpDeliveryError({
|
|
914
|
+
message: `MX for ${domain} (${selfMx || 'none'}) points back at this server — refusing self-delivery to avoid a mail loop`,
|
|
915
|
+
errorType: 'config',
|
|
916
|
+
retryable: false,
|
|
917
|
+
phase: 'connect',
|
|
918
|
+
transactionLog: [],
|
|
919
|
+
recipients,
|
|
920
|
+
recipientDomain: domain,
|
|
921
|
+
});
|
|
922
|
+
unresolvedFailures.push(loopError);
|
|
923
|
+
unresolvedRecipients.push(...recipients);
|
|
558
924
|
results.push({
|
|
559
925
|
domain,
|
|
560
926
|
success: false,
|
|
561
|
-
error:
|
|
927
|
+
error: loopError.message,
|
|
562
928
|
});
|
|
563
929
|
logger.log('error', `MTA: refusing self-delivery for ${domain} — MX (${selfMx}) is this server's own hostname (mail loop)`);
|
|
564
930
|
continue;
|
|
565
931
|
}
|
|
566
932
|
|
|
567
|
-
let
|
|
568
|
-
|
|
933
|
+
let recipientsForMx = [...recipients];
|
|
934
|
+
const domainFailures: SmtpDeliveryError[] = [];
|
|
935
|
+
const acceptedForDomain: string[] = [];
|
|
569
936
|
|
|
570
937
|
for (const mx of mxHosts) {
|
|
938
|
+
if (recipientsForMx.length === 0) break;
|
|
939
|
+
const startedAt = new Date();
|
|
571
940
|
try {
|
|
572
941
|
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
|
|
573
942
|
|
|
574
943
|
// Create a temporary Email scoped to this domain's recipients
|
|
575
|
-
const domainEmail =
|
|
576
|
-
from: email.from,
|
|
577
|
-
to: recipients.filter(r => email.to.includes(r)),
|
|
578
|
-
cc: recipients.filter(r => (email.cc || []).includes(r)),
|
|
579
|
-
bcc: recipients.filter(r => (email.bcc || []).includes(r)),
|
|
580
|
-
subject: email.subject,
|
|
581
|
-
text: email.text,
|
|
582
|
-
html: email.html,
|
|
583
|
-
});
|
|
944
|
+
const domainEmail = this.createEmailForRecipients(email, recipientsForMx);
|
|
584
945
|
|
|
585
946
|
const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
|
|
586
947
|
dkimDomain,
|
|
587
948
|
dkimSelector,
|
|
588
949
|
});
|
|
589
950
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
951
|
+
const disposition = this.splitRecipientResults(
|
|
952
|
+
result.recipientResults,
|
|
953
|
+
result.rejected,
|
|
954
|
+
);
|
|
955
|
+
const accepted = disposition.accepted.length > 0
|
|
956
|
+
? disposition.accepted
|
|
957
|
+
: result.accepted;
|
|
958
|
+
await this.queue.markRecipientsDelivered(item.id, accepted);
|
|
959
|
+
acceptedForDomain.push(...accepted);
|
|
960
|
+
await this.checkpointPermanentRecipientFailures(
|
|
961
|
+
item,
|
|
962
|
+
disposition.permanent,
|
|
963
|
+
);
|
|
964
|
+
await this.persistSmtpAttempt({
|
|
965
|
+
item,
|
|
966
|
+
targetHost: mx.exchange,
|
|
967
|
+
targetPort: 25,
|
|
968
|
+
recipientDomain: domain,
|
|
969
|
+
recipients: recipientsForMx,
|
|
970
|
+
startedAt,
|
|
971
|
+
result,
|
|
595
972
|
});
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
973
|
+
recipientsForMx = disposition.transient;
|
|
974
|
+
if (recipientsForMx.length === 0) {
|
|
975
|
+
logger.log('info', `MTA: completed recipient dispositions for ${domain} via ${mx.exchange}`);
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
domainFailures.push(new SmtpDeliveryError({
|
|
979
|
+
message: `MX ${mx.exchange} temporarily rejected ${recipientsForMx.length} recipient(s)`,
|
|
980
|
+
errorType: 'protocol',
|
|
981
|
+
retryable: true,
|
|
982
|
+
smtpCode: result.recipientResults
|
|
983
|
+
? Math.min(
|
|
984
|
+
...result.recipientResults
|
|
985
|
+
.filter((entry) => recipientsForMx.includes(entry.recipient))
|
|
986
|
+
.map((entry) => entry.responseCode),
|
|
987
|
+
)
|
|
988
|
+
: 451,
|
|
989
|
+
phase: 'rcpt_to',
|
|
990
|
+
transactionLog: result.transactionLog,
|
|
991
|
+
recipients: recipientsForMx,
|
|
992
|
+
recipientDomain: domain,
|
|
993
|
+
recipientResults: result.recipientResults?.filter(
|
|
994
|
+
(entry) => recipientsForMx.includes(entry.recipient),
|
|
995
|
+
),
|
|
996
|
+
}));
|
|
997
|
+
} catch (error: unknown) {
|
|
998
|
+
const smtpError = this.contextualizeSmtpError(error, recipientsForMx, domain);
|
|
999
|
+
if (!(error instanceof SmtpDeliveryError && error.errorType === 'partial_recipient_rejection')) {
|
|
1000
|
+
await this.persistSmtpAttempt({
|
|
1001
|
+
item,
|
|
1002
|
+
targetHost: mx.exchange,
|
|
1003
|
+
targetPort: 25,
|
|
1004
|
+
recipientDomain: domain,
|
|
1005
|
+
recipients: recipientsForMx,
|
|
1006
|
+
startedAt,
|
|
1007
|
+
error: smtpError,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
const disposition = smtpError.phase === 'rcpt_to'
|
|
1011
|
+
? (
|
|
1012
|
+
smtpError.recipientResults.length > 0
|
|
1013
|
+
? this.splitRecipientResults(smtpError.recipientResults, smtpError.recipients)
|
|
1014
|
+
: (
|
|
1015
|
+
typeof smtpError.smtpCode === 'number' && smtpError.smtpCode >= 500
|
|
1016
|
+
? {
|
|
1017
|
+
accepted: [],
|
|
1018
|
+
permanent: recipientsForMx.map((recipient) => ({
|
|
1019
|
+
recipient,
|
|
1020
|
+
accepted: false,
|
|
1021
|
+
responseCode: smtpError.smtpCode!,
|
|
1022
|
+
})),
|
|
1023
|
+
transient: [],
|
|
1024
|
+
}
|
|
1025
|
+
: { accepted: [], permanent: [], transient: [...recipientsForMx] }
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
: { accepted: [], permanent: [], transient: [] };
|
|
1029
|
+
if (disposition.accepted.length > 0) {
|
|
1030
|
+
await this.queue.markRecipientsDelivered(item.id, disposition.accepted);
|
|
1031
|
+
acceptedForDomain.push(...disposition.accepted);
|
|
1032
|
+
}
|
|
1033
|
+
await this.checkpointPermanentRecipientFailures(
|
|
1034
|
+
item,
|
|
1035
|
+
disposition.permanent,
|
|
1036
|
+
);
|
|
1037
|
+
if (smtpError.phase === 'rcpt_to') {
|
|
1038
|
+
recipientsForMx = disposition.transient;
|
|
1039
|
+
}
|
|
1040
|
+
if (recipientsForMx.length > 0) {
|
|
1041
|
+
domainFailures.push(smtpError);
|
|
1042
|
+
}
|
|
1043
|
+
logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${smtpError.message}`);
|
|
602
1044
|
}
|
|
603
1045
|
}
|
|
604
1046
|
|
|
605
|
-
if (
|
|
606
|
-
|
|
1047
|
+
if (recipientsForMx.length > 0) {
|
|
1048
|
+
const domainError = this.aggregateSmtpFailures(
|
|
1049
|
+
domainFailures,
|
|
1050
|
+
recipientsForMx,
|
|
1051
|
+
domain,
|
|
1052
|
+
);
|
|
1053
|
+
unresolvedFailures.push(domainError);
|
|
1054
|
+
unresolvedRecipients.push(...recipientsForMx);
|
|
1055
|
+
results.push({
|
|
1056
|
+
domain,
|
|
1057
|
+
success: false,
|
|
1058
|
+
error: domainError.message,
|
|
1059
|
+
accepted: acceptedForDomain,
|
|
1060
|
+
rejected: recipientsForMx,
|
|
1061
|
+
});
|
|
607
1062
|
logger.log('error', `MTA: all MX hosts failed for ${domain}`);
|
|
1063
|
+
} else {
|
|
1064
|
+
results.push({
|
|
1065
|
+
domain,
|
|
1066
|
+
success: true,
|
|
1067
|
+
accepted: acceptedForDomain,
|
|
1068
|
+
rejected: [],
|
|
1069
|
+
});
|
|
608
1070
|
}
|
|
609
1071
|
}
|
|
610
1072
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const summary = results.map(r => `${r.domain}: ${r.error}`).join('; ');
|
|
614
|
-
throw new Error(`MTA delivery failed for all domains: ${summary}`);
|
|
1073
|
+
if (unresolvedRecipients.length > 0) {
|
|
1074
|
+
throw this.aggregateSmtpFailures(unresolvedFailures, unresolvedRecipients);
|
|
615
1075
|
}
|
|
616
1076
|
|
|
617
1077
|
return {
|
|
1078
|
+
recipientDispositionManaged: true,
|
|
618
1079
|
recipients: allRecipients.length,
|
|
619
1080
|
domainResults: results,
|
|
620
1081
|
};
|
|
@@ -684,11 +1145,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
684
1145
|
}
|
|
685
1146
|
}
|
|
686
1147
|
|
|
687
|
-
// Apply DKIM signing if configured (after all transformations)
|
|
688
|
-
if (item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim) {
|
|
689
|
-
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
|
|
690
|
-
}
|
|
691
|
-
|
|
692
1148
|
logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
|
|
693
1149
|
|
|
694
1150
|
// After scanning + transformations, deliver via MTA
|
|
@@ -706,49 +1162,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
706
1162
|
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
|
707
1163
|
}
|
|
708
1164
|
|
|
709
|
-
/**
|
|
710
|
-
* Apply DKIM signing to an email
|
|
711
|
-
*/
|
|
712
|
-
private async applyDkimSigning(email: Email, mtaOptions: any): Promise<void> {
|
|
713
|
-
if (!this.emailServer) {
|
|
714
|
-
logger.log('warn', 'Cannot apply DKIM signing without email server reference');
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
const domainName = mtaOptions.dkimOptions?.domainName || email.from.split('@')[1];
|
|
719
|
-
const keySelector = mtaOptions.dkimOptions?.keySelector || 'default';
|
|
720
|
-
|
|
721
|
-
try {
|
|
722
|
-
// Ensure DKIM keys exist for the exact selector advertised in the signature.
|
|
723
|
-
await this.emailServer.dkimCreator.handleDKIMKeysForSelector(domainName, keySelector);
|
|
724
|
-
|
|
725
|
-
// Load the private key for that same selector.
|
|
726
|
-
const dkimPrivateKey = (
|
|
727
|
-
await this.emailServer.dkimCreator.readDKIMKeysForSelector(domainName, keySelector)
|
|
728
|
-
).privateKey;
|
|
729
|
-
|
|
730
|
-
// Convert Email to raw format for signing
|
|
731
|
-
const rawEmail = email.toRFC822String();
|
|
732
|
-
|
|
733
|
-
// Sign via Rust bridge
|
|
734
|
-
const bridge = RustSecurityBridge.getInstance();
|
|
735
|
-
const signResult = await bridge.signDkim({
|
|
736
|
-
rawMessage: rawEmail,
|
|
737
|
-
domain: domainName,
|
|
738
|
-
selector: keySelector,
|
|
739
|
-
privateKey: dkimPrivateKey,
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
if (signResult.header) {
|
|
743
|
-
email.addHeader('DKIM-Signature', signResult.header);
|
|
744
|
-
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
|
|
745
|
-
}
|
|
746
|
-
} catch (error) {
|
|
747
|
-
logger.log('error', `Failed to apply DKIM signature: ${error.message}`);
|
|
748
|
-
// Don't throw - allow email to be sent without DKIM if signing fails
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
1165
|
/**
|
|
753
1166
|
* Update delivery time statistics
|
|
754
1167
|
*/
|