@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.
- package/deno.json +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +15 -0
- package/dist_ts/email/classes.accepted-email-spool.js +102 -23
- package/dist_ts/email/classes.smartmta-blob-storage-manager.js +12 -3
- package/dist_ts_migrations/index.js +84 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/email/classes.accepted-email-spool.ts +98 -21
- package/ts/email/classes.smartmta-blob-storage-manager.ts +10 -2
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@serve.zone/dcrouter',
|
|
6
|
-
version: '18.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
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
|
735
|
+
for (const queuedEmail of recoverableEmails) {
|
|
712
736
|
if (this.isCachedEmailTerminal(queuedEmail)) {
|
|
713
737
|
continue;
|
|
714
738
|
}
|
|
715
|
-
queuedEmail.status
|
|
716
|
-
queuedEmail.nextAttempt
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|