@push.rocks/smartmta 9.4.1 → 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 +14 -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 +63 -2
- package/dist_ts/mail/delivery/classes.delivery.queue.js +749 -193
- package/dist_ts/mail/delivery/classes.delivery.system.d.ts +6 -0
- package/dist_ts/mail/delivery/classes.delivery.system.js +146 -62
- 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 +34 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +923 -226
- package/ts/mail/delivery/classes.delivery.system.ts +192 -80
- 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,8 +157,20 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
148
157
|
private emailServer?: UnifiedEmailServer;
|
|
149
158
|
private bounceDrainTimer?: NodeJS.Timeout;
|
|
150
159
|
private bounceDrainPromise?: Promise<void>;
|
|
151
|
-
private readonly
|
|
152
|
-
|
|
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
|
+
};
|
|
153
174
|
private queueListenerRegistered = false;
|
|
154
175
|
private lifecycleGeneration = 0;
|
|
155
176
|
private pendingSmtpAuthenticationUpdates = new Map<string, {
|
|
@@ -233,16 +254,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
233
254
|
|
|
234
255
|
private registerQueueListener(): void {
|
|
235
256
|
if (this.queueListenerRegistered) return;
|
|
236
|
-
this.queue
|
|
257
|
+
this.queue[kQueueSetReadyItemsHandler](this.queueItemsReadyHandler);
|
|
237
258
|
this.queueListenerRegistered = true;
|
|
238
259
|
}
|
|
239
260
|
|
|
240
261
|
private unregisterQueueListener(): void {
|
|
241
262
|
if (!this.queueListenerRegistered) return;
|
|
242
|
-
this.queue
|
|
263
|
+
this.queue[kQueueSetReadyItemsHandler](undefined);
|
|
243
264
|
this.queueListenerRegistered = false;
|
|
244
265
|
}
|
|
245
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
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
246
287
|
private createEmailForRecipients(email: Email, recipients: string[]): Email {
|
|
247
288
|
const recipientSet = new Set(recipients.map((recipient) => recipient.toLowerCase()));
|
|
248
289
|
return new Email({
|
|
@@ -368,20 +409,27 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
368
409
|
}
|
|
369
410
|
await this.persistSmtpAuthenticationUpdate(update);
|
|
370
411
|
};
|
|
371
|
-
void
|
|
372
|
-
|
|
373
|
-
(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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);
|
|
385
433
|
}
|
|
386
434
|
return attempt;
|
|
387
435
|
}
|
|
@@ -556,7 +604,19 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
556
604
|
/**
|
|
557
605
|
* Stop the delivery system
|
|
558
606
|
*/
|
|
559
|
-
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> {
|
|
560
620
|
logger.log('info', 'Stopping MultiModeDeliverySystem');
|
|
561
621
|
|
|
562
622
|
if (!this.running) {
|
|
@@ -584,26 +644,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
584
644
|
this.rateLimitLastCheck = Date.now();
|
|
585
645
|
this.stats.rateLimiting.currentRate = 0;
|
|
586
646
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
}, 1000);
|
|
600
|
-
|
|
601
|
-
// Force resolve after 30 seconds
|
|
602
|
-
const forceTimeout = setTimeout(() => {
|
|
603
|
-
clearInterval(checkInterval);
|
|
604
|
-
resolve();
|
|
605
|
-
}, 30000);
|
|
606
|
-
});
|
|
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]);
|
|
607
658
|
}
|
|
608
659
|
|
|
609
660
|
await this.bounceDrainPromise;
|
|
@@ -617,7 +668,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
617
668
|
* Process ready items from the queue
|
|
618
669
|
* @param items Queue items ready for processing
|
|
619
670
|
*/
|
|
620
|
-
private async processItems(
|
|
671
|
+
private async processItems(itemIds: string[]): Promise<void> {
|
|
621
672
|
if (!this.running) {
|
|
622
673
|
return;
|
|
623
674
|
}
|
|
@@ -630,9 +681,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
630
681
|
|
|
631
682
|
let startedDeliveries = 0;
|
|
632
683
|
|
|
633
|
-
for (const
|
|
684
|
+
for (const itemId of itemIds) {
|
|
634
685
|
if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
|
|
635
|
-
if (this.activeDeliveries.has(
|
|
686
|
+
if (this.activeDeliveries.has(itemId)) continue;
|
|
636
687
|
if (this.checkRateLimit()) {
|
|
637
688
|
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
|
638
689
|
break;
|
|
@@ -640,30 +691,43 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
640
691
|
|
|
641
692
|
// Reserve synchronously before the first await so overlapping queue events
|
|
642
693
|
// cannot dispatch the same item while its durable claim is in flight.
|
|
643
|
-
this.activeDeliveries.add(
|
|
694
|
+
this.activeDeliveries.add(itemId);
|
|
644
695
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
645
696
|
|
|
646
697
|
let claimed = false;
|
|
647
698
|
try {
|
|
648
|
-
claimed = await this.queue.markProcessing(
|
|
699
|
+
claimed = await this.queue.markProcessing(itemId);
|
|
649
700
|
} catch (error) {
|
|
650
|
-
this.activeDeliveries.delete(
|
|
701
|
+
this.activeDeliveries.delete(itemId);
|
|
651
702
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
652
|
-
logger.log('error', `Failed to claim queue item ${
|
|
703
|
+
logger.log('error', `Failed to claim queue item ${itemId}: ${(error as Error).message}`);
|
|
653
704
|
continue;
|
|
654
705
|
}
|
|
655
706
|
|
|
656
707
|
if (!claimed) {
|
|
657
|
-
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);
|
|
658
716
|
this.stats.activeDeliveries = this.activeDeliveries.size;
|
|
659
717
|
continue;
|
|
660
718
|
}
|
|
661
719
|
|
|
662
720
|
startedDeliveries++;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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);
|
|
667
731
|
}
|
|
668
732
|
|
|
669
733
|
if (startedDeliveries > 0) {
|
|
@@ -683,10 +747,14 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
683
747
|
|
|
684
748
|
try {
|
|
685
749
|
// Call delivery start hook
|
|
686
|
-
await callObserverSafely(
|
|
750
|
+
await callObserverSafely(
|
|
751
|
+
'delivery start hook',
|
|
752
|
+
this.options.onDeliveryStart,
|
|
753
|
+
this.queue[kQueueCreateItemSnapshot](item),
|
|
754
|
+
);
|
|
687
755
|
|
|
688
756
|
// Emit delivery start event
|
|
689
|
-
await
|
|
757
|
+
await this.emitItemEventSafely('deliveryStart', item);
|
|
690
758
|
logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
|
|
691
759
|
|
|
692
760
|
// Choose the appropriate handler based on mode
|
|
@@ -694,15 +762,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
694
762
|
|
|
695
763
|
switch (item.processingMode) {
|
|
696
764
|
case 'forward':
|
|
697
|
-
result = await this.options.forwardHandler.deliver(item);
|
|
765
|
+
result = await this.options.forwardHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
|
|
698
766
|
break;
|
|
699
767
|
|
|
700
768
|
case 'mta':
|
|
701
|
-
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));
|
|
702
772
|
break;
|
|
703
773
|
|
|
704
774
|
case 'process':
|
|
705
|
-
result = await this.options.processHandler.deliver(item);
|
|
775
|
+
result = await this.options.processHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
|
|
706
776
|
break;
|
|
707
777
|
|
|
708
778
|
default:
|
|
@@ -752,11 +822,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
752
822
|
await callObserverSafely(
|
|
753
823
|
'delivery success hook',
|
|
754
824
|
this.options.onDeliverySuccess,
|
|
755
|
-
item,
|
|
825
|
+
this.queue[kQueueCreateItemSnapshot](item),
|
|
756
826
|
result,
|
|
757
827
|
);
|
|
758
828
|
|
|
759
|
-
await
|
|
829
|
+
await this.emitItemEventSafely('deliverySuccess', item, result);
|
|
760
830
|
logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
|
|
761
831
|
|
|
762
832
|
await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
|
|
@@ -816,17 +886,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
816
886
|
await callObserverSafely(
|
|
817
887
|
'delivery failure hook',
|
|
818
888
|
this.options.onDeliveryFailed,
|
|
819
|
-
item,
|
|
889
|
+
this.queue[kQueueCreateItemSnapshot](item),
|
|
820
890
|
deliveryError.message,
|
|
821
891
|
);
|
|
822
892
|
}
|
|
823
893
|
|
|
824
894
|
// Emit delivery failed event
|
|
825
895
|
if (transition.terminal) {
|
|
826
|
-
await
|
|
827
|
-
await
|
|
896
|
+
await this.emitItemEventSafely('deliveryFailed', item, deliveryError);
|
|
897
|
+
await this.emitItemEventSafely('deliveryTerminalFailure', item, deliveryError, transition);
|
|
828
898
|
} else {
|
|
829
|
-
await
|
|
899
|
+
await this.emitItemEventSafely('deliveryDeferred', item, deliveryError, transition);
|
|
830
900
|
}
|
|
831
901
|
logger.log(
|
|
832
902
|
transition.terminal ? 'error' : 'warn',
|
|
@@ -908,7 +978,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
908
978
|
const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
|
|
909
979
|
|
|
910
980
|
// Send via Rust SMTP client
|
|
911
|
-
const result = await this.emailServer
|
|
981
|
+
const result = await this.emailServer[kSendOutboundEmailInternal](
|
|
912
982
|
targetServer,
|
|
913
983
|
targetPort,
|
|
914
984
|
this.createEmailForRecipients(email, recipients),
|
|
@@ -1066,6 +1136,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1066
1136
|
logger.log('info', `MTA delivery for item ${item.id}`);
|
|
1067
1137
|
|
|
1068
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();
|
|
1069
1151
|
|
|
1070
1152
|
// Structural invariant: `deliver` actions are terminal local deliveries
|
|
1071
1153
|
// and must never reach outbound MX relay (and thus never the self-MX
|
|
@@ -1079,7 +1161,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1079
1161
|
retryable: false,
|
|
1080
1162
|
phase: 'connect',
|
|
1081
1163
|
transactionLog: [],
|
|
1082
|
-
recipients:
|
|
1164
|
+
recipients: allRecipients,
|
|
1083
1165
|
});
|
|
1084
1166
|
}
|
|
1085
1167
|
|
|
@@ -1087,16 +1169,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1087
1169
|
throw new Error('No email server available for MTA delivery');
|
|
1088
1170
|
}
|
|
1089
1171
|
|
|
1090
|
-
//
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
+
}
|
|
1098
1201
|
|
|
1099
|
-
const allRecipients = email.getAllRecipients();
|
|
1100
1202
|
if (allRecipients.length === 0) {
|
|
1101
1203
|
throw new Error('No recipients specified for MTA delivery');
|
|
1102
1204
|
}
|
|
@@ -1193,13 +1295,23 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|
|
1193
1295
|
try {
|
|
1194
1296
|
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
|
|
1195
1297
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
+
);
|
|
1203
1315
|
|
|
1204
1316
|
const disposition = this.splitRecipientResults(
|
|
1205
1317
|
result.recipientResults,
|
|
@@ -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');
|
package/ts/mail/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './interfaces.storage.js';
|
|
|
3
3
|
export * from './routing/index.js';
|
|
4
4
|
export * from './security/index.js';
|
|
5
5
|
export type { ISmtpTransactionAttempt } from './delivery/interfaces.js';
|
|
6
|
+
export type { IRawSmtpEnvelope } from './delivery/functions.raw-email.js';
|
|
6
7
|
|
|
7
8
|
// Make the core and delivery modules accessible
|
|
8
9
|
import * as Core from './core/index.js';
|