@remit/smtp-worker 0.0.24 → 0.0.26
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,
|
|
@@ -80,11 +87,18 @@ export interface SendMessageDeps {
|
|
|
80
87
|
*/
|
|
81
88
|
resolveCredentials: (account: AccountItem) => Promise<CredentialResolution>;
|
|
82
89
|
/**
|
|
83
|
-
* Persist the account's connectionState
|
|
84
|
-
*
|
|
85
|
-
* re-auths (mirrors the
|
|
90
|
+
* Persist the account's connectionState, and with it the sentence the
|
|
91
|
+
* account card shows. Called when a terminal OAuth/SMTP auth failure is
|
|
92
|
+
* detected so the account is fenced off until the user re-auths (mirrors the
|
|
93
|
+
* IMAP withOAuthLifecycle contract). `lastError` carries the server's own
|
|
94
|
+
* words when the refusal came with any, and its absence clears the stored
|
|
95
|
+
* reason rather than leaving an older failure to describe this one.
|
|
86
96
|
*/
|
|
87
|
-
updateConnectionState: (
|
|
97
|
+
updateConnectionState: (
|
|
98
|
+
accountId: string,
|
|
99
|
+
state: string,
|
|
100
|
+
lastError?: string,
|
|
101
|
+
) => Promise<void>;
|
|
88
102
|
send: typeof sendMail;
|
|
89
103
|
emitAppendSentMessage: (
|
|
90
104
|
accountId: string,
|
|
@@ -96,6 +110,14 @@ export interface SendMessageDeps {
|
|
|
96
110
|
const UNFILED_NOT_QUEUED =
|
|
97
111
|
"Sent, but not filed: the copy for the Sent folder could not be queued.";
|
|
98
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Every re-auth branch settles the row with this, because the row is the only
|
|
115
|
+
* place the user learns the send stopped: `blocked` shows its reason and offers
|
|
116
|
+
* no Retry, and a retry cannot succeed until the account is reconnected.
|
|
117
|
+
*/
|
|
118
|
+
const REAUTH_REQUIRED =
|
|
119
|
+
"This account needs to be reconnected before it can send. Open Settings → Accounts and choose Reconnect, then send this message again.";
|
|
120
|
+
|
|
99
121
|
const UNFILED_CONNECTION_LOST =
|
|
100
122
|
"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
123
|
|
|
@@ -169,6 +191,26 @@ export const sendMessage = async (
|
|
|
169
191
|
return;
|
|
170
192
|
}
|
|
171
193
|
|
|
194
|
+
// `send` queues a row before it emits, so an event that finds one at `draft`
|
|
195
|
+
// found a row the user pulled back into the composer after the emit. That is
|
|
196
|
+
// not a stalled send and it keeps its own state. Every other settle is
|
|
197
|
+
// conditional on the status the send was decided against, so a row that has
|
|
198
|
+
// moved on since is not overwritten either.
|
|
199
|
+
const settleBlockedOnReauth = async (
|
|
200
|
+
expected: OutboxMessageItem["status"] = outbox.status,
|
|
201
|
+
): Promise<void> => {
|
|
202
|
+
if (expected === OutboxMessageStatus.draft) return;
|
|
203
|
+
await deps.updateOutboxIfStatus(
|
|
204
|
+
accountConfigId,
|
|
205
|
+
outboxMessageId,
|
|
206
|
+
expected,
|
|
207
|
+
{
|
|
208
|
+
status: OutboxMessageStatus.blocked,
|
|
209
|
+
lastError: REAUTH_REQUIRED,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
172
214
|
// Tombstone fence: drop events for deleted accounts (#228)
|
|
173
215
|
if (account.deletedAt) {
|
|
174
216
|
log.info(
|
|
@@ -181,9 +223,10 @@ export const sendMessage = async (
|
|
|
181
223
|
// Reauth fence: skip accounts that need re-authentication. No SMTP traffic
|
|
182
224
|
// until the user re-auths (mirrors the IMAP reauth/ACK contract, #472).
|
|
183
225
|
if (account.connectionState === "reauth_required") {
|
|
226
|
+
await settleBlockedOnReauth();
|
|
184
227
|
log.info(
|
|
185
228
|
{ accountId, connectionState: account.connectionState },
|
|
186
|
-
"Account requires reauth,
|
|
229
|
+
"Account requires reauth, blocking the message",
|
|
187
230
|
);
|
|
188
231
|
return;
|
|
189
232
|
}
|
|
@@ -219,6 +262,7 @@ export const sendMessage = async (
|
|
|
219
262
|
"OAuth token revoked; marking account reauth_required",
|
|
220
263
|
);
|
|
221
264
|
await deps.updateConnectionState(accountId, "reauth_required");
|
|
265
|
+
await settleBlockedOnReauth();
|
|
222
266
|
return; // ACK — do not retry
|
|
223
267
|
}
|
|
224
268
|
// transient or config: let-it-crash (SQS retry / DLQ)
|
|
@@ -231,10 +275,15 @@ export const sendMessage = async (
|
|
|
231
275
|
throw err;
|
|
232
276
|
}
|
|
233
277
|
log.warn(
|
|
234
|
-
{ accountId, errorKind: err.kind },
|
|
278
|
+
{ accountId, errorKind: err.kind, error: err.message },
|
|
235
279
|
"SMTP auth rejected; marking account reauth_required",
|
|
236
280
|
);
|
|
237
|
-
await deps.updateConnectionState(
|
|
281
|
+
await deps.updateConnectionState(
|
|
282
|
+
accountId,
|
|
283
|
+
"reauth_required",
|
|
284
|
+
err.message,
|
|
285
|
+
);
|
|
286
|
+
await settleBlockedOnReauth();
|
|
238
287
|
return; // ACK — do not retry
|
|
239
288
|
}
|
|
240
289
|
throw err;
|
|
@@ -273,10 +322,15 @@ export const sendMessage = async (
|
|
|
273
322
|
account.authType === AccountAuthType.OauthMicrosoft
|
|
274
323
|
) {
|
|
275
324
|
log.warn(
|
|
276
|
-
{ accountId, errorKind: err.kind },
|
|
325
|
+
{ accountId, errorKind: err.kind, error: err.message },
|
|
277
326
|
"SMTP auth rejected during send; marking account reauth_required",
|
|
278
327
|
);
|
|
279
|
-
await deps.updateConnectionState(
|
|
328
|
+
await deps.updateConnectionState(
|
|
329
|
+
accountId,
|
|
330
|
+
"reauth_required",
|
|
331
|
+
err.message,
|
|
332
|
+
);
|
|
333
|
+
await settleBlockedOnReauth(OutboxMessageStatus.sending);
|
|
280
334
|
return; // ACK — do not retry
|
|
281
335
|
}
|
|
282
336
|
// 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,17 +150,31 @@ 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,
|
|
156
163
|
resolveCredentials: (account) =>
|
|
157
164
|
resolveConnectionCredentials(account, credentialDeps),
|
|
158
|
-
|
|
165
|
+
// A flip with no reason of its own removes the stored one, so the card
|
|
166
|
+
// never reads back a failure the account has moved on from.
|
|
167
|
+
updateConnectionState: async (id, state, lastError) => {
|
|
159
168
|
const { account } = await getPorts();
|
|
160
|
-
await account.update(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
await account.update(
|
|
170
|
+
id,
|
|
171
|
+
{
|
|
172
|
+
connectionState:
|
|
173
|
+
state as (typeof ConnectionState)[keyof typeof ConnectionState],
|
|
174
|
+
lastError,
|
|
175
|
+
},
|
|
176
|
+
lastError === undefined ? ["lastError"] : undefined,
|
|
177
|
+
);
|
|
164
178
|
},
|
|
165
179
|
send: sendMail,
|
|
166
180
|
emitAppendSentMessage,
|