@serve.zone/dcrouter 18.0.0 → 18.0.2

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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.0.0',
6
+ version: '18.0.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "18.0.0",
4
+ "version": "18.0.2",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.0.0',
6
+ version: '18.0.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -77,6 +77,19 @@ const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
77
77
  /** Retention for catch-all stored inbound mail (30 days). */
78
78
  const INBOUND_STORE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
79
79
 
80
+ /**
81
+ * Permanent per-email storage failure: the raw RFC822 payload of an accepted
82
+ * email is unrecoverable, so delivery can never succeed. The spool marks such
83
+ * rows failed instead of retrying them, so one lost blob cannot wedge the
84
+ * whole queue.
85
+ */
86
+ export class AcceptedEmailRawMessageMissingError extends Error {
87
+ constructor(messageArg: string) {
88
+ super(messageArg);
89
+ this.name = 'AcceptedEmailRawMessageMissingError';
90
+ }
91
+ }
92
+
80
93
  export type TSmartMtaQueueItemLike = {
81
94
  id?: string;
82
95
  processingResult?: {
@@ -690,32 +703,55 @@ export class AcceptedEmailSpool {
690
703
  for (const item of (emailServerArg as TSmartMtaQueueReader).getQueueItems?.() || []) {
691
704
  const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
692
705
  if (!cachedEmailId || !item.smtpTransactions?.length) continue;
693
- await this.runCachedEmailUpdate(cachedEmailId, async () => {
694
- const cachedEmail = await CachedEmail.findById(cachedEmailId);
695
- if (!cachedEmail) return;
696
- for (const transaction of item.smtpTransactions || []) {
697
- this.appendSmtpTransaction(cachedEmail, transaction);
698
- }
699
- await cachedEmail.save();
700
- });
706
+ try {
707
+ await this.runCachedEmailUpdate(cachedEmailId, async () => {
708
+ const cachedEmail = await CachedEmail.findById(cachedEmailId);
709
+ if (!cachedEmail) return;
710
+ for (const transaction of item.smtpTransactions || []) {
711
+ this.appendSmtpTransaction(cachedEmail, transaction);
712
+ }
713
+ await cachedEmail.save();
714
+ });
715
+ } catch (error: unknown) {
716
+ // History reconciliation runs during email-server startup; one row that
717
+ // cannot be persisted must not abort reconciliation or startup.
718
+ logger.log('warn', `Unable to reconcile SMTP transaction history for accepted email ${cachedEmailId}: ${(error as Error).message}`);
719
+ }
701
720
  }
702
721
  }
703
722
 
704
723
  /** Requeue emails left in 'queued' state by a previous process as pending. */
705
724
  public async recoverQueuedEmails(): Promise<void> {
725
+ // Rows whose recovery save failed stay 'queued' and are re-served by
726
+ // findQueuedForRecovery; track them so they are neither retried in a loop
727
+ // within this run nor able to wedge recovery when they fill a whole batch.
728
+ const unrecoverableIds = new Set<string>();
706
729
  while (true) {
707
730
  const queuedEmails = await CachedEmail.findQueuedForRecovery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
708
- if (queuedEmails.length === 0) {
731
+ const recoverableEmails = queuedEmails.filter((queuedEmail) => !unrecoverableIds.has(queuedEmail.id));
732
+ if (recoverableEmails.length === 0) {
709
733
  return;
710
734
  }
711
- for (const queuedEmail of queuedEmails) {
735
+ for (const queuedEmail of recoverableEmails) {
712
736
  if (this.isCachedEmailTerminal(queuedEmail)) {
713
737
  continue;
714
738
  }
715
- queuedEmail.status = 'pending';
716
- queuedEmail.nextAttempt = new Date();
717
- await queuedEmail.save();
718
- await this.notifyEmailQueuePersisted(queuedEmail, 'email-queue-recovered');
739
+ const previousStatus = queuedEmail.status;
740
+ const previousNextAttempt = queuedEmail.nextAttempt;
741
+ try {
742
+ queuedEmail.status = 'pending';
743
+ queuedEmail.nextAttempt = new Date();
744
+ await queuedEmail.save();
745
+ await this.notifyEmailQueuePersisted(queuedEmail, 'email-queue-recovered');
746
+ } catch (error: unknown) {
747
+ // Recovery runs during email-server startup; a row that cannot be
748
+ // persisted stays 'queued' for the next startup and must not abort
749
+ // recovery of the remaining rows.
750
+ queuedEmail.status = previousStatus;
751
+ queuedEmail.nextAttempt = previousNextAttempt;
752
+ unrecoverableIds.add(queuedEmail.id);
753
+ logger.log('warn', `Unable to recover queued accepted email ${queuedEmail.id}: ${(error as Error).message}`);
754
+ }
719
755
  }
720
756
  if (queuedEmails.length < ACCEPTED_EMAIL_SPOOL_BATCH_SIZE) {
721
757
  return;
@@ -735,18 +771,54 @@ export class AcceptedEmailSpool {
735
771
  if (this.stopping || this.dcRouterRef.emailServer !== emailServer) {
736
772
  break;
737
773
  }
738
- if (await this.postponeLiveSmartMtaOwnedEmail(cachedEmail, emailServer)) {
739
- continue;
774
+ try {
775
+ if (await this.postponeLiveSmartMtaOwnedEmail(cachedEmail, emailServer)) {
776
+ continue;
777
+ }
778
+ const session = this.buildCachedEmailSession(cachedEmail);
779
+ const rawMessage = await this.readRawMessage(cachedEmail);
780
+ await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
781
+ } catch (error: unknown) {
782
+ // One poisoned row must never stall the rest of the batch: record the
783
+ // failure on that row alone and keep processing the remaining emails.
784
+ await this.handleSpoolItemFailure(cachedEmail, error);
740
785
  }
741
- const session = this.buildCachedEmailSession(cachedEmail);
742
- const rawMessage = await this.readRawMessage(cachedEmail);
743
- await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
744
786
  }
745
787
  } finally {
746
788
  this.processing = false;
747
789
  }
748
790
  }
749
791
 
792
+ /**
793
+ * Record a per-email spool failure without aborting the batch: permanent
794
+ * raw-message losses are marked failed, everything else is deferred through
795
+ * the regular retry mechanism (which terminates at maxAttempts).
796
+ */
797
+ private async handleSpoolItemFailure(cachedEmail: CachedEmail, errorArg: unknown): Promise<void> {
798
+ const message = (errorArg as Error).message;
799
+ try {
800
+ const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
801
+ if (this.isCachedEmailTerminal(currentCachedEmail)) {
802
+ return;
803
+ }
804
+ if (errorArg instanceof AcceptedEmailRawMessageMissingError) {
805
+ currentCachedEmail.markFailed(message);
806
+ await currentCachedEmail.save();
807
+ await this.notifyEmailQueuePersisted(currentCachedEmail, 'email-raw-message-missing');
808
+ logger.log('error', `Accepted email ${currentCachedEmail.id} failed permanently: ${message}`);
809
+ return;
810
+ }
811
+ currentCachedEmail.scheduleRetry(ACCEPTED_EMAIL_RETRY_DELAY_MS);
812
+ currentCachedEmail.lastError = message;
813
+ await currentCachedEmail.save();
814
+ await this.notifyEmailQueuePersisted(currentCachedEmail, 'email-spool-deferred');
815
+ logger.log('warn', `Accepted email ${currentCachedEmail.id} deferred after spool processing failure: ${message}`);
816
+ } catch (persistError: unknown) {
817
+ // Even failing to persist the failure state must not abort the batch.
818
+ logger.log('error', `Unable to persist spool failure state for accepted email ${cachedEmail.id}: ${(persistError as Error).message} (original error: ${message})`);
819
+ }
820
+ }
821
+
750
822
  private async processAcceptedCachedEmail(
751
823
  cachedEmail: CachedEmail,
752
824
  emailData: Email | plugins.buffer.Buffer,
@@ -1010,11 +1082,16 @@ export class AcceptedEmailSpool {
1010
1082
  }
1011
1083
  const rawMessage = await blobStorage.get(cachedEmailArg.rawContentObjectKey);
1012
1084
  if (!rawMessage) {
1013
- throw new Error(`Raw RFC822 object is missing for accepted email ${cachedEmailArg.id}`);
1085
+ throw new AcceptedEmailRawMessageMissingError(`Raw RFC822 object is missing for accepted email ${cachedEmailArg.id}`);
1014
1086
  }
1015
1087
  return plugins.buffer.Buffer.from(rawMessage);
1016
1088
  }
1017
- throw new Error(`Accepted email ${cachedEmailArg.id} has no raw RFC822 payload`);
1089
+ if (cachedEmailArg.rawContent) {
1090
+ // Legacy rows persisted before SmartBucket-backed storage keep their
1091
+ // payload inline; serve it rather than declaring the payload lost.
1092
+ return plugins.buffer.Buffer.from(cachedEmailArg.rawContent, 'utf8');
1093
+ }
1094
+ throw new AcceptedEmailRawMessageMissingError(`Accepted email ${cachedEmailArg.id} has no raw RFC822 payload`);
1018
1095
  }
1019
1096
 
1020
1097
  public async deleteRawMessage(cachedEmailArg: CachedEmail): Promise<void> {
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
2
3
  import type { IBlobStorageManager } from '@push.rocks/smartmta';
3
4
 
4
5
  const SMARTMTA_BLOB_NAMESPACES = [
@@ -119,11 +120,18 @@ export class SmartMtaBlobStorageManager implements IBlobStorageManager {
119
120
  const objectPrefix = this.toObjectKey(prefix);
120
121
  const keys: string[] = [];
121
122
  for await (const objectKey of this.bucketRef.listAllObjects(objectPrefix)) {
123
+ // One stray or malformed object under the prefix must not abort the
124
+ // whole listing (SmartMTA queue recovery lists at startup); skip it.
122
125
  if (!objectKey.startsWith(this.objectPrefix)) {
123
- throw new Error(`SmartBucket returned an object outside the configured prefix: ${objectKey}`);
126
+ logger.log('warn', `Skipping SmartBucket object outside the configured prefix during SmartMTA blob listing: ${objectKey}`);
127
+ continue;
124
128
  }
125
129
  const key = `/${objectKey.slice(this.objectPrefix.length)}`;
126
- keys.push(normalizeBlobKey(key));
130
+ try {
131
+ keys.push(normalizeBlobKey(key));
132
+ } catch (error: unknown) {
133
+ logger.log('warn', `Skipping unnormalizable SmartBucket object key during SmartMTA blob listing: ${objectKey} (${(error as Error).message})`);
134
+ }
127
135
  }
128
136
  return keys.sort();
129
137
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.0.0',
6
+ version: '18.0.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }