@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/smtp-worker",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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, dropping send event",
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("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,