@remit/smtp-worker 0.0.23 → 0.0.25
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/package.json
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SmtpConnectionError,
|
|
16
16
|
type sendMail,
|
|
17
17
|
} from "@remit/smtp-service";
|
|
18
|
+
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
18
19
|
import type { SendMessageEvent } from "../events.js";
|
|
19
20
|
import { writeEngagementCounters } from "./engagement-counters.js";
|
|
20
21
|
import {
|
|
@@ -63,6 +64,13 @@ export interface SendMessageDeps {
|
|
|
63
64
|
id: string,
|
|
64
65
|
status: OutboxMessageItem["status"],
|
|
65
66
|
) => Promise<unknown>;
|
|
67
|
+
/** Write the patch only while the row still holds `expected` (compare-and-set). */
|
|
68
|
+
updateOutboxIfStatus: (
|
|
69
|
+
accountConfigId: string,
|
|
70
|
+
id: string,
|
|
71
|
+
expected: OutboxMessageItem["status"],
|
|
72
|
+
patch: UpdateOutboxMessageInput,
|
|
73
|
+
) => Promise<unknown>;
|
|
66
74
|
markOutboxSent: (
|
|
67
75
|
accountConfigId: string,
|
|
68
76
|
id: string,
|
|
@@ -95,6 +103,14 @@ export interface SendMessageDeps {
|
|
|
95
103
|
const UNFILED_NOT_QUEUED =
|
|
96
104
|
"Sent, but not filed: the copy for the Sent folder could not be queued.";
|
|
97
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Every re-auth branch settles the row with this, because the row is the only
|
|
108
|
+
* place the user learns the send stopped: `blocked` shows its reason and offers
|
|
109
|
+
* no Retry, and a retry cannot succeed until the account is reconnected.
|
|
110
|
+
*/
|
|
111
|
+
const REAUTH_REQUIRED =
|
|
112
|
+
"This account needs to be reconnected before it can send. Open Settings → Accounts and choose Reconnect, then send this message again.";
|
|
113
|
+
|
|
98
114
|
const UNFILED_CONNECTION_LOST =
|
|
99
115
|
"The connection to the outgoing server dropped during the send, so this message may already have been delivered. Check with the recipient before sending it again.";
|
|
100
116
|
|
|
@@ -133,24 +149,9 @@ const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
|
|
|
133
149
|
OutboxMessageStatus.sending,
|
|
134
150
|
]);
|
|
135
151
|
|
|
136
|
-
/**
|
|
137
|
-
* Fallback when `SEND_MESSAGE_MAX_ATTEMPTS` is unset (local dev, unit tests).
|
|
138
|
-
* Matches `remit-smtp`'s `maxReceiveCount` (`deploy/vps/queues.json`), same
|
|
139
|
-
* pattern as `MESSAGE_MOVE_MAX_ATTEMPTS` / `FLAG_PUSH_MAX_ATTEMPTS` in
|
|
140
|
-
* imap-worker.
|
|
141
|
-
*/
|
|
142
|
-
const DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS = 3;
|
|
143
|
-
|
|
144
152
|
export const getSendMessageMaxAttempts = (
|
|
145
153
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
146
|
-
): number =>
|
|
147
|
-
const raw = processEnv.SEND_MESSAGE_MAX_ATTEMPTS;
|
|
148
|
-
if (!raw) return DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS;
|
|
149
|
-
const parsed = Number.parseInt(raw, 10);
|
|
150
|
-
return Number.isFinite(parsed) && parsed > 0
|
|
151
|
-
? parsed
|
|
152
|
-
: DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS;
|
|
153
|
-
};
|
|
154
|
+
): number => attemptBudget("SEND_MESSAGE_MAX_ATTEMPTS", 3, processEnv);
|
|
154
155
|
|
|
155
156
|
export const SEND_MESSAGE_MAX_ATTEMPTS = getSendMessageMaxAttempts();
|
|
156
157
|
|
|
@@ -183,6 +184,26 @@ export const sendMessage = async (
|
|
|
183
184
|
return;
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
// `send` queues a row before it emits, so an event that finds one at `draft`
|
|
188
|
+
// found a row the user pulled back into the composer after the emit. That is
|
|
189
|
+
// not a stalled send and it keeps its own state. Every other settle is
|
|
190
|
+
// conditional on the status the send was decided against, so a row that has
|
|
191
|
+
// moved on since is not overwritten either.
|
|
192
|
+
const settleBlockedOnReauth = async (
|
|
193
|
+
expected: OutboxMessageItem["status"] = outbox.status,
|
|
194
|
+
): Promise<void> => {
|
|
195
|
+
if (expected === OutboxMessageStatus.draft) return;
|
|
196
|
+
await deps.updateOutboxIfStatus(
|
|
197
|
+
accountConfigId,
|
|
198
|
+
outboxMessageId,
|
|
199
|
+
expected,
|
|
200
|
+
{
|
|
201
|
+
status: OutboxMessageStatus.blocked,
|
|
202
|
+
lastError: REAUTH_REQUIRED,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
186
207
|
// Tombstone fence: drop events for deleted accounts (#228)
|
|
187
208
|
if (account.deletedAt) {
|
|
188
209
|
log.info(
|
|
@@ -195,9 +216,10 @@ export const sendMessage = async (
|
|
|
195
216
|
// Reauth fence: skip accounts that need re-authentication. No SMTP traffic
|
|
196
217
|
// until the user re-auths (mirrors the IMAP reauth/ACK contract, #472).
|
|
197
218
|
if (account.connectionState === "reauth_required") {
|
|
219
|
+
await settleBlockedOnReauth();
|
|
198
220
|
log.info(
|
|
199
221
|
{ accountId, connectionState: account.connectionState },
|
|
200
|
-
"Account requires reauth,
|
|
222
|
+
"Account requires reauth, blocking the message",
|
|
201
223
|
);
|
|
202
224
|
return;
|
|
203
225
|
}
|
|
@@ -233,6 +255,7 @@ export const sendMessage = async (
|
|
|
233
255
|
"OAuth token revoked; marking account reauth_required",
|
|
234
256
|
);
|
|
235
257
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
258
|
+
await settleBlockedOnReauth();
|
|
236
259
|
return; // ACK — do not retry
|
|
237
260
|
}
|
|
238
261
|
// transient or config: let-it-crash (SQS retry / DLQ)
|
|
@@ -249,6 +272,7 @@ export const sendMessage = async (
|
|
|
249
272
|
"SMTP auth rejected; marking account reauth_required",
|
|
250
273
|
);
|
|
251
274
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
275
|
+
await settleBlockedOnReauth();
|
|
252
276
|
return; // ACK — do not retry
|
|
253
277
|
}
|
|
254
278
|
throw err;
|
|
@@ -291,6 +315,7 @@ export const sendMessage = async (
|
|
|
291
315
|
"SMTP auth rejected during send; marking account reauth_required",
|
|
292
316
|
);
|
|
293
317
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
318
|
+
await settleBlockedOnReauth(OutboxMessageStatus.sending);
|
|
294
319
|
return; // ACK — do not retry
|
|
295
320
|
}
|
|
296
321
|
// A password account's auth failure and every network failure retry on
|
|
@@ -85,6 +85,13 @@ interface Recorded {
|
|
|
85
85
|
sendCalls: number;
|
|
86
86
|
resolveCalls: number;
|
|
87
87
|
connectionStateUpdates: Array<{ accountId: string; state: string }>;
|
|
88
|
+
conditionalUpdates: Array<{
|
|
89
|
+
id: string;
|
|
90
|
+
expected: OutboxMessageItem["status"];
|
|
91
|
+
patch: UpdateOutboxMessageInput;
|
|
92
|
+
}>;
|
|
93
|
+
/** Ordered names of the writes the handler made, across every recorder. */
|
|
94
|
+
writeOrder: string[];
|
|
88
95
|
outboundIncrements: Array<{ addressId: string; now: number }>;
|
|
89
96
|
replyIncrements: Array<{ addressId: string; now: number }>;
|
|
90
97
|
}
|
|
@@ -107,11 +114,14 @@ const buildDeps = (
|
|
|
107
114
|
sendCalls: 0,
|
|
108
115
|
resolveCalls: 0,
|
|
109
116
|
connectionStateUpdates: [],
|
|
117
|
+
conditionalUpdates: [],
|
|
118
|
+
writeOrder: [],
|
|
110
119
|
outboundIncrements: [],
|
|
111
120
|
replyIncrements: [],
|
|
112
121
|
};
|
|
113
122
|
const outbox = options.outbox ?? buildOutbox();
|
|
114
123
|
const account = options.account ?? buildAccount();
|
|
124
|
+
let currentStatus: OutboxMessageItem["status"] = outbox.status;
|
|
115
125
|
const deps: SendMessageDeps = {
|
|
116
126
|
getOutbox: async (accountConfigId, id) => {
|
|
117
127
|
assert.equal(accountConfigId, account.accountConfigId);
|
|
@@ -124,12 +134,26 @@ const buildDeps = (
|
|
|
124
134
|
},
|
|
125
135
|
updateOutbox: async (accountConfigId, id, patch) => {
|
|
126
136
|
assert.equal(accountConfigId, account.accountConfigId);
|
|
137
|
+
if (patch.status) currentStatus = patch.status;
|
|
138
|
+
recorded.writeOrder.push("outbox");
|
|
127
139
|
recorded.updates.push({ id, patch });
|
|
128
140
|
},
|
|
129
141
|
updateOutboxStatus: async (accountConfigId, id, status) => {
|
|
130
142
|
assert.equal(accountConfigId, account.accountConfigId);
|
|
143
|
+
currentStatus = status;
|
|
131
144
|
recorded.statuses.push({ id, status });
|
|
132
145
|
},
|
|
146
|
+
// Compare-and-set, as the port implements it: the write lands only while
|
|
147
|
+
// the row still holds `expected`, and answers null when it does not.
|
|
148
|
+
updateOutboxIfStatus: async (accountConfigId, id, expected, patch) => {
|
|
149
|
+
assert.equal(accountConfigId, account.accountConfigId);
|
|
150
|
+
recorded.conditionalUpdates.push({ id, expected, patch });
|
|
151
|
+
if (expected !== currentStatus) return null;
|
|
152
|
+
if (patch.status) currentStatus = patch.status;
|
|
153
|
+
recorded.writeOrder.push("outbox");
|
|
154
|
+
recorded.updates.push({ id, patch });
|
|
155
|
+
return {};
|
|
156
|
+
},
|
|
133
157
|
markOutboxSent: async (accountConfigId, id, fields) => {
|
|
134
158
|
assert.equal(accountConfigId, account.accountConfigId);
|
|
135
159
|
recorded.marked.push({ id, ...fields });
|
|
@@ -155,6 +179,7 @@ const buildDeps = (
|
|
|
155
179
|
};
|
|
156
180
|
}),
|
|
157
181
|
updateConnectionState: async (accountId, state) => {
|
|
182
|
+
recorded.writeOrder.push("connectionState");
|
|
158
183
|
recorded.connectionStateUpdates.push({ accountId, state });
|
|
159
184
|
},
|
|
160
185
|
send:
|
|
@@ -527,8 +552,20 @@ describe("sendMessage handler", () => {
|
|
|
527
552
|
});
|
|
528
553
|
});
|
|
529
554
|
|
|
555
|
+
/**
|
|
556
|
+
* The row is the only place the user learns a send stopped on re-auth, so every
|
|
557
|
+
* branch that abandons the send has to leave a `blocked` row naming the remedy
|
|
558
|
+
* (issue #1152).
|
|
559
|
+
*/
|
|
560
|
+
const assertBlockedOnReauth = (recorded: Recorded): void => {
|
|
561
|
+
const settled = recorded.updates.at(-1)?.patch;
|
|
562
|
+
assert.equal(settled?.status, "blocked");
|
|
563
|
+
assert.match(String(settled?.lastError), /reconnected/i);
|
|
564
|
+
assert.match(String(settled?.lastError), /Reconnect/);
|
|
565
|
+
};
|
|
566
|
+
|
|
530
567
|
describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
531
|
-
it("
|
|
568
|
+
it("blocks the message when the account already needs reauth (issue #1152)", async () => {
|
|
532
569
|
const { deps, recorded } = buildDeps({
|
|
533
570
|
account: buildAccount({
|
|
534
571
|
smtpHost: "smtp.example.com",
|
|
@@ -546,7 +583,7 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
|
546
583
|
0,
|
|
547
584
|
"must not flip connectionState",
|
|
548
585
|
);
|
|
549
|
-
|
|
586
|
+
assertBlockedOnReauth(recorded);
|
|
550
587
|
assert.equal(recorded.statuses.length, 0, "must not change status");
|
|
551
588
|
});
|
|
552
589
|
|
|
@@ -569,6 +606,7 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
|
569
606
|
accountId: "acc-1",
|
|
570
607
|
state: "reauth_required",
|
|
571
608
|
});
|
|
609
|
+
assertBlockedOnReauth(recorded);
|
|
572
610
|
});
|
|
573
611
|
|
|
574
612
|
it("account storing no credential: settles `blocked` and never retries (issue #1120)", async () => {
|
|
@@ -629,6 +667,7 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
|
629
667
|
accountId: "acc-1",
|
|
630
668
|
state: "reauth_required",
|
|
631
669
|
});
|
|
670
|
+
assertBlockedOnReauth(recorded);
|
|
632
671
|
});
|
|
633
672
|
|
|
634
673
|
it("on SmtpConnectionError auth during credential resolution for password account: rethrows (no state flip)", async () => {
|
|
@@ -673,6 +712,7 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
|
673
712
|
accountId: "acc-1",
|
|
674
713
|
state: "reauth_required",
|
|
675
714
|
});
|
|
715
|
+
assertBlockedOnReauth(recorded);
|
|
676
716
|
});
|
|
677
717
|
|
|
678
718
|
it("on SmtpConnectionError auth during send for password account: rethrows (no state flip)", async () => {
|
|
@@ -697,6 +737,53 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
|
697
737
|
"must not flip connectionState for password account",
|
|
698
738
|
);
|
|
699
739
|
});
|
|
740
|
+
|
|
741
|
+
it("flips the connection state before it settles the row", async () => {
|
|
742
|
+
const { deps, recorded } = buildDeps({
|
|
743
|
+
resolveCredentials: async () => {
|
|
744
|
+
throw new RefreshTokenError({
|
|
745
|
+
kind: "reauth-required",
|
|
746
|
+
code: "invalid_grant",
|
|
747
|
+
});
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
await sendMessage(event, silentLogger, deps);
|
|
752
|
+
|
|
753
|
+
// A settle that throws after the flip is redelivered into the fence, which
|
|
754
|
+
// settles the row. The other order leaves the account unfenced, and the
|
|
755
|
+
// settled row is outside the send fence so no redelivery gets back here.
|
|
756
|
+
assert.deepEqual(recorded.writeOrder, ["connectionState", "outbox"]);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("leaves a row the user pulled back into the composer alone", async () => {
|
|
760
|
+
const { deps, recorded } = buildDeps({
|
|
761
|
+
outbox: buildOutbox({ status: "draft" }),
|
|
762
|
+
account: buildAccount({ connectionState: "reauth_required" }),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
await sendMessage(event, silentLogger, deps);
|
|
766
|
+
|
|
767
|
+
assert.equal(recorded.updates.length, 0, "must not overwrite the draft");
|
|
768
|
+
assert.equal(recorded.conditionalUpdates.length, 0, "must not even try");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("settles a send-time rejection against `sending`, the status the row reached", async () => {
|
|
772
|
+
const { deps, recorded } = buildDeps({
|
|
773
|
+
account: buildAccount({ authType: AccountAuthType.OauthMicrosoft }),
|
|
774
|
+
send: async () => {
|
|
775
|
+
throw new SmtpConnectionError("auth", "535 authentication failed");
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
await sendMessage(event, silentLogger, deps);
|
|
780
|
+
|
|
781
|
+
assert.deepEqual(
|
|
782
|
+
recorded.conditionalUpdates.map((update) => update.expected),
|
|
783
|
+
["sending"],
|
|
784
|
+
);
|
|
785
|
+
assertBlockedOnReauth(recorded);
|
|
786
|
+
});
|
|
700
787
|
});
|
|
701
788
|
|
|
702
789
|
/**
|
|
@@ -150,6 +150,13 @@ export const handleSendMessage = (
|
|
|
150
150
|
id,
|
|
151
151
|
status,
|
|
152
152
|
),
|
|
153
|
+
updateOutboxIfStatus: async (accountConfigId, id, expected, patch) =>
|
|
154
|
+
(await getPorts()).outboxMessage.updateIfStatus(
|
|
155
|
+
accountConfigId,
|
|
156
|
+
id,
|
|
157
|
+
expected,
|
|
158
|
+
patch,
|
|
159
|
+
),
|
|
153
160
|
markOutboxSent: async (accountConfigId, id, fields) =>
|
|
154
161
|
(await getPorts()).outboxMessage.markSent(accountConfigId, id, fields),
|
|
155
162
|
secrets,
|