@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/smtp-worker",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
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,
@@ -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, dropping send event",
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("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,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,