@remit/smtp-worker 0.0.24 → 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
|
@@ -64,6 +64,13 @@ export interface SendMessageDeps {
|
|
|
64
64
|
id: string,
|
|
65
65
|
status: OutboxMessageItem["status"],
|
|
66
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>;
|
|
67
74
|
markOutboxSent: (
|
|
68
75
|
accountConfigId: string,
|
|
69
76
|
id: string,
|
|
@@ -96,6 +103,14 @@ export interface SendMessageDeps {
|
|
|
96
103
|
const UNFILED_NOT_QUEUED =
|
|
97
104
|
"Sent, but not filed: the copy for the Sent folder could not be queued.";
|
|
98
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
|
+
|
|
99
114
|
const UNFILED_CONNECTION_LOST =
|
|
100
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.";
|
|
101
116
|
|
|
@@ -169,6 +184,26 @@ export const sendMessage = async (
|
|
|
169
184
|
return;
|
|
170
185
|
}
|
|
171
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
|
+
|
|
172
207
|
// Tombstone fence: drop events for deleted accounts (#228)
|
|
173
208
|
if (account.deletedAt) {
|
|
174
209
|
log.info(
|
|
@@ -181,9 +216,10 @@ export const sendMessage = async (
|
|
|
181
216
|
// Reauth fence: skip accounts that need re-authentication. No SMTP traffic
|
|
182
217
|
// until the user re-auths (mirrors the IMAP reauth/ACK contract, #472).
|
|
183
218
|
if (account.connectionState === "reauth_required") {
|
|
219
|
+
await settleBlockedOnReauth();
|
|
184
220
|
log.info(
|
|
185
221
|
{ accountId, connectionState: account.connectionState },
|
|
186
|
-
"Account requires reauth,
|
|
222
|
+
"Account requires reauth, blocking the message",
|
|
187
223
|
);
|
|
188
224
|
return;
|
|
189
225
|
}
|
|
@@ -219,6 +255,7 @@ export const sendMessage = async (
|
|
|
219
255
|
"OAuth token revoked; marking account reauth_required",
|
|
220
256
|
);
|
|
221
257
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
258
|
+
await settleBlockedOnReauth();
|
|
222
259
|
return; // ACK — do not retry
|
|
223
260
|
}
|
|
224
261
|
// transient or config: let-it-crash (SQS retry / DLQ)
|
|
@@ -235,6 +272,7 @@ export const sendMessage = async (
|
|
|
235
272
|
"SMTP auth rejected; marking account reauth_required",
|
|
236
273
|
);
|
|
237
274
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
275
|
+
await settleBlockedOnReauth();
|
|
238
276
|
return; // ACK — do not retry
|
|
239
277
|
}
|
|
240
278
|
throw err;
|
|
@@ -277,6 +315,7 @@ export const sendMessage = async (
|
|
|
277
315
|
"SMTP auth rejected during send; marking account reauth_required",
|
|
278
316
|
);
|
|
279
317
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
318
|
+
await settleBlockedOnReauth(OutboxMessageStatus.sending);
|
|
280
319
|
return; // ACK — do not retry
|
|
281
320
|
}
|
|
282
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,
|