@push.rocks/smartmta 9.4.0 → 9.5.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/changelog.md +23 -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/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +71 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.js +794 -179
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +11 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +206 -80
- package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
- package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
- package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
- package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
- package/dist_ts/mail/index.d.ts +1 -0
- package/dist_ts/mail/index.js +1 -1
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +40 -0
- package/dist_ts/mail/routing/classes.unified.email.server.js +222 -76
- package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
- package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
- package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
- package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
- package/package.json +2 -2
- package/readme.hints.md +8 -0
- package/readme.md +36 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +969 -209
- package/ts/mail/delivery/classes.delivery.system.ts +254 -101
- package/ts/mail/delivery/functions.raw-email.ts +111 -0
- package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
- package/ts/mail/index.ts +1 -0
- package/ts/mail/routing/classes.unified.email.server.ts +329 -85
- package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
- package/ts/security/classes.rustsecuritybridge.ts +6 -0
|
@@ -23,6 +23,15 @@ import type {
|
|
|
23
23
|
} from '../../security/classes.rustsecuritybridge.js';
|
|
24
24
|
import type { ISmtpTransactionAttempt } from './interfaces.js';
|
|
25
25
|
import { callObserverSafely, emitSafely } from './functions.safe-observers.js';
|
|
26
|
+
import {
|
|
27
|
+
kQueueCreateItemSnapshot,
|
|
28
|
+
kQueueSetReadyItemsHandler,
|
|
29
|
+
kQueueWithRawMessage,
|
|
30
|
+
} from './symbols.delivery.queue.js';
|
|
31
|
+
import {
|
|
32
|
+
kSendOutboundEmailInternal,
|
|
33
|
+
kSendRawOutboundEmailInternal,
|
|
34
|
+
} from '../routing/symbols.unified.email.server.js';
|
|
26
35
|
|
|
27
36
|
const dns = plugins.dns;
|
|
28
37
|
|
|
@@ -148,6 +157,22 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
148
157
|
private emailServer?: UnifiedEmailServer;
|
|
149
158
|
private bounceDrainTimer?: NodeJS.Timeout;
|
|
150
159
|
private bounceDrainPromise?: Promise<void>;
|
|
160
|
+
private readonly dispatchFlights = new Set<Promise<void>>();
|
|
161
|
+
private readonly deliveryFlights = new Set<Promise<void>>();
|
|
162
|
+
private readonly authenticationUpdateFlights = new Set<Promise<void>>();
|
|
163
|
+
private stopFlight?: Promise<void>;
|
|
164
|
+
private readonly queueItemsReadyHandler = (itemIds: string[]): Promise<void> => {
|
|
165
|
+
let flight: Promise<void>;
|
|
166
|
+
flight = Promise.resolve()
|
|
167
|
+
.then(() => this.processItems(itemIds))
|
|
168
|
+
.finally(() => {
|
|
169
|
+
this.dispatchFlights.delete(flight);
|
|
170
|
+
});
|
|
171
|
+
this.dispatchFlights.add(flight);
|
|
172
|
+
return flight;
|
|
173
|
+
};
|
|
174
|
+
private queueListenerRegistered = false;
|
|
175
|
+
private lifecycleGeneration = 0;
|
|
151
176
|
private pendingSmtpAuthenticationUpdates = new Map<string, {
|
|
152
177
|
itemId: string;
|
|
153
178
|
transactionId: string;
|
|
@@ -225,8 +250,38 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
225
250
|
}
|
|
226
251
|
};
|
|
227
252
|
|
|
228
|
-
|
|
229
|
-
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private registerQueueListener(): void {
|
|
256
|
+
if (this.queueListenerRegistered) return;
|
|
257
|
+
this.queue[kQueueSetReadyItemsHandler](this.queueItemsReadyHandler);
|
|
258
|
+
this.queueListenerRegistered = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private unregisterQueueListener(): void {
|
|
262
|
+
if (!this.queueListenerRegistered) return;
|
|
263
|
+
this.queue[kQueueSetReadyItemsHandler](undefined);
|
|
264
|
+
this.queueListenerRegistered = false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async emitItemEventSafely(
|
|
268
|
+
eventNameArg: string,
|
|
269
|
+
itemArg: IQueueItem,
|
|
270
|
+
...additionalArgsArg: unknown[]
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
for (const rawListener of this.rawListeners(eventNameArg)) {
|
|
273
|
+
try {
|
|
274
|
+
await Reflect.apply(rawListener, this, [
|
|
275
|
+
this.queue[kQueueCreateItemSnapshot](itemArg),
|
|
276
|
+
...additionalArgsArg,
|
|
277
|
+
]);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logger.log(
|
|
280
|
+
'error',
|
|
281
|
+
`Observer for ${eventNameArg} failed: ${getErrorMessage(error)}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
230
285
|
}
|
|
231
286
|
|
|
232
287
|
private createEmailForRecipients(email: Email, recipients: string[]): Email {
|
|
@@ -354,20 +409,27 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
354
409
|
}
|
|
355
410
|
await this.persistSmtpAuthenticationUpdate(update);
|
|
356
411
|
};
|
|
357
|
-
void
|
|
358
|
-
|
|
359
|
-
(
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
412
|
+
let authenticationUpdateFlight: Promise<void>;
|
|
413
|
+
authenticationUpdateFlight = authenticationHandle.result
|
|
414
|
+
.then(
|
|
415
|
+
persistAuthentication,
|
|
416
|
+
(failureArg) => persistAuthentication({
|
|
417
|
+
state: 'indeterminate',
|
|
418
|
+
overall: 'indeterminate',
|
|
419
|
+
checkedAt: new Date().toISOString(),
|
|
420
|
+
error: String(failureArg instanceof Error ? failureArg.message : failureArg).slice(0, 512),
|
|
421
|
+
}),
|
|
422
|
+
)
|
|
423
|
+
.catch((error) => {
|
|
424
|
+
logger.log(
|
|
425
|
+
'warn',
|
|
426
|
+
`Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
|
|
427
|
+
);
|
|
428
|
+
})
|
|
429
|
+
.finally(() => {
|
|
430
|
+
this.authenticationUpdateFlights.delete(authenticationUpdateFlight);
|
|
431
|
+
});
|
|
432
|
+
this.authenticationUpdateFlights.add(authenticationUpdateFlight);
|
|
371
433
|
}
|
|
372
434
|
return attempt;
|
|
373
435
|
}
|
|
@@ -510,28 +572,63 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
510
572
|
return;
|
|
511
573
|
}
|
|
512
574
|
|
|
575
|
+
const lifecycleGeneration = ++this.lifecycleGeneration;
|
|
513
576
|
this.running = true;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
577
|
+
this.registerQueueListener();
|
|
578
|
+
try {
|
|
579
|
+
await this.drainBounceOutbox();
|
|
580
|
+
if (!this.running || lifecycleGeneration !== this.lifecycleGeneration) return;
|
|
581
|
+
|
|
582
|
+
this.bounceDrainTimer = setInterval(() => {
|
|
583
|
+
void this.drainBounceOutbox();
|
|
584
|
+
}, 30_000);
|
|
585
|
+
|
|
586
|
+
await emitSafely(this, 'started');
|
|
587
|
+
if (!this.running || lifecycleGeneration !== this.lifecycleGeneration) return;
|
|
588
|
+
|
|
589
|
+
this.queue.wake();
|
|
590
|
+
logger.log('info', 'MultiModeDeliverySystem started successfully');
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
593
|
+
this.running = false;
|
|
594
|
+
this.unregisterQueueListener();
|
|
595
|
+
if (this.bounceDrainTimer) {
|
|
596
|
+
clearInterval(this.bounceDrainTimer);
|
|
597
|
+
this.bounceDrainTimer = undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
521
602
|
}
|
|
522
603
|
|
|
523
604
|
/**
|
|
524
605
|
* Stop the delivery system
|
|
525
606
|
*/
|
|
526
|
-
public
|
|
607
|
+
public stop(): Promise<void> {
|
|
608
|
+
if (this.stopFlight) return this.stopFlight;
|
|
609
|
+
let stopFlight: Promise<void>;
|
|
610
|
+
stopFlight = this.stopOnce().finally(() => {
|
|
611
|
+
if (this.stopFlight === stopFlight) {
|
|
612
|
+
this.stopFlight = undefined;
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
this.stopFlight = stopFlight;
|
|
616
|
+
return stopFlight;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private async stopOnce(): Promise<void> {
|
|
527
620
|
logger.log('info', 'Stopping MultiModeDeliverySystem');
|
|
528
621
|
|
|
529
622
|
if (!this.running) {
|
|
623
|
+
this.unregisterQueueListener();
|
|
530
624
|
logger.log('warn', 'MultiModeDeliverySystem is already stopped');
|
|
531
625
|
return;
|
|
532
626
|
}
|
|
627
|
+
|
|
628
|
+
const lifecycleGeneration = ++this.lifecycleGeneration;
|
|
533
629
|
|
|
534
630
|
this.running = false;
|
|
631
|
+
this.unregisterQueueListener();
|
|
535
632
|
if (this.bounceDrainTimer) {
|
|
536
633
|
clearInterval(this.bounceDrainTimer);
|
|
537
634
|
this.bounceDrainTimer = undefined;
|
|
@@ -542,30 +639,27 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
542
639
|
clearTimeout(this.throttleResetTimer);
|
|
543
640
|
this.throttleResetTimer = null;
|
|
544
641
|
}
|
|
642
|
+
this.throttled = false;
|
|
643
|
+
this.rateLimitCounter = 0;
|
|
644
|
+
this.rateLimitLastCheck = Date.now();
|
|
645
|
+
this.stats.rateLimiting.currentRate = 0;
|
|
545
646
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
}, 1000);
|
|
559
|
-
|
|
560
|
-
// Force resolve after 30 seconds
|
|
561
|
-
const forceTimeout = setTimeout(() => {
|
|
562
|
-
clearInterval(checkInterval);
|
|
563
|
-
resolve();
|
|
564
|
-
}, 30000);
|
|
565
|
-
});
|
|
647
|
+
while (this.dispatchFlights.size > 0) {
|
|
648
|
+
await Promise.allSettled([...this.dispatchFlights]);
|
|
649
|
+
}
|
|
650
|
+
if (this.deliveryFlights.size > 0) {
|
|
651
|
+
logger.log('info', `Waiting for ${this.deliveryFlights.size} active deliveries to complete`);
|
|
652
|
+
while (this.deliveryFlights.size > 0) {
|
|
653
|
+
await Promise.allSettled([...this.deliveryFlights]);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
while (this.authenticationUpdateFlights.size > 0) {
|
|
657
|
+
await Promise.allSettled([...this.authenticationUpdateFlights]);
|
|
566
658
|
}
|
|
567
659
|
|
|
568
660
|
await this.bounceDrainPromise;
|
|
661
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return;
|
|
662
|
+
|
|
569
663
|
await emitSafely(this, 'stopped');
|
|
570
664
|
logger.log('info', 'MultiModeDeliverySystem stopped successfully');
|
|
571
665
|
}
|
|
@@ -574,7 +668,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
574
668
|
* Process ready items from the queue
|
|
575
669
|
* @param items Queue items ready for processing
|
|
576
670
|
*/
|
|
577
|
-
private async processItems(
|
|
671
|
+
private async processItems(itemIds: string[]): Promise<void> {
|
|
578
672
|
if (!this.running) {
|
|
579
673
|
return;
|
|
580
674
|
}
|
|
@@ -585,44 +679,55 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
585
679
|
return;
|
|
586
680
|
}
|
|
587
681
|
|
|
588
|
-
// Check rate limiting
|
|
589
|
-
if (this.checkRateLimit()) {
|
|
590
|
-
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
682
|
let startedDeliveries = 0;
|
|
595
683
|
|
|
596
|
-
for (const
|
|
684
|
+
for (const itemId of itemIds) {
|
|
597
685
|
if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
|
|
598
|
-
if (this.activeDeliveries.has(
|
|
686
|
+
if (this.activeDeliveries.has(itemId)) continue;
|
|
687
|
+
if (this.checkRateLimit()) {
|
|
688
|
+
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
599
691
|
|
|
600
692
|
// Reserve synchronously before the first await so overlapping queue events
|
|
601
693
|
// cannot dispatch the same item while its durable claim is in flight.
|
|
602
|
-
this.activeDeliveries.add(
|
|
694
|
+
this.activeDeliveries.add(itemId);
|
|
603
695
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
604
696
|
|
|
605
697
|
let claimed = false;
|
|
606
698
|
try {
|
|
607
|
-
claimed = await this.queue.markProcessing(
|
|
699
|
+
claimed = await this.queue.markProcessing(itemId);
|
|
608
700
|
} catch (error) {
|
|
609
|
-
this.activeDeliveries.delete(
|
|
701
|
+
this.activeDeliveries.delete(itemId);
|
|
610
702
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
611
|
-
logger.log('error', `Failed to claim queue item ${
|
|
703
|
+
logger.log('error', `Failed to claim queue item ${itemId}: ${(error as Error).message}`);
|
|
612
704
|
continue;
|
|
613
705
|
}
|
|
614
706
|
|
|
615
707
|
if (!claimed) {
|
|
616
|
-
this.activeDeliveries.delete(
|
|
708
|
+
this.activeDeliveries.delete(itemId);
|
|
709
|
+
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const item = this.queue.getItem(itemId);
|
|
714
|
+
if (!item) {
|
|
715
|
+
this.activeDeliveries.delete(itemId);
|
|
617
716
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
618
717
|
continue;
|
|
619
718
|
}
|
|
620
719
|
|
|
621
720
|
startedDeliveries++;
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
721
|
+
let deliveryFlight: Promise<void>;
|
|
722
|
+
deliveryFlight = Promise.resolve()
|
|
723
|
+
.then(() => this.deliverItem(item))
|
|
724
|
+
.catch((error) => {
|
|
725
|
+
logger.log('error', `Unhandled error in delivery: ${getErrorMessage(error)}`);
|
|
726
|
+
})
|
|
727
|
+
.finally(() => {
|
|
728
|
+
this.deliveryFlights.delete(deliveryFlight);
|
|
729
|
+
});
|
|
730
|
+
this.deliveryFlights.add(deliveryFlight);
|
|
626
731
|
}
|
|
627
732
|
|
|
628
733
|
if (startedDeliveries > 0) {
|
|
@@ -642,10 +747,14 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
642
747
|
|
|
643
748
|
try {
|
|
644
749
|
// Call delivery start hook
|
|
645
|
-
await callObserverSafely(
|
|
750
|
+
await callObserverSafely(
|
|
751
|
+
'delivery start hook',
|
|
752
|
+
this.options.onDeliveryStart,
|
|
753
|
+
this.queue[kQueueCreateItemSnapshot](item),
|
|
754
|
+
);
|
|
646
755
|
|
|
647
756
|
// Emit delivery start event
|
|
648
|
-
await
|
|
757
|
+
await this.emitItemEventSafely('deliveryStart', item);
|
|
649
758
|
logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
|
|
650
759
|
|
|
651
760
|
// Choose the appropriate handler based on mode
|
|
@@ -653,15 +762,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
653
762
|
|
|
654
763
|
switch (item.processingMode) {
|
|
655
764
|
case 'forward':
|
|
656
|
-
result = await this.options.forwardHandler.deliver(item);
|
|
765
|
+
result = await this.options.forwardHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
|
|
657
766
|
break;
|
|
658
767
|
|
|
659
768
|
case 'mta':
|
|
660
|
-
result =
|
|
769
|
+
result = item.messageFormat === 'raw-rfc822'
|
|
770
|
+
? await this.handleMtaDelivery(this.queue[kQueueCreateItemSnapshot](item))
|
|
771
|
+
: await this.options.deliveryHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
|
|
661
772
|
break;
|
|
662
773
|
|
|
663
774
|
case 'process':
|
|
664
|
-
result = await this.options.processHandler.deliver(item);
|
|
775
|
+
result = await this.options.processHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
|
|
665
776
|
break;
|
|
666
777
|
|
|
667
778
|
default:
|
|
@@ -711,11 +822,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
711
822
|
await callObserverSafely(
|
|
712
823
|
'delivery success hook',
|
|
713
824
|
this.options.onDeliverySuccess,
|
|
714
|
-
item,
|
|
825
|
+
this.queue[kQueueCreateItemSnapshot](item),
|
|
715
826
|
result,
|
|
716
827
|
);
|
|
717
828
|
|
|
718
|
-
await
|
|
829
|
+
await this.emitItemEventSafely('deliverySuccess', item, result);
|
|
719
830
|
logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
|
|
720
831
|
|
|
721
832
|
await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
|
|
@@ -775,17 +886,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
775
886
|
await callObserverSafely(
|
|
776
887
|
'delivery failure hook',
|
|
777
888
|
this.options.onDeliveryFailed,
|
|
778
|
-
item,
|
|
889
|
+
this.queue[kQueueCreateItemSnapshot](item),
|
|
779
890
|
deliveryError.message,
|
|
780
891
|
);
|
|
781
892
|
}
|
|
782
893
|
|
|
783
894
|
// Emit delivery failed event
|
|
784
895
|
if (transition.terminal) {
|
|
785
|
-
await
|
|
786
|
-
await
|
|
896
|
+
await this.emitItemEventSafely('deliveryFailed', item, deliveryError);
|
|
897
|
+
await this.emitItemEventSafely('deliveryTerminalFailure', item, deliveryError, transition);
|
|
787
898
|
} else {
|
|
788
|
-
await
|
|
899
|
+
await this.emitItemEventSafely('deliveryDeferred', item, deliveryError, transition);
|
|
789
900
|
}
|
|
790
901
|
logger.log(
|
|
791
902
|
transition.terminal ? 'error' : 'warn',
|
|
@@ -818,6 +929,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
818
929
|
|
|
819
930
|
// Update statistics
|
|
820
931
|
await emitSafely(this, 'statsUpdated', this.stats);
|
|
932
|
+
this.queue.wake();
|
|
821
933
|
}
|
|
822
934
|
}
|
|
823
935
|
|
|
@@ -866,7 +978,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
866
978
|
const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
|
|
867
979
|
|
|
868
980
|
// Send via Rust SMTP client
|
|
869
|
-
const result = await this.emailServer
|
|
981
|
+
const result = await this.emailServer[kSendOutboundEmailInternal](
|
|
870
982
|
targetServer,
|
|
871
983
|
targetPort,
|
|
872
984
|
this.createEmailForRecipients(email, recipients),
|
|
@@ -1024,6 +1136,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1024
1136
|
logger.log('info', `MTA delivery for item ${item.id}`);
|
|
1025
1137
|
|
|
1026
1138
|
const email = item.processingResult as Email;
|
|
1139
|
+
const rawEnvelope = item.messageFormat === 'raw-rfc822' ? item.smtpEnvelope : undefined;
|
|
1140
|
+
if (item.messageFormat === 'raw-rfc822' && !rawEnvelope) {
|
|
1141
|
+
throw new SmtpDeliveryError({
|
|
1142
|
+
message: `Raw queue item ${item.id} is missing its SMTP envelope metadata`,
|
|
1143
|
+
errorType: 'raw_queue_integrity',
|
|
1144
|
+
retryable: false,
|
|
1145
|
+
phase: 'message_body',
|
|
1146
|
+
transactionLog: [],
|
|
1147
|
+
recipients: [],
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
const allRecipients = rawEnvelope?.to || email.getAllRecipients();
|
|
1027
1151
|
|
|
1028
1152
|
// Structural invariant: `deliver` actions are terminal local deliveries
|
|
1029
1153
|
// and must never reach outbound MX relay (and thus never the self-MX
|
|
@@ -1037,7 +1161,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1037
1161
|
retryable: false,
|
|
1038
1162
|
phase: 'connect',
|
|
1039
1163
|
transactionLog: [],
|
|
1040
|
-
recipients:
|
|
1164
|
+
recipients: allRecipients,
|
|
1041
1165
|
});
|
|
1042
1166
|
}
|
|
1043
1167
|
|
|
@@ -1045,16 +1169,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1045
1169
|
throw new Error('No email server available for MTA delivery');
|
|
1046
1170
|
}
|
|
1047
1171
|
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1172
|
+
// Raw queue delivery always resolves the current sender-domain selector and
|
|
1173
|
+
// key at final transmit. The unsigned queue bytes never change.
|
|
1174
|
+
let dkimDomain: string | undefined;
|
|
1175
|
+
let dkimSelector: string;
|
|
1176
|
+
if (rawEnvelope) {
|
|
1177
|
+
dkimDomain = rawEnvelope.from.split('@')[1]?.toLowerCase();
|
|
1178
|
+
const domainConfig = dkimDomain
|
|
1179
|
+
? this.emailServer.getDomainRegistry().getDomainConfig(dkimDomain)
|
|
1180
|
+
: undefined;
|
|
1181
|
+
if (!dkimDomain || !domainConfig?.dkim?.selector) {
|
|
1182
|
+
throw new SmtpDeliveryError({
|
|
1183
|
+
message: `Raw outbound DKIM is unavailable for sender domain ${dkimDomain || 'unknown'}`,
|
|
1184
|
+
errorType: 'dkim',
|
|
1185
|
+
retryable: false,
|
|
1186
|
+
phase: 'message_body',
|
|
1187
|
+
transactionLog: [],
|
|
1188
|
+
recipients: allRecipients,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
dkimSelector = domainConfig.dkim.selector;
|
|
1192
|
+
} else {
|
|
1193
|
+
const effectiveDkimSigning = Boolean(
|
|
1194
|
+
item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim,
|
|
1195
|
+
);
|
|
1196
|
+
dkimDomain = effectiveDkimSigning
|
|
1197
|
+
? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
|
|
1198
|
+
: undefined;
|
|
1199
|
+
dkimSelector = this.resolveDkimSelector(item, dkimDomain);
|
|
1200
|
+
}
|
|
1056
1201
|
|
|
1057
|
-
const allRecipients = email.getAllRecipients();
|
|
1058
1202
|
if (allRecipients.length === 0) {
|
|
1059
1203
|
throw new Error('No recipients specified for MTA delivery');
|
|
1060
1204
|
}
|
|
@@ -1151,13 +1295,23 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1151
1295
|
try {
|
|
1152
1296
|
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
|
|
1153
1297
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1298
|
+
const result = rawEnvelope
|
|
1299
|
+
? await this.queue[kQueueWithRawMessage](
|
|
1300
|
+
item.id,
|
|
1301
|
+
(rawMessage) => this.emailServer,
|
|
1308
|
+
)
|
|
1309
|
+
: await this.emailServer[kSendOutboundEmailInternal](
|
|
1310
|
+
mx.exchange,
|
|
1311
|
+
25,
|
|
1312
|
+
this.createEmailForRecipients(email, recipientsForMx),
|
|
1313
|
+
{ dkimDomain, dkimSelector },
|
|
1314
|
+
);
|
|
1161
1315
|
|
|
1162
1316
|
const disposition = this.splitRecipientResults(
|
|
1163
1317
|
result.recipientResults,
|
|
@@ -1403,7 +1557,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1403
1557
|
this.rateLimitCounter = 0;
|
|
1404
1558
|
this.throttled = false;
|
|
1405
1559
|
this.stats.rateLimiting.currentRate = 0;
|
|
1406
|
-
return false;
|
|
1407
1560
|
}
|
|
1408
1561
|
|
|
1409
1562
|
// Check if we're already throttled
|
|
@@ -1411,15 +1564,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1411
1564
|
return true;
|
|
1412
1565
|
}
|
|
1413
1566
|
|
|
1414
|
-
//
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
// Calculate current rate (emails per minute)
|
|
1418
|
-
const rate = (this.rateLimitCounter / elapsed) * 60000;
|
|
1419
|
-
this.stats.rateLimiting.currentRate = rate;
|
|
1420
|
-
|
|
1421
|
-
// Check if rate limit is exceeded
|
|
1422
|
-
if (rate > this.options.globalRateLimit) {
|
|
1567
|
+
// Use a fixed one-minute window. Extrapolating the first delivery from a
|
|
1568
|
+
// few elapsed milliseconds falsely throttles every freshly started queue.
|
|
1569
|
+
if (this.rateLimitCounter >= this.options.globalRateLimit) {
|
|
1423
1570
|
this.throttled = true;
|
|
1424
1571
|
this.stats.rateLimiting.throttled++;
|
|
1425
1572
|
|
|
@@ -1434,10 +1581,16 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1434
1581
|
this.rateLimitLastCheck = Date.now();
|
|
1435
1582
|
this.rateLimitCounter = 0;
|
|
1436
1583
|
this.stats.rateLimiting.currentRate = 0;
|
|
1584
|
+
if (this.running) {
|
|
1585
|
+
this.queue.wake();
|
|
1586
|
+
}
|
|
1437
1587
|
}, resetDelay);
|
|
1438
1588
|
|
|
1439
1589
|
return true;
|
|
1440
1590
|
}
|
|
1591
|
+
|
|
1592
|
+
this.rateLimitCounter++;
|
|
1593
|
+
this.stats.rateLimiting.currentRate = this.rateLimitCounter;
|
|
1441
1594
|
|
|
1442
1595
|
return false;
|
|
1443
1596
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash, type Hash } from 'node:crypto';
|
|
2
|
+
import { domainToASCII } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { Email } from '../core/classes.email.js';
|
|
5
|
+
|
|
6
|
+
export interface IRawSmtpEnvelope {
|
|
7
|
+
from: string;
|
|
8
|
+
to: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const MAX_RAW_SMTP_RECIPIENTS = 100;
|
|
12
|
+
|
|
13
|
+
export const assertCanonicalRawRfc822Message = (rawMessageArg: Buffer): void => {
|
|
14
|
+
if (!Buffer.isBuffer(rawMessageArg) || rawMessageArg.length === 0) {
|
|
15
|
+
throw new Error('Raw outbound message must be a nonempty Buffer');
|
|
16
|
+
}
|
|
17
|
+
for (let index = 0; index < rawMessageArg.length; index++) {
|
|
18
|
+
const byte = rawMessageArg[index];
|
|
19
|
+
if (byte === 0x0d && rawMessageArg[index + 1] !== 0x0a) {
|
|
20
|
+
throw new Error('Raw outbound message must use canonical CRLF line endings');
|
|
21
|
+
}
|
|
22
|
+
if (byte === 0x0a && (index === 0 || rawMessageArg[index - 1] !== 0x0d)) {
|
|
23
|
+
throw new Error('Raw outbound message must use canonical CRLF line endings');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (
|
|
27
|
+
rawMessageArg.length < 2
|
|
28
|
+
|| rawMessageArg[rawMessageArg.length - 2] !== 0x0d
|
|
29
|
+
|| rawMessageArg[rawMessageArg.length - 1] !== 0x0a
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('Raw outbound message must end with CRLF');
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const writeLengthFramed = (hashArg: Hash, labelArg: string, valueArg: Buffer): void => {
|
|
36
|
+
const label = Buffer.from(labelArg, 'utf8');
|
|
37
|
+
const labelLength = Buffer.allocUnsafe(4);
|
|
38
|
+
labelLength.writeUInt32BE(label.length);
|
|
39
|
+
const valueLength = Buffer.allocUnsafe(8);
|
|
40
|
+
valueLength.writeBigUInt64BE(BigInt(valueArg.length));
|
|
41
|
+
hashArg.update(labelLength).update(label).update(valueLength).update(valueArg);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const normalizeRawMailbox = (valueArg: string, fieldArg: string): string => {
|
|
45
|
+
if (typeof valueArg !== 'string' || /[\r\n<>]/.test(valueArg)) {
|
|
46
|
+
throw new Error(`Raw SMTP envelope ${fieldArg} must be a plain mailbox address`);
|
|
47
|
+
}
|
|
48
|
+
const value = valueArg.trim();
|
|
49
|
+
const atIndex = value.indexOf('@');
|
|
50
|
+
if (atIndex < 1 || atIndex !== value.lastIndexOf('@') || atIndex === value.length - 1) {
|
|
51
|
+
throw new Error(`Raw SMTP envelope ${fieldArg} must be a plain mailbox address`);
|
|
52
|
+
}
|
|
53
|
+
const asciiDomain = domainToASCII(value.slice(atIndex + 1));
|
|
54
|
+
if (!asciiDomain) {
|
|
55
|
+
throw new Error(`Raw SMTP envelope ${fieldArg} has an invalid domain`);
|
|
56
|
+
}
|
|
57
|
+
const normalized = `${value.slice(0, atIndex).toLowerCase()}@${asciiDomain.toLowerCase()}`;
|
|
58
|
+
try {
|
|
59
|
+
new Email({ from: normalized, to: normalized, subject: '', text: '' });
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`Raw SMTP envelope ${fieldArg} must be a valid mailbox address`);
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const normalizeRawSmtpEnvelope = (envelopeArg: IRawSmtpEnvelope): IRawSmtpEnvelope => {
|
|
67
|
+
if (!envelopeArg || typeof envelopeArg !== 'object') {
|
|
68
|
+
throw new Error('Raw SMTP envelope is required');
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
!Array.isArray(envelopeArg.to)
|
|
72
|
+
|| envelopeArg.to.length === 0
|
|
73
|
+
|| envelopeArg.to.length > MAX_RAW_SMTP_RECIPIENTS
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Raw SMTP envelope requires 1 to ${MAX_RAW_SMTP_RECIPIENTS} recipients`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const from = normalizeRawMailbox(envelopeArg.from, 'sender');
|
|
80
|
+
const to = envelopeArg.to.map((recipient, index) => (
|
|
81
|
+
normalizeRawMailbox(recipient, `recipient ${index + 1}`)
|
|
82
|
+
));
|
|
83
|
+
if (new Set(to).size !== to.length) {
|
|
84
|
+
throw new Error('Raw SMTP envelope recipients must be unique after canonicalization');
|
|
85
|
+
}
|
|
86
|
+
return { from, to };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const cloneRawSmtpEnvelope = (envelopeArg: IRawSmtpEnvelope): IRawSmtpEnvelope => ({
|
|
90
|
+
from: envelopeArg.from,
|
|
91
|
+
to: [...envelopeArg.to],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const createRawQueueFingerprint = (
|
|
95
|
+
rawMessageArg: Buffer,
|
|
96
|
+
envelopeArg: IRawSmtpEnvelope,
|
|
97
|
+
): string => {
|
|
98
|
+
const hash = createHash('sha256');
|
|
99
|
+
hash.update('smartmta:durable-raw-outbound-queue:fingerprint:v1\0');
|
|
100
|
+
writeLengthFramed(hash, 'raw-rfc822-bytes', rawMessageArg);
|
|
101
|
+
writeLengthFramed(hash, 'smtp-envelope-from', Buffer.from(envelopeArg.from, 'utf8'));
|
|
102
|
+
for (const recipient of envelopeArg.to) {
|
|
103
|
+
writeLengthFramed(hash, 'smtp-envelope-recipient', Buffer.from(recipient, 'utf8'));
|
|
104
|
+
}
|
|
105
|
+
writeLengthFramed(
|
|
106
|
+
hash,
|
|
107
|
+
'processing-semantics',
|
|
108
|
+
Buffer.from('raw-mta;dkim=required-current-sender-domain-key;transform=none', 'utf8'),
|
|
109
|
+
);
|
|
110
|
+
return hash.digest('hex');
|
|
111
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const kQueueCloseAdmission: unique symbol = Symbol('smartmta.queue.closeAdmission');
|
|
2
|
+
export const kQueueCreateItemSnapshot: unique symbol = Symbol('smartmta.queue.createItemSnapshot');
|
|
3
|
+
export const kQueueEnqueueRaw: unique symbol = Symbol('smartmta.queue.enqueueRaw');
|
|
4
|
+
export const kQueueReserveRawAccess: unique symbol = Symbol('smartmta.queue.reserveRawAccess');
|
|
5
|
+
export const kQueueSetReadyItemsHandler: unique symbol = Symbol('smartmta.queue.setReadyItemsHandler');
|
|
6
|
+
export const kQueueWithRawMessage: unique symbol = Symbol('smartmta.queue.withRawMessage');
|