@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/smtp-worker",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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. Called when a terminal OAuth/SMTP
84
- * auth failure is detected so the account is fenced off until the user
85
- * re-auths (mirrors the IMAP withOAuthLifecycle contract).
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: (accountId: string, state: string) => Promise<void>;
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, dropping send event",
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(accountId, "reauth_required");
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(accountId, "reauth_required");
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("skips send when account is reauth_required", async () => {
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
- assert.equal(recorded.updates.length, 0, "must not update outbox");
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
- updateConnectionState: async (id, state) => {
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(id, {
161
- connectionState:
162
- state as (typeof ConnectionState)[keyof typeof ConnectionState],
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,