@push.rocks/smartmta 9.3.0 → 9.4.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 +19 -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/mail/delivery/classes.delivery.queue.d.ts +11 -0
- package/dist_ts/mail/delivery/classes.delivery.queue.js +95 -2
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +9 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +120 -22
- package/dist_ts/mail/delivery/interfaces.d.ts +2 -1
- package/dist_ts/mail/delivery/interfaces.js +1 -1
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +7 -0
- package/dist_ts/mail/routing/classes.unified.email.server.js +100 -3
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +41 -0
- package/dist_ts/security/classes.rustsecuritybridge.js +11 -1
- package/package.json +2 -2
- package/readme.md +19 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mail/delivery/classes.delivery.queue.ts +109 -1
- package/ts/mail/delivery/classes.delivery.system.ts +147 -24
- package/ts/mail/delivery/interfaces.ts +2 -0
- package/ts/mail/routing/classes.unified.email.server.ts +119 -2
- package/ts/security/classes.rustsecuritybridge.ts +68 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { logger } from '../../logger.js';
|
|
4
|
+
import { getErrorMessage } from '../../functions.errors.js';
|
|
4
5
|
import {
|
|
5
6
|
SecurityLogger,
|
|
6
7
|
SecurityLogLevel,
|
|
@@ -16,6 +17,7 @@ import { Email } from '../core/classes.email.js';
|
|
|
16
17
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
|
17
18
|
import { SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
|
|
18
19
|
import type {
|
|
20
|
+
IOutboundAuthenticationCheck,
|
|
19
21
|
ISmtpRecipientResult,
|
|
20
22
|
ISmtpSendResult,
|
|
21
23
|
} from '../../security/classes.rustsecuritybridge.js';
|
|
@@ -146,6 +148,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
146
148
|
private emailServer?: UnifiedEmailServer;
|
|
147
149
|
private bounceDrainTimer?: NodeJS.Timeout;
|
|
148
150
|
private bounceDrainPromise?: Promise<void>;
|
|
151
|
+
private readonly queueItemsReadyHandler = (items: IQueueItem[]): Promise<void> =>
|
|
152
|
+
this.processItems(items);
|
|
153
|
+
private queueListenerRegistered = false;
|
|
154
|
+
private lifecycleGeneration = 0;
|
|
155
|
+
private pendingSmtpAuthenticationUpdates = new Map<string, {
|
|
156
|
+
itemId: string;
|
|
157
|
+
transactionId: string;
|
|
158
|
+
transaction: ISmtpTransactionAttempt;
|
|
159
|
+
}>();
|
|
149
160
|
|
|
150
161
|
/**
|
|
151
162
|
* Create a new multi-mode delivery system
|
|
@@ -218,8 +229,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
218
229
|
}
|
|
219
230
|
};
|
|
220
231
|
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private registerQueueListener(): void {
|
|
235
|
+
if (this.queueListenerRegistered) return;
|
|
236
|
+
this.queue.on('itemsReady', this.queueItemsReadyHandler);
|
|
237
|
+
this.queueListenerRegistered = true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private unregisterQueueListener(): void {
|
|
241
|
+
if (!this.queueListenerRegistered) return;
|
|
242
|
+
this.queue.off('itemsReady', this.queueItemsReadyHandler);
|
|
243
|
+
this.queueListenerRegistered = false;
|
|
223
244
|
}
|
|
224
245
|
|
|
225
246
|
private createEmailForRecipients(email: Email, recipients: string[]): Email {
|
|
@@ -250,9 +271,43 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
250
271
|
recipients,
|
|
251
272
|
recipientResults: error.recipientResults,
|
|
252
273
|
...(recipientDomain ? { recipientDomain } : {}),
|
|
274
|
+
...(error.outboundAuthenticationHandle ? { outboundAuthenticationHandle: error.outboundAuthenticationHandle } : {}),
|
|
253
275
|
}, error);
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
private smtpAuthenticationUpdateKey(itemIdArg: string, transactionIdArg: string): string {
|
|
279
|
+
return `${itemIdArg}\u0000${transactionIdArg}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private async persistSmtpAuthenticationUpdate(updateArg: {
|
|
283
|
+
itemId: string;
|
|
284
|
+
transactionId: string;
|
|
285
|
+
transaction: ISmtpTransactionAttempt;
|
|
286
|
+
}): Promise<void> {
|
|
287
|
+
const updated = await this.queue.updateSmtpTransaction(
|
|
288
|
+
updateArg.itemId,
|
|
289
|
+
updateArg.transactionId,
|
|
290
|
+
updateArg.transaction,
|
|
291
|
+
);
|
|
292
|
+
if (updated) await emitSafely(this, 'smtpTransactionUpdated', updateArg.transaction);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async flushPendingSmtpAuthenticationUpdates(itemIdArg: string): Promise<void> {
|
|
296
|
+
const updates = [...this.pendingSmtpAuthenticationUpdates.entries()]
|
|
297
|
+
.filter(([, update]) => update.itemId === itemIdArg);
|
|
298
|
+
for (const [key, update] of updates) {
|
|
299
|
+
this.pendingSmtpAuthenticationUpdates.delete(key);
|
|
300
|
+
try {
|
|
301
|
+
await this.persistSmtpAuthenticationUpdate(update);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
logger.log(
|
|
304
|
+
'warn',
|
|
305
|
+
`Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
256
311
|
private async persistSmtpAttempt(optionsArg: {
|
|
257
312
|
item: IQueueItem;
|
|
258
313
|
targetHost: string;
|
|
@@ -265,6 +320,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
265
320
|
}): Promise<ISmtpTransactionAttempt> {
|
|
266
321
|
const completedAt = new Date();
|
|
267
322
|
const error = optionsArg.error;
|
|
323
|
+
const authenticationHandle = error?.outboundAuthenticationHandle || optionsArg.result?.outboundAuthenticationHandle;
|
|
268
324
|
const attempt: ISmtpTransactionAttempt = {
|
|
269
325
|
id: plugins.uuid.v4(),
|
|
270
326
|
queueItemId: optionsArg.item.id,
|
|
@@ -285,9 +341,48 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
285
341
|
recipientResults: error?.recipientResults || optionsArg.result?.recipientResults,
|
|
286
342
|
...(error ? { failurePhase: error.phase, error: error.message } : {}),
|
|
287
343
|
transcript: error?.transactionLog || optionsArg.result?.transactionLog || [],
|
|
344
|
+
...(authenticationHandle ? { outboundAuthentication: authenticationHandle.pending } : {}),
|
|
288
345
|
};
|
|
289
346
|
await this.queue.appendSmtpTransaction(optionsArg.item.id, attempt);
|
|
290
347
|
await emitSafely(this, 'smtpTransactionCompleted', attempt);
|
|
348
|
+
if (authenticationHandle) {
|
|
349
|
+
const persistAuthentication = async (authenticationArg: IOutboundAuthenticationCheck): Promise<void> => {
|
|
350
|
+
const updatedAttempt: ISmtpTransactionAttempt = {
|
|
351
|
+
...attempt,
|
|
352
|
+
outboundAuthentication: authenticationArg,
|
|
353
|
+
};
|
|
354
|
+
const update = {
|
|
355
|
+
itemId: optionsArg.item.id,
|
|
356
|
+
transactionId: attempt.id,
|
|
357
|
+
transaction: updatedAttempt,
|
|
358
|
+
};
|
|
359
|
+
// Delivery status and recipient checkpoints use whole-item durable
|
|
360
|
+
// mutations. Defer this diagnostic replacement until that lifecycle
|
|
361
|
+
// has settled so it cannot race and restore an earlier queue status.
|
|
362
|
+
if (this.activeDeliveries.has(optionsArg.item.id)) {
|
|
363
|
+
this.pendingSmtpAuthenticationUpdates.set(
|
|
364
|
+
this.smtpAuthenticationUpdateKey(optionsArg.item.id, attempt.id),
|
|
365
|
+
update,
|
|
366
|
+
);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await this.persistSmtpAuthenticationUpdate(update);
|
|
370
|
+
};
|
|
371
|
+
void authenticationHandle.result.then(
|
|
372
|
+
persistAuthentication,
|
|
373
|
+
(failureArg) => persistAuthentication({
|
|
374
|
+
state: 'indeterminate',
|
|
375
|
+
overall: 'indeterminate',
|
|
376
|
+
checkedAt: new Date().toISOString(),
|
|
377
|
+
error: String(failureArg instanceof Error ? failureArg.message : failureArg).slice(0, 512),
|
|
378
|
+
}),
|
|
379
|
+
).catch((error) => {
|
|
380
|
+
logger.log(
|
|
381
|
+
'warn',
|
|
382
|
+
`Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
291
386
|
return attempt;
|
|
292
387
|
}
|
|
293
388
|
|
|
@@ -429,14 +524,33 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
429
524
|
return;
|
|
430
525
|
}
|
|
431
526
|
|
|
527
|
+
const lifecycleGeneration = ++this.lifecycleGeneration;
|
|
432
528
|
this.running = true;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
529
|
+
this.registerQueueListener();
|
|
530
|
+
try {
|
|
531
|
+
await this.drainBounceOutbox();
|
|
532
|
+
if (!this.running || lifecycleGeneration !== this.lifecycleGeneration) return;
|
|
533
|
+
|
|
534
|
+
this.bounceDrainTimer = setInterval(() => {
|
|
535
|
+
void this.drainBounceOutbox();
|
|
536
|
+
}, 30_000);
|
|
537
|
+
|
|
538
|
+
await emitSafely(this, 'started');
|
|
539
|
+
if (!this.running || lifecycleGeneration !== this.lifecycleGeneration) return;
|
|
437
540
|
|
|
438
|
-
|
|
439
|
-
|
|
541
|
+
this.queue.wake();
|
|
542
|
+
logger.log('info', 'MultiModeDeliverySystem started successfully');
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
545
|
+
this.running = false;
|
|
546
|
+
this.unregisterQueueListener();
|
|
547
|
+
if (this.bounceDrainTimer) {
|
|
548
|
+
clearInterval(this.bounceDrainTimer);
|
|
549
|
+
this.bounceDrainTimer = undefined;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
440
554
|
}
|
|
441
555
|
|
|
442
556
|
/**
|
|
@@ -446,11 +560,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
446
560
|
logger.log('info', 'Stopping MultiModeDeliverySystem');
|
|
447
561
|
|
|
448
562
|
if (!this.running) {
|
|
563
|
+
this.unregisterQueueListener();
|
|
449
564
|
logger.log('warn', 'MultiModeDeliverySystem is already stopped');
|
|
450
565
|
return;
|
|
451
566
|
}
|
|
567
|
+
|
|
568
|
+
const lifecycleGeneration = ++this.lifecycleGeneration;
|
|
452
569
|
|
|
453
570
|
this.running = false;
|
|
571
|
+
this.unregisterQueueListener();
|
|
454
572
|
if (this.bounceDrainTimer) {
|
|
455
573
|
clearInterval(this.bounceDrainTimer);
|
|
456
574
|
this.bounceDrainTimer = undefined;
|
|
@@ -461,6 +579,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
461
579
|
clearTimeout(this.throttleResetTimer);
|
|
462
580
|
this.throttleResetTimer = null;
|
|
463
581
|
}
|
|
582
|
+
this.throttled = false;
|
|
583
|
+
this.rateLimitCounter = 0;
|
|
584
|
+
this.rateLimitLastCheck = Date.now();
|
|
585
|
+
this.stats.rateLimiting.currentRate = 0;
|
|
464
586
|
|
|
465
587
|
// Wait for active deliveries to complete
|
|
466
588
|
if (this.activeDeliveries.size > 0) {
|
|
@@ -485,6 +607,8 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
485
607
|
}
|
|
486
608
|
|
|
487
609
|
await this.bounceDrainPromise;
|
|
610
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return;
|
|
611
|
+
|
|
488
612
|
await emitSafely(this, 'stopped');
|
|
489
613
|
logger.log('info', 'MultiModeDeliverySystem stopped successfully');
|
|
490
614
|
}
|
|
@@ -504,17 +628,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
504
628
|
return;
|
|
505
629
|
}
|
|
506
630
|
|
|
507
|
-
// Check rate limiting
|
|
508
|
-
if (this.checkRateLimit()) {
|
|
509
|
-
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
631
|
let startedDeliveries = 0;
|
|
514
632
|
|
|
515
633
|
for (const item of items) {
|
|
516
634
|
if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
|
|
517
635
|
if (this.activeDeliveries.has(item.id)) continue;
|
|
636
|
+
if (this.checkRateLimit()) {
|
|
637
|
+
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
518
640
|
|
|
519
641
|
// Reserve synchronously before the first await so overlapping queue events
|
|
520
642
|
// cannot dispatch the same item while its durable claim is in flight.
|
|
@@ -733,9 +855,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
733
855
|
// Remove from active deliveries
|
|
734
856
|
this.activeDeliveries.delete(item.id);
|
|
735
857
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
858
|
+
await this.flushPendingSmtpAuthenticationUpdates(item.id);
|
|
736
859
|
|
|
737
860
|
// Update statistics
|
|
738
861
|
await emitSafely(this, 'statsUpdated', this.stats);
|
|
862
|
+
this.queue.wake();
|
|
739
863
|
}
|
|
740
864
|
}
|
|
741
865
|
|
|
@@ -1321,7 +1445,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1321
1445
|
this.rateLimitCounter = 0;
|
|
1322
1446
|
this.throttled = false;
|
|
1323
1447
|
this.stats.rateLimiting.currentRate = 0;
|
|
1324
|
-
return false;
|
|
1325
1448
|
}
|
|
1326
1449
|
|
|
1327
1450
|
// Check if we're already throttled
|
|
@@ -1329,15 +1452,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1329
1452
|
return true;
|
|
1330
1453
|
}
|
|
1331
1454
|
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
// Calculate current rate (emails per minute)
|
|
1336
|
-
const rate = (this.rateLimitCounter / elapsed) * 60000;
|
|
1337
|
-
this.stats.rateLimiting.currentRate = rate;
|
|
1338
|
-
|
|
1339
|
-
// Check if rate limit is exceeded
|
|
1340
|
-
if (rate > this.options.globalRateLimit) {
|
|
1455
|
+
// Use a fixed one-minute window. Extrapolating the first delivery from a
|
|
1456
|
+
// few elapsed milliseconds falsely throttles every freshly started queue.
|
|
1457
|
+
if (this.rateLimitCounter >= this.options.globalRateLimit) {
|
|
1341
1458
|
this.throttled = true;
|
|
1342
1459
|
this.stats.rateLimiting.throttled++;
|
|
1343
1460
|
|
|
@@ -1352,10 +1469,16 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1352
1469
|
this.rateLimitLastCheck = Date.now();
|
|
1353
1470
|
this.rateLimitCounter = 0;
|
|
1354
1471
|
this.stats.rateLimiting.currentRate = 0;
|
|
1472
|
+
if (this.running) {
|
|
1473
|
+
this.queue.wake();
|
|
1474
|
+
}
|
|
1355
1475
|
}, resetDelay);
|
|
1356
1476
|
|
|
1357
1477
|
return true;
|
|
1358
1478
|
}
|
|
1479
|
+
|
|
1480
|
+
this.rateLimitCounter++;
|
|
1481
|
+
this.stats.rateLimiting.currentRate = this.rateLimitCounter;
|
|
1359
1482
|
|
|
1360
1483
|
return false;
|
|
1361
1484
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
+
IOutboundAuthenticationCheck,
|
|
6
7
|
ISmtpRecipientResult,
|
|
7
8
|
ISmtpTranscriptEntry,
|
|
8
9
|
TSmtpDeliveryPhase,
|
|
@@ -27,6 +28,7 @@ export interface ISmtpTransactionAttempt {
|
|
|
27
28
|
failurePhase?: TSmtpDeliveryPhase;
|
|
28
29
|
error?: string;
|
|
29
30
|
transcript: ISmtpTranscriptEntry[];
|
|
31
|
+
outboundAuthentication?: IOutboundAuthenticationCheck;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
} from '../../security/index.js';
|
|
10
10
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
|
11
11
|
import type { IStorageManager } from '../interfaces.storage.js';
|
|
12
|
-
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
12
|
+
import { RustSecurityBridge, SmtpDeliveryError } from '../../security/classes.rustsecuritybridge.js';
|
|
13
13
|
import type {
|
|
14
|
+
IOutboundAuthenticationCheck,
|
|
15
|
+
IOutboundAuthenticationHandle,
|
|
14
16
|
IAuthRequestEvent,
|
|
15
17
|
IEmailReceivedEvent,
|
|
16
18
|
IEmailSecurityResult,
|
|
@@ -258,6 +260,12 @@ export interface IOutboundConnectionProxyContext {
|
|
|
258
260
|
senderDomain?: string;
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
export interface IOutboundConnectionSourceEvidence {
|
|
264
|
+
sourceIp: string;
|
|
265
|
+
addressFamily: 'ipv4' | 'ipv6';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
261
269
|
export interface IOutboundConnectionProxy {
|
|
262
270
|
connectHost: string;
|
|
263
271
|
connectPort: number;
|
|
@@ -265,6 +273,8 @@ export interface IOutboundConnectionProxy {
|
|
|
265
273
|
/** Per-connection EHLO identity override (e.g. the selected egress edge's PTR hostname). */
|
|
266
274
|
ehloHostname?: string;
|
|
267
275
|
close?: () => Promise<void> | void;
|
|
276
|
+
/** Actual source identity observed after the proxied connection is established. */
|
|
277
|
+
sourceEvidence?: Promise<IOutboundConnectionSourceEvidence | null>;
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
export interface IOutboundTopologyIdentity {
|
|
@@ -937,8 +947,28 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
937
947
|
senderDomain,
|
|
938
948
|
});
|
|
939
949
|
|
|
950
|
+
const captureId = proxy?.sourceEvidence ? plugins.uuid.v4() : undefined;
|
|
951
|
+
const attachAuthenticationHandle = <T extends ISmtpSendResult | SmtpDeliveryError>(valueArg: T): T => {
|
|
952
|
+
if (!captureId || !proxy?.sourceEvidence) return valueArg;
|
|
953
|
+
const handle = this.createOutboundAuthenticationHandle({
|
|
954
|
+
captureId,
|
|
955
|
+
sourceEvidence: proxy.sourceEvidence,
|
|
956
|
+
heloDomain: proxy.ehloHostname || this.getOutboundHostname(),
|
|
957
|
+
receiverHostname: host,
|
|
958
|
+
envelopeFrom: email.from,
|
|
959
|
+
dkimDomain: dkim?.domain,
|
|
960
|
+
dkimSelector: dkim?.selector,
|
|
961
|
+
});
|
|
962
|
+
Object.defineProperty(valueArg, 'outboundAuthenticationHandle', {
|
|
963
|
+
value: handle,
|
|
964
|
+
enumerable: false,
|
|
965
|
+
configurable: true,
|
|
966
|
+
});
|
|
967
|
+
return valueArg;
|
|
968
|
+
};
|
|
969
|
+
|
|
940
970
|
try {
|
|
941
|
-
|
|
971
|
+
const result = await this.rustBridge.sendOutboundEmail({
|
|
942
972
|
host,
|
|
943
973
|
port,
|
|
944
974
|
...(proxy ? { connectHost: proxy.connectHost, connectPort: proxy.connectPort } : {}),
|
|
@@ -953,12 +983,99 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
953
983
|
maxPoolConnections: this.options.outbound?.maxConnections || 10,
|
|
954
984
|
tlsOpportunistic,
|
|
955
985
|
...(proxy ? { disablePooling: true } : {}),
|
|
986
|
+
...(captureId ? { outboundAuthenticationCaptureId: captureId } : {}),
|
|
956
987
|
});
|
|
988
|
+
return attachAuthenticationHandle(result);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
throw attachAuthenticationHandle(SmtpDeliveryError.from(error));
|
|
957
991
|
} finally {
|
|
958
992
|
await proxy?.close?.();
|
|
959
993
|
}
|
|
960
994
|
}
|
|
961
995
|
|
|
996
|
+
private createOutboundAuthenticationHandle(
|
|
997
|
+
optionsArg: {
|
|
998
|
+
captureId: string;
|
|
999
|
+
sourceEvidence: Promise<IOutboundConnectionSourceEvidence | null>;
|
|
1000
|
+
heloDomain: string;
|
|
1001
|
+
receiverHostname: string;
|
|
1002
|
+
envelopeFrom: string;
|
|
1003
|
+
dkimDomain?: string;
|
|
1004
|
+
dkimSelector?: string;
|
|
1005
|
+
},
|
|
1006
|
+
): IOutboundAuthenticationHandle {
|
|
1007
|
+
const pending: IOutboundAuthenticationCheck = {
|
|
1008
|
+
state: 'pending',
|
|
1009
|
+
overall: 'indeterminate',
|
|
1010
|
+
checkedAt: new Date().toISOString(),
|
|
1011
|
+
};
|
|
1012
|
+
const indeterminate = (errorArg: string): IOutboundAuthenticationCheck => ({
|
|
1013
|
+
state: 'indeterminate',
|
|
1014
|
+
overall: 'indeterminate',
|
|
1015
|
+
checkedAt: new Date().toISOString(),
|
|
1016
|
+
error: errorArg,
|
|
1017
|
+
});
|
|
1018
|
+
const result = (async (): Promise<IOutboundAuthenticationCheck> => {
|
|
1019
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1020
|
+
try {
|
|
1021
|
+
const sourceEvidence = await new Promise<IOutboundConnectionSourceEvidence | null>((resolve, reject) => {
|
|
1022
|
+
timeout = setTimeout(() => {
|
|
1023
|
+
reject(new Error('Timed out waiting for the actual outbound source identity'));
|
|
1024
|
+
}, 40_000);
|
|
1025
|
+
timeout.unref?.();
|
|
1026
|
+
optionsArg.sourceEvidence.then(resolve, reject);
|
|
1027
|
+
});
|
|
1028
|
+
if (!sourceEvidence) {
|
|
1029
|
+
await this.rustBridge.discardCapturedOutboundEmail(optionsArg.captureId).catch(() => undefined);
|
|
1030
|
+
return indeterminate('The outbound proxy did not provide source identity evidence');
|
|
1031
|
+
}
|
|
1032
|
+
const verification = await this.rustBridge.verifyCapturedOutboundEmail({
|
|
1033
|
+
captureId: optionsArg.captureId,
|
|
1034
|
+
ip: sourceEvidence.sourceIp,
|
|
1035
|
+
heloDomain: optionsArg.heloDomain,
|
|
1036
|
+
hostname: optionsArg.receiverHostname,
|
|
1037
|
+
mailFrom: optionsArg.envelopeFrom,
|
|
1038
|
+
});
|
|
1039
|
+
if (verification.state !== 'completed' || !verification.result) {
|
|
1040
|
+
return indeterminate(verification.error || 'The outbound authentication self-check was not completed');
|
|
1041
|
+
}
|
|
1042
|
+
const security = verification.result;
|
|
1043
|
+
const dkimPass = !optionsArg.dkimDomain || security.dkim.some((resultArg) => (
|
|
1044
|
+
resultArg.is_valid
|
|
1045
|
+
&& resultArg.domain?.toLowerCase() === optionsArg.dkimDomain?.toLowerCase()
|
|
1046
|
+
&& (!optionsArg.dkimSelector || resultArg.selector === optionsArg.dkimSelector)
|
|
1047
|
+
));
|
|
1048
|
+
const spfPass = security.spf?.result.toLowerCase() === 'pass';
|
|
1049
|
+
const dmarcPass = security.dmarc?.passed === true;
|
|
1050
|
+
return {
|
|
1051
|
+
state: 'completed',
|
|
1052
|
+
overall: dkimPass && spfPass && dmarcPass ? 'pass' : 'fail',
|
|
1053
|
+
checkedAt: new Date().toISOString(),
|
|
1054
|
+
signedMessageSha256: verification.signedMessageSha256,
|
|
1055
|
+
identity: {
|
|
1056
|
+
sourceIp: sourceEvidence.sourceIp,
|
|
1057
|
+
addressFamily: sourceEvidence.addressFamily,
|
|
1058
|
+
heloDomain: optionsArg.heloDomain,
|
|
1059
|
+
receiverHostname: optionsArg.receiverHostname,
|
|
1060
|
+
envelopeFrom: optionsArg.envelopeFrom,
|
|
1061
|
+
...(optionsArg.dkimDomain ? { dkimDomain: optionsArg.dkimDomain } : {}),
|
|
1062
|
+
...(optionsArg.dkimSelector ? { dkimSelector: optionsArg.dkimSelector } : {}),
|
|
1063
|
+
},
|
|
1064
|
+
dkim: security.dkim,
|
|
1065
|
+
spf: security.spf,
|
|
1066
|
+
dmarc: security.dmarc,
|
|
1067
|
+
};
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
await this.rustBridge.discardCapturedOutboundEmail(optionsArg.captureId).catch(() => undefined);
|
|
1070
|
+
return indeterminate(getErrorMessage(error).slice(0, 512));
|
|
1071
|
+
} finally {
|
|
1072
|
+
if (timeout) clearTimeout(timeout);
|
|
1073
|
+
}
|
|
1074
|
+
})();
|
|
1075
|
+
return { pending, result };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
|
|
962
1079
|
/**
|
|
963
1080
|
* Start the unified email server
|
|
964
1081
|
*/
|
|
@@ -242,6 +242,7 @@ export interface ISmtpDeliveryErrorDetails {
|
|
|
242
242
|
recipients?: string[];
|
|
243
243
|
recipientDomain?: string;
|
|
244
244
|
recipientResults?: ISmtpRecipientResult[];
|
|
245
|
+
outboundAuthenticationHandle?: IOutboundAuthenticationHandle;
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
interface ISmtpRecipientResult {
|
|
@@ -259,6 +260,7 @@ export class SmtpDeliveryError extends Error implements ISmtpDeliveryErrorDetail
|
|
|
259
260
|
public readonly recipients: string[];
|
|
260
261
|
public readonly recipientDomain?: string;
|
|
261
262
|
public readonly recipientResults: ISmtpRecipientResult[];
|
|
263
|
+
public readonly outboundAuthenticationHandle?: IOutboundAuthenticationHandle;
|
|
262
264
|
|
|
263
265
|
constructor(details: ISmtpDeliveryErrorDetails, cause?: unknown) {
|
|
264
266
|
const transactionLog = normalizeSmtpTranscript(details.transactionLog);
|
|
@@ -277,6 +279,7 @@ export class SmtpDeliveryError extends Error implements ISmtpDeliveryErrorDetail
|
|
|
277
279
|
this.recipients = details.recipients || [];
|
|
278
280
|
this.recipientDomain = details.recipientDomain;
|
|
279
281
|
this.recipientResults = (details.recipientResults || []).map((result) => ({ ...result }));
|
|
282
|
+
this.outboundAuthenticationHandle = details.outboundAuthenticationHandle;
|
|
280
283
|
if (cause !== undefined && !authFailure) {
|
|
281
284
|
(this as Error & { cause?: unknown }).cause = cause;
|
|
282
285
|
}
|
|
@@ -370,6 +373,31 @@ interface IOutboundEmail {
|
|
|
370
373
|
}>;
|
|
371
374
|
}
|
|
372
375
|
|
|
376
|
+
export interface IOutboundAuthenticationCheck {
|
|
377
|
+
state: 'pending' | 'completed' | 'indeterminate' | 'not-evaluated';
|
|
378
|
+
overall: 'pass' | 'fail' | 'indeterminate';
|
|
379
|
+
checkedAt: string;
|
|
380
|
+
signedMessageSha256?: string;
|
|
381
|
+
error?: string;
|
|
382
|
+
identity?: {
|
|
383
|
+
sourceIp: string;
|
|
384
|
+
addressFamily: 'ipv4' | 'ipv6';
|
|
385
|
+
heloDomain: string;
|
|
386
|
+
receiverHostname: string;
|
|
387
|
+
envelopeFrom: string;
|
|
388
|
+
dkimDomain?: string;
|
|
389
|
+
dkimSelector?: string;
|
|
390
|
+
};
|
|
391
|
+
dkim?: IDkimVerificationResult[];
|
|
392
|
+
spf?: ISpfResult | null;
|
|
393
|
+
dmarc?: IDmarcResult | null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface IOutboundAuthenticationHandle {
|
|
397
|
+
pending: IOutboundAuthenticationCheck;
|
|
398
|
+
result: Promise<IOutboundAuthenticationCheck>;
|
|
399
|
+
}
|
|
400
|
+
|
|
373
401
|
interface ISmtpSendResult {
|
|
374
402
|
accepted: string[];
|
|
375
403
|
rejected: string[];
|
|
@@ -378,6 +406,7 @@ interface ISmtpSendResult {
|
|
|
378
406
|
response: string;
|
|
379
407
|
envelope: { from: string; to: string[] };
|
|
380
408
|
transactionLog: ISmtpTranscriptEntry[];
|
|
409
|
+
outboundAuthenticationHandle?: IOutboundAuthenticationHandle;
|
|
381
410
|
}
|
|
382
411
|
|
|
383
412
|
interface ISmtpSendOptions {
|
|
@@ -396,6 +425,14 @@ interface ISmtpSendOptions {
|
|
|
396
425
|
maxPoolConnections?: number;
|
|
397
426
|
tlsOpportunistic?: boolean;
|
|
398
427
|
disablePooling?: boolean;
|
|
428
|
+
outboundAuthenticationCaptureId?: string;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface IVerifyCapturedOutboundEmailResult {
|
|
432
|
+
state: 'completed' | 'indeterminate';
|
|
433
|
+
signedMessageSha256?: string;
|
|
434
|
+
error?: string;
|
|
435
|
+
result?: IEmailSecurityResult;
|
|
399
436
|
}
|
|
400
437
|
|
|
401
438
|
interface ISmtpSendRawOptions {
|
|
@@ -634,6 +671,20 @@ type TMailerCommands = {
|
|
|
634
671
|
params: ISmtpSendRawOptions;
|
|
635
672
|
result: ISmtpSendResult;
|
|
636
673
|
};
|
|
674
|
+
verifyCapturedOutboundEmail: {
|
|
675
|
+
params: {
|
|
676
|
+
captureId: string;
|
|
677
|
+
ip: string;
|
|
678
|
+
heloDomain: string;
|
|
679
|
+
hostname: string;
|
|
680
|
+
mailFrom: string;
|
|
681
|
+
};
|
|
682
|
+
result: IVerifyCapturedOutboundEmailResult;
|
|
683
|
+
};
|
|
684
|
+
discardCapturedOutboundEmail: {
|
|
685
|
+
params: { captureId: string };
|
|
686
|
+
result: { discarded: boolean };
|
|
687
|
+
};
|
|
637
688
|
verifySmtpConnection: {
|
|
638
689
|
params: ISmtpVerifyOptions;
|
|
639
690
|
result: ISmtpVerifyResult;
|
|
@@ -1189,6 +1240,7 @@ export class RustSecurityBridge extends EventEmitter {
|
|
|
1189
1240
|
this.ensureRunning();
|
|
1190
1241
|
try {
|
|
1191
1242
|
const result = await this.bridge.sendCommand('sendRawEmail', opts);
|
|
1243
|
+
|
|
1192
1244
|
return {
|
|
1193
1245
|
...result,
|
|
1194
1246
|
transactionLog: normalizeSmtpTranscript(result.transactionLog),
|
|
@@ -1198,6 +1250,22 @@ export class RustSecurityBridge extends EventEmitter {
|
|
|
1198
1250
|
}
|
|
1199
1251
|
}
|
|
1200
1252
|
|
|
1253
|
+
public async verifyCapturedOutboundEmail(opts: {
|
|
1254
|
+
captureId: string;
|
|
1255
|
+
ip: string;
|
|
1256
|
+
heloDomain: string;
|
|
1257
|
+
hostname: string;
|
|
1258
|
+
mailFrom: string;
|
|
1259
|
+
}): Promise<IVerifyCapturedOutboundEmailResult> {
|
|
1260
|
+
this.ensureRunning();
|
|
1261
|
+
return this.bridge.sendCommand('verifyCapturedOutboundEmail', opts);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
public async discardCapturedOutboundEmail(captureId: string): Promise<void> {
|
|
1265
|
+
this.ensureRunning();
|
|
1266
|
+
await this.bridge.sendCommand('discardCapturedOutboundEmail', { captureId });
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1201
1269
|
/** Verify connectivity to an SMTP server. */
|
|
1202
1270
|
public async verifySmtpConnection(opts: ISmtpVerifyOptions): Promise<ISmtpVerifyResult> {
|
|
1203
1271
|
this.ensureRunning();
|