@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.
Files changed (35) hide show
  1. package/changelog.md +23 -0
  2. package/dist_rust/mailer-bin_linux_amd64 +0 -0
  3. package/dist_rust/mailer-bin_linux_arm64 +0 -0
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/index.d.ts +1 -0
  6. package/dist_ts/index.js +2 -1
  7. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +71 -1
  8. package/dist_ts/mail/delivery/classes.delivery.queue.js +794 -179
  9. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +11 -0
  10. package/dist_ts/mail/delivery/classes.delivery.system.js +206 -80
  11. package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
  12. package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
  13. package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
  14. package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
  15. package/dist_ts/mail/index.d.ts +1 -0
  16. package/dist_ts/mail/index.js +1 -1
  17. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +40 -0
  18. package/dist_ts/mail/routing/classes.unified.email.server.js +222 -76
  19. package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
  20. package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
  21. package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
  22. package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
  23. package/package.json +2 -2
  24. package/readme.hints.md +8 -0
  25. package/readme.md +36 -3
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/index.ts +1 -0
  28. package/ts/mail/delivery/classes.delivery.queue.ts +969 -209
  29. package/ts/mail/delivery/classes.delivery.system.ts +254 -101
  30. package/ts/mail/delivery/functions.raw-email.ts +111 -0
  31. package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
  32. package/ts/mail/index.ts +1 -0
  33. package/ts/mail/routing/classes.unified.email.server.ts +329 -85
  34. package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
  35. 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
- // Set up event listeners
229
- this.queue.on('itemsReady', this.processItems.bind(this));
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 authenticationHandle.result.then(
358
- persistAuthentication,
359
- (failureArg) => persistAuthentication({
360
- state: 'indeterminate',
361
- overall: 'indeterminate',
362
- checkedAt: new Date().toISOString(),
363
- error: String(failureArg instanceof Error ? failureArg.message : failureArg).slice(0, 512),
364
- }),
365
- ).catch((error) => {
366
- logger.log(
367
- 'warn',
368
- `Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
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
- await this.drainBounceOutbox();
515
- this.bounceDrainTimer = setInterval(() => {
516
- void this.drainBounceOutbox();
517
- }, 30_000);
518
-
519
- await emitSafely(this, 'started');
520
- logger.log('info', 'MultiModeDeliverySystem started successfully');
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 async stop(): Promise<void> {
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
- // Wait for active deliveries to complete
547
- if (this.activeDeliveries.size > 0) {
548
- logger.log('info', `Waiting for ${this.activeDeliveries.size} active deliveries to complete`);
549
-
550
- // Wait for a maximum of 30 seconds
551
- await new Promise<void>(resolve => {
552
- const checkInterval = setInterval(() => {
553
- if (this.activeDeliveries.size === 0) {
554
- clearInterval(checkInterval);
555
- clearTimeout(forceTimeout);
556
- resolve();
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(items: IQueueItem[]): Promise<void> {
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 item of items) {
684
+ for (const itemId of itemIds) {
597
685
  if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
598
- if (this.activeDeliveries.has(item.id)) continue;
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(item.id);
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(item.id);
699
+ claimed = await this.queue.markProcessing(itemId);
608
700
  } catch (error) {
609
- this.activeDeliveries.delete(item.id);
701
+ this.activeDeliveries.delete(itemId);
610
702
  this.stats.activeDeliveries = this.activeDeliveries.size;
611
- logger.log('error', `Failed to claim queue item ${item.id}: ${(error as Error).message}`);
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(item.id);
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
- // Deliver asynchronously
623
- this.deliverItem(item).catch(err => {
624
- logger.log('error', `Unhandled error in delivery: ${err.message}`);
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('delivery start hook', this.options.onDeliveryStart, item);
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 emitSafely(this, 'deliveryStart', item);
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 = await this.options.deliveryHandler.deliver(item);
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 emitSafely(this, 'deliverySuccess', item, result);
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 emitSafely(this, 'deliveryFailed', item, deliveryError);
786
- await emitSafely(this, 'deliveryTerminalFailure', item, deliveryError, transition);
896
+ await this.emitItemEventSafely('deliveryFailed', item, deliveryError);
897
+ await this.emitItemEventSafely('deliveryTerminalFailure', item, deliveryError, transition);
787
898
  } else {
788
- await emitSafely(this, 'deliveryDeferred', item, deliveryError, transition);
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.sendOutboundEmail(
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: email.getAllRecipients(),
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
- // Build DKIM options from route config
1049
- const effectiveDkimSigning = Boolean(
1050
- item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim,
1051
- );
1052
- const dkimDomain = effectiveDkimSigning
1053
- ? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
1054
- : undefined;
1055
- const dkimSelector = this.resolveDkimSelector(item, dkimDomain);
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
- // Create a temporary Email scoped to this domain's recipients
1155
- const domainEmail = this.createEmailForRecipients(email, recipientsForMx);
1156
-
1157
- const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
1158
- dkimDomain,
1159
- dkimSelector,
1160
- });
1298
+ const result = rawEnvelope
1299
+ ? await this.queue[kQueueWithRawMessage](
1300
+ item.id,
1301
+ (rawMessage) => this.emailServer![kSendRawOutboundEmailInternal](
1302
+ mx.exchange,
1303
+ 25,
1304
+ rawMessage,
1305
+ { from: rawEnvelope.from, to: recipientsForMx },
1306
+ { dkimDomain: dkimDomain!, dkimSelector },
1307
+ ),
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
- // Increment counter
1415
- this.rateLimitCounter++;
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');