@remit/smtp-worker 0.0.20 → 0.0.21

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.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "bundle": "node build.mjs",
18
18
  "test:typecheck": "tsgo --noEmit",
19
- "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=97 --test 'src/**/*.test.ts'",
19
+ "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=83 --test 'src/**/*.test.ts'",
20
20
  "test": "npm run test:typecheck && npm run test:run",
21
21
  "dev": "node --import tsx src/e2e-processor-shim.ts"
22
22
  },
@@ -5,7 +5,7 @@ import type {
5
5
  UpdateOutboxMessageInput,
6
6
  } from "@remit/data-ports";
7
7
  import { AccountAuthType, OutboxMessageStatus } from "@remit/domain-enums";
8
- import type { Logger } from "@remit/logger-lambda";
8
+ import { type Logger, recordSmtpFailure } from "@remit/logger-lambda";
9
9
  import { RefreshTokenError } from "@remit/mail-oauth-service";
10
10
  import type { SecretsService } from "@remit/secrets-service";
11
11
  import {
@@ -93,16 +93,70 @@ export interface SendMessageDeps {
93
93
  const UNFILED_NOT_QUEUED =
94
94
  "Sent, but not filed: the copy for the Sent folder could not be queued.";
95
95
 
96
+ const UNFILED_CONNECTION_LOST =
97
+ "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.";
98
+
99
+ /**
100
+ * The connection failures that prove nothing was submitted: no session ever
101
+ * opened, so the server holds no copy and `failed` is safe — Retry sends the
102
+ * only copy there is.
103
+ *
104
+ * `ECONNRESET` and `ETIMEDOUT` classify as `network` too and are deliberately
105
+ * absent. Either can land after DATA, with the message already queued on the
106
+ * server, and a `failed` row invites a Retry that delivers it twice.
107
+ */
108
+ const NEVER_SUBMITTED_CODES: ReadonlySet<string> = new Set([
109
+ "ECONNREFUSED",
110
+ "ENOTFOUND",
111
+ "EHOSTUNREACH",
112
+ ]);
113
+
114
+ const errorCode = (cause: unknown): string =>
115
+ cause instanceof Error && "code" in cause && typeof cause.code === "string"
116
+ ? cause.code
117
+ : "";
118
+
119
+ /**
120
+ * Whether the message can be re-sent without risking a second copy. An auth
121
+ * rejection is decided before the envelope; a connection failure carries the
122
+ * code that says how far it got. Anything else counts as possibly delivered,
123
+ * which is the answer that cannot produce a duplicate.
124
+ */
125
+ const neverSubmitted = (err: SmtpConnectionError): boolean =>
126
+ err.kind === "auth" || NEVER_SUBMITTED_CODES.has(errorCode(err.cause));
127
+
96
128
  const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
97
129
  OutboxMessageStatus.draft,
98
130
  OutboxMessageStatus.queued,
99
131
  OutboxMessageStatus.sending,
100
132
  ]);
101
133
 
134
+ /**
135
+ * Fallback when `SEND_MESSAGE_MAX_ATTEMPTS` is unset (local dev, unit tests).
136
+ * Matches `remit-smtp`'s `maxReceiveCount` (`deploy/vps/queues.json`), same
137
+ * pattern as `MESSAGE_MOVE_MAX_ATTEMPTS` / `FLAG_PUSH_MAX_ATTEMPTS` in
138
+ * imap-worker.
139
+ */
140
+ const DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS = 3;
141
+
142
+ export const getSendMessageMaxAttempts = (
143
+ processEnv: NodeJS.ProcessEnv = process.env,
144
+ ): number => {
145
+ const raw = processEnv.SEND_MESSAGE_MAX_ATTEMPTS;
146
+ if (!raw) return DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS;
147
+ const parsed = Number.parseInt(raw, 10);
148
+ return Number.isFinite(parsed) && parsed > 0
149
+ ? parsed
150
+ : DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS;
151
+ };
152
+
153
+ export const SEND_MESSAGE_MAX_ATTEMPTS = getSendMessageMaxAttempts();
154
+
102
155
  export const sendMessage = async (
103
156
  event: SendMessageEvent,
104
157
  log: Logger,
105
158
  deps: SendMessageDeps,
159
+ receiveCount = 1,
106
160
  ): Promise<void> => {
107
161
  const { outboxMessageId, accountId } = event;
108
162
 
@@ -207,12 +261,12 @@ export const sendMessage = async (
207
261
  } catch (err) {
208
262
  // A terminal SMTP auth rejection (e.g. expired OAuth token surfaced at
209
263
  // connect time) flips the account to reauth_required and ACKs.
210
- // Only OAuth accounts have a re-auth recovery path. For password
211
- // accounts, rethrow to preserve pre-PR batch-item-failure behaviour.
212
- if (err instanceof SmtpConnectionError && err.kind === "auth") {
213
- if (account.authType !== AccountAuthType.OauthMicrosoft) {
214
- throw err;
215
- }
264
+ // Only OAuth accounts have a re-auth recovery path.
265
+ if (
266
+ err instanceof SmtpConnectionError &&
267
+ err.kind === "auth" &&
268
+ account.authType === AccountAuthType.OauthMicrosoft
269
+ ) {
216
270
  log.warn(
217
271
  { accountId, errorKind: err.kind },
218
272
  "SMTP auth rejected during send; marking account reauth_required",
@@ -220,6 +274,42 @@ export const sendMessage = async (
220
274
  await deps.updateConnectionState(accountId, "reauth_required");
221
275
  return; // ACK — do not retry
222
276
  }
277
+ // A password account's auth failure and every network failure retry on
278
+ // SQS redelivery until the queue's own budget runs out, then settle
279
+ // here instead of dead-lettering with the row stuck at `sending`
280
+ // (issue #951). Where it settles is the double-send question:
281
+ // `neverSubmitted` says the message cannot be on the server, so
282
+ // `failed` offers the Retry the user needs; anything else settles
283
+ // `unfiled`, the state that says a copy may be out there and which
284
+ // Retry is not offered on. Wait-or-reconcile
285
+ // (docs/architecture/imap-mutations.md R2): neither applies — a
286
+ // submission leaves no server-side handle to reconcile against, so the
287
+ // row settles on what the failure itself proves.
288
+ if (err instanceof SmtpConnectionError) {
289
+ if (receiveCount < SEND_MESSAGE_MAX_ATTEMPTS) {
290
+ throw err;
291
+ }
292
+ const settled = neverSubmitted(err)
293
+ ? { status: OutboxMessageStatus.failed, lastError: err.message }
294
+ : {
295
+ status: OutboxMessageStatus.unfiled,
296
+ lastError: `${UNFILED_CONNECTION_LOST} (${err.message})`,
297
+ };
298
+ await deps.updateOutbox(accountConfigId, outboxMessageId, settled);
299
+ // Terminal and never re-thrown, so the handler-outcome series
300
+ // records this record as a success. Counted here or it is invisible.
301
+ recordSmtpFailure(err.kind);
302
+ log.error(
303
+ {
304
+ outboxMessageId,
305
+ errorKind: err.kind,
306
+ receiveCount,
307
+ status: settled.status,
308
+ },
309
+ "SMTP send retry exhausted; settling the row",
310
+ );
311
+ return; // ACK — settled terminal, no more retries
312
+ }
223
313
  throw err;
224
314
  }
225
315
 
@@ -265,16 +355,41 @@ export const sendMessage = async (
265
355
  }
266
356
 
267
357
  if (result.isTransient) {
268
- log.warn(
358
+ // A 4xx from the server is retried on SQS redelivery the same way a
359
+ // connection failure is above; once the queue's own budget runs out
360
+ // this settles the row at `failed` rather than leaving it at `queued`
361
+ // forever once the record dead-letters (issue #951).
362
+ if (receiveCount < SEND_MESSAGE_MAX_ATTEMPTS) {
363
+ log.warn(
364
+ {
365
+ outboxMessageId,
366
+ smtpCode: result.smtpCode,
367
+ error: result.error?.message,
368
+ },
369
+ "Transient failure, will retry",
370
+ );
371
+ await deps.updateOutboxStatus(accountConfigId, outboxMessageId, "queued");
372
+ throw new Error(`SMTP transient error: ${result.error?.message}`);
373
+ }
374
+
375
+ await deps.updateOutbox(accountConfigId, outboxMessageId, {
376
+ status: "failed",
377
+ lastError: result.error?.message,
378
+ lastSmtpCode: result.smtpCode,
379
+ });
380
+ // Terminal and never re-thrown, so the handler-outcome series records
381
+ // this record as a success. Counted here or it is invisible.
382
+ recordSmtpFailure("other");
383
+ log.error(
269
384
  {
270
385
  outboxMessageId,
271
386
  smtpCode: result.smtpCode,
272
387
  error: result.error?.message,
388
+ receiveCount,
273
389
  },
274
- "Transient failure, will retry",
390
+ "Transient failure retry exhausted; settling as failed",
275
391
  );
276
- await deps.updateOutboxStatus(accountConfigId, outboxMessageId, "queued");
277
- throw new Error(`SMTP transient error: ${result.error?.message}`);
392
+ return;
278
393
  }
279
394
 
280
395
  // Permanent failure - mark as failed, don't throw (no retry)
@@ -13,7 +13,12 @@ import {
13
13
  } from "@remit/secrets-service";
14
14
  import { type SendResult, SmtpConnectionError } from "@remit/smtp-service";
15
15
  import type { SendMessageEvent } from "../events.js";
16
- import { type SendMessageDeps, sendMessage } from "./send-message-core.js";
16
+ import {
17
+ getSendMessageMaxAttempts,
18
+ SEND_MESSAGE_MAX_ATTEMPTS,
19
+ type SendMessageDeps,
20
+ sendMessage,
21
+ } from "./send-message-core.js";
17
22
 
18
23
  const silentLogger = {
19
24
  info: () => {},
@@ -653,3 +658,179 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
653
658
  );
654
659
  });
655
660
  });
661
+
662
+ /**
663
+ * A connection failure as `smtp-client.ts` raises one: the code nodemailer put
664
+ * on the underlying error is what says how far the submission got, and it
665
+ * survives only on the cause.
666
+ */
667
+ const connectionError = (code: string): SmtpConnectionError =>
668
+ new SmtpConnectionError(
669
+ "network",
670
+ `SMTP connection failed: ${code}`,
671
+ Object.assign(new Error(code), { code }),
672
+ );
673
+
674
+ describe("sendMessage retry-budget exhaustion (issue #951)", () => {
675
+ it("rethrows a password account's auth failure below the retry budget, leaving the row `sending`", async () => {
676
+ const { deps, recorded } = buildDeps({
677
+ account: buildAccount({
678
+ smtpHost: "smtp.example.com",
679
+ smtpPort: 587,
680
+ authType: AccountAuthType.Password,
681
+ }),
682
+ send: async () => {
683
+ throw new SmtpConnectionError("auth", "535 authentication failed");
684
+ },
685
+ });
686
+
687
+ await assert.rejects(
688
+ () =>
689
+ sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS - 1),
690
+ /535 authentication failed/,
691
+ );
692
+ assert.equal(
693
+ recorded.updates.length,
694
+ 0,
695
+ "must not settle the row before the retry budget is spent",
696
+ );
697
+ });
698
+
699
+ it("settles a password account's exhausted auth failure at `failed`, not stranded at `sending`", async () => {
700
+ const { deps, recorded } = buildDeps({
701
+ account: buildAccount({
702
+ smtpHost: "smtp.example.com",
703
+ smtpPort: 587,
704
+ authType: AccountAuthType.Password,
705
+ }),
706
+ send: async () => {
707
+ throw new SmtpConnectionError("auth", "535 authentication failed");
708
+ },
709
+ });
710
+
711
+ await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
712
+
713
+ assert.equal(recorded.connectionStateUpdates.length, 0);
714
+ const failedUpdate = recorded.updates.find(
715
+ (u) => u.patch.status === "failed",
716
+ );
717
+ assert.ok(failedUpdate, "should settle the row as failed");
718
+ assert.match(String(failedUpdate.patch.lastError), /authentication failed/);
719
+ });
720
+
721
+ it("settles an exhausted refused connection at `failed`, not stranded at `sending`", async () => {
722
+ const { deps, recorded } = buildDeps({
723
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
724
+ send: async () => {
725
+ throw connectionError("ECONNREFUSED");
726
+ },
727
+ });
728
+
729
+ await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
730
+
731
+ const failedUpdate = recorded.updates.find(
732
+ (u) => u.patch.status === "failed",
733
+ );
734
+ assert.ok(failedUpdate, "should settle the row as failed");
735
+ assert.match(String(failedUpdate.patch.lastError), /ECONNREFUSED/);
736
+ });
737
+
738
+ for (const code of ["ECONNRESET", "ETIMEDOUT"]) {
739
+ it(`settles an exhausted ${code} at \`unfiled\` — the server may hold the message`, async () => {
740
+ // Both classify as `network` and both can land after DATA, so a
741
+ // `failed` row here would offer a Retry that delivers a second copy.
742
+ const { deps, recorded } = buildDeps({
743
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
744
+ send: async () => {
745
+ throw connectionError(code);
746
+ },
747
+ });
748
+
749
+ await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
750
+
751
+ assert.equal(
752
+ recorded.updates.find((u) => u.patch.status === "failed"),
753
+ undefined,
754
+ "a row that may have been delivered must not be re-sendable",
755
+ );
756
+ const settled = recorded.updates.at(-1)?.patch;
757
+ assert.equal(settled?.status, "unfiled");
758
+ assert.match(
759
+ String(settled?.lastError),
760
+ /may already have been delivered/,
761
+ );
762
+ assert.match(String(settled?.lastError), new RegExp(code));
763
+ });
764
+ }
765
+
766
+ it("settles an exhausted connection failure that names no code at `unfiled`", async () => {
767
+ // Nothing says the message did not reach the server, and the answer that
768
+ // cannot produce a second copy is the one to settle on.
769
+ const { deps, recorded } = buildDeps({
770
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
771
+ send: async () => {
772
+ throw new SmtpConnectionError("network", "SMTP connection failed");
773
+ },
774
+ });
775
+
776
+ await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
777
+
778
+ assert.equal(recorded.updates.at(-1)?.patch.status, "unfiled");
779
+ });
780
+
781
+ it("settles an exhausted transient SMTP failure at `failed` instead of leaving it `queued` forever", async () => {
782
+ const { deps, recorded } = buildDeps({
783
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
784
+ sendResult: {
785
+ success: false,
786
+ error: new Error("temporarily unavailable"),
787
+ smtpCode: 421,
788
+ isTransient: true,
789
+ },
790
+ });
791
+
792
+ await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
793
+
794
+ const failedUpdate = recorded.updates.find(
795
+ (u) => u.patch.status === "failed",
796
+ );
797
+ assert.ok(failedUpdate, "should settle the row as failed");
798
+ assert.equal(failedUpdate.patch.lastSmtpCode, 421);
799
+ assert.equal(
800
+ recorded.statuses.find((s) => s.status === "queued"),
801
+ undefined,
802
+ "must not leave the row requeued once the budget is spent",
803
+ );
804
+ });
805
+ });
806
+
807
+ describe("the retry budget is the queue's, read from the environment", () => {
808
+ it("takes the deployment's own maxReceiveCount when it is set", () => {
809
+ // The e2e stack sets 1 (`deploy/vps/e2e.env`): its pollers hold a record
810
+ // for 300s, so a spec cannot wait out three deliveries.
811
+ assert.equal(
812
+ getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "1" }),
813
+ 1,
814
+ );
815
+ assert.equal(
816
+ getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "5" }),
817
+ 5,
818
+ );
819
+ });
820
+
821
+ it("falls back to remit-smtp's maxReceiveCount when it is unset or unusable", () => {
822
+ assert.equal(getSendMessageMaxAttempts({}), 3);
823
+ assert.equal(
824
+ getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "" }),
825
+ 3,
826
+ );
827
+ assert.equal(
828
+ getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "not-a-number" }),
829
+ 3,
830
+ );
831
+ assert.equal(
832
+ getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "0" }),
833
+ 3,
834
+ );
835
+ });
836
+ });
@@ -132,50 +132,56 @@ const buildCredentialDeps = (): AccountCredentialsDeps => ({
132
132
  export const handleSendMessage = (
133
133
  event: SendMessageEvent,
134
134
  log: Logger,
135
+ receiveCount = 1,
135
136
  ): Promise<void> => {
136
137
  const credentialDeps = buildCredentialDeps();
137
- return sendMessage(event, log, {
138
- getOutbox: async (accountConfigId, id) =>
139
- (await getPorts()).outboxMessage.get(accountConfigId, id),
140
- getAccount: async (id) => (await getPorts()).account.get(id),
141
- updateOutbox: async (accountConfigId, id, patch) =>
142
- (await getPorts()).outboxMessage.update(accountConfigId, id, patch),
143
- updateOutboxStatus: async (accountConfigId, id, status) =>
144
- (await getPorts()).outboxMessage.updateStatus(
145
- accountConfigId,
146
- id,
147
- status,
148
- ),
149
- markOutboxSent: async (accountConfigId, id, fields) =>
150
- (await getPorts()).outboxMessage.markSent(accountConfigId, id, fields),
151
- secrets,
152
- resolveCredentials: (account) =>
153
- resolveConnectionCredentials(account, credentialDeps),
154
- updateConnectionState: async (id, state) => {
155
- const { account } = await getPorts();
156
- await account.update(id, {
157
- connectionState:
158
- state as (typeof ConnectionState)[keyof typeof ConnectionState],
159
- });
160
- },
161
- send: sendMail,
162
- emitAppendSentMessage,
163
- engagement: {
164
- resolveAddressId: deriveAddressId,
165
- incrementOutboundCount: async (accountConfigId, addressId, now) =>
166
- (await getPorts()).address.incrementOutboundCount(
167
- accountConfigId,
168
- addressId,
169
- now,
170
- ),
171
- incrementReplyCount: async (accountConfigId, addressId, now) =>
172
- (await getPorts()).address.incrementReplyCount(
138
+ return sendMessage(
139
+ event,
140
+ log,
141
+ {
142
+ getOutbox: async (accountConfigId, id) =>
143
+ (await getPorts()).outboxMessage.get(accountConfigId, id),
144
+ getAccount: async (id) => (await getPorts()).account.get(id),
145
+ updateOutbox: async (accountConfigId, id, patch) =>
146
+ (await getPorts()).outboxMessage.update(accountConfigId, id, patch),
147
+ updateOutboxStatus: async (accountConfigId, id, status) =>
148
+ (await getPorts()).outboxMessage.updateStatus(
173
149
  accountConfigId,
174
- addressId,
175
- now,
150
+ id,
151
+ status,
176
152
  ),
177
- findMessageByHeader,
178
- getEnvelopeFromEmail,
153
+ markOutboxSent: async (accountConfigId, id, fields) =>
154
+ (await getPorts()).outboxMessage.markSent(accountConfigId, id, fields),
155
+ secrets,
156
+ resolveCredentials: (account) =>
157
+ resolveConnectionCredentials(account, credentialDeps),
158
+ updateConnectionState: async (id, state) => {
159
+ const { account } = await getPorts();
160
+ await account.update(id, {
161
+ connectionState:
162
+ state as (typeof ConnectionState)[keyof typeof ConnectionState],
163
+ });
164
+ },
165
+ send: sendMail,
166
+ emitAppendSentMessage,
167
+ engagement: {
168
+ resolveAddressId: deriveAddressId,
169
+ incrementOutboundCount: async (accountConfigId, addressId, now) =>
170
+ (await getPorts()).address.incrementOutboundCount(
171
+ accountConfigId,
172
+ addressId,
173
+ now,
174
+ ),
175
+ incrementReplyCount: async (accountConfigId, addressId, now) =>
176
+ (await getPorts()).address.incrementReplyCount(
177
+ accountConfigId,
178
+ addressId,
179
+ now,
180
+ ),
181
+ findMessageByHeader,
182
+ getEnvelopeFromEmail,
183
+ },
179
184
  },
180
- });
185
+ receiveCount,
186
+ );
181
187
  };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The delivery count the settle decision is made on comes from the queue, and
3
+ * nothing between the record and the handler is allowed to drop it: a handler
4
+ * that always sees 1 never reaches the settle at all, and the row dead-letters
5
+ * at `sending` exactly as it did before #951.
6
+ *
7
+ * The send handler is replaced here, so this file is about the wiring and not
8
+ * about what the handler does with it — and replacing it is also what keeps
9
+ * the real one's data ports and queue producers out of a unit test.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { describe, it, mock } from "node:test";
13
+ import type { Context, SQSEvent } from "aws-lambda";
14
+
15
+ const receiveCounts: number[] = [];
16
+
17
+ mock.module("./handlers/send-message.js", {
18
+ namedExports: {
19
+ handleSendMessage: async (
20
+ _event: unknown,
21
+ _log: unknown,
22
+ receiveCount: number,
23
+ ): Promise<void> => {
24
+ receiveCounts.push(receiveCount);
25
+ },
26
+ },
27
+ });
28
+
29
+ const { handler, parseReceiveCount } = await import("./index.js");
30
+
31
+ const context = {
32
+ functionName: "smtp-worker-test",
33
+ awsRequestId: "req-1",
34
+ } as Context;
35
+
36
+ const sendMessageEvent = (approximateReceiveCount: string): SQSEvent =>
37
+ ({
38
+ Records: [
39
+ {
40
+ messageId: "sqs-1",
41
+ receiptHandle: "rh-1",
42
+ body: JSON.stringify({
43
+ type: "SEND_MESSAGE",
44
+ eventId: "evt-1",
45
+ timestamp: 0,
46
+ accountId: "acc-1",
47
+ outboxMessageId: "obx-1",
48
+ }),
49
+ attributes: { ApproximateReceiveCount: approximateReceiveCount },
50
+ messageAttributes: {},
51
+ md5OfBody: "",
52
+ eventSource: "aws:sqs",
53
+ eventSourceARN: "http://queue:9324/000000000000/remit-smtp",
54
+ awsRegion: "local",
55
+ },
56
+ ],
57
+ }) as unknown as SQSEvent;
58
+
59
+ describe("parseReceiveCount — SQS ApproximateReceiveCount parsing", () => {
60
+ it("parses the raw string attribute", () => {
61
+ assert.equal(parseReceiveCount("1"), 1);
62
+ assert.equal(parseReceiveCount("3"), 3);
63
+ });
64
+
65
+ it("defaults to 1 when the attribute is missing", () => {
66
+ assert.equal(parseReceiveCount(undefined), 1);
67
+ });
68
+
69
+ it("defaults to 1 on a non-numeric or non-positive value", () => {
70
+ assert.equal(parseReceiveCount("not-a-number"), 1);
71
+ assert.equal(parseReceiveCount("0"), 1);
72
+ assert.equal(parseReceiveCount("-1"), 1);
73
+ });
74
+ });
75
+
76
+ describe("the record's delivery count reaches the send handler", () => {
77
+ it("threads ApproximateReceiveCount through the processor into handleSendMessage", async () => {
78
+ receiveCounts.length = 0;
79
+
80
+ const response = await handler(sendMessageEvent("3"), context);
81
+
82
+ assert.deepEqual(receiveCounts, [3]);
83
+ assert.deepEqual(response.batchItemFailures, []);
84
+ });
85
+
86
+ it("carries the first delivery through as 1", async () => {
87
+ receiveCounts.length = 0;
88
+
89
+ await handler(sendMessageEvent("1"), context);
90
+
91
+ assert.deepEqual(receiveCounts, [1]);
92
+ });
93
+ });
package/src/index.ts CHANGED
@@ -13,20 +13,36 @@ import { processEvent } from "./processor.js";
13
13
 
14
14
  const log = createLogger();
15
15
 
16
+ /**
17
+ * Parse SQS's `ApproximateReceiveCount` record attribute (1 on first
18
+ * delivery). Missing/malformed defaults to 1 so a record with no attribute
19
+ * (e.g. an older local harness) is treated as a first attempt rather than
20
+ * skipping straight to retry-exhaustion handling. Mirrors
21
+ * `imap-worker/src/index.ts`'s `parseReceiveCount`.
22
+ */
23
+ export const parseReceiveCount = (value: string | undefined): number => {
24
+ const parsed = Number.parseInt(value ?? "1", 10);
25
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
26
+ };
27
+
16
28
  export const handler = withTelemetry(
17
29
  async (event: SQSEvent): Promise<SQSBatchResponse> => {
18
30
  const batchItemFailures: { itemIdentifier: string }[] = [];
19
31
 
20
32
  for (const record of event.Records) {
21
33
  const smtpEvent: SmtpEvent = JSON.parse(record.body);
34
+ const receiveCount = parseReceiveCount(
35
+ record.attributes?.ApproximateReceiveCount,
36
+ );
22
37
  log.info("Processing SMTP event", {
23
38
  eventType: smtpEvent.type,
24
39
  eventId: smtpEvent.eventId,
40
+ receiveCount,
25
41
  });
26
42
 
27
43
  const queue = queueNameFromEventSource(record.eventSourceARN);
28
44
  const sendStart = Date.now();
29
- const failed = await processEvent(smtpEvent, log)
45
+ const failed = await processEvent(smtpEvent, log, receiveCount)
30
46
  .then(() => {
31
47
  recordQueueEvent({
32
48
  queue,
package/src/processor.ts CHANGED
@@ -5,10 +5,18 @@ import { handleSendMessage } from "./handlers/send-message.js";
5
5
  export const processEvent = async (
6
6
  event: SmtpEvent,
7
7
  log: Logger,
8
+ /**
9
+ * SQS's own delivery count for the record carrying this event (1 on first
10
+ * delivery). Read by SEND_MESSAGE so it knows from it when this is the
11
+ * last attempt before the queue's own redrive policy would DLQ the
12
+ * record, so it can resolve retry exhaustion into a terminal outcome
13
+ * (issue #951) instead of dead-lettering blindly.
14
+ */
15
+ receiveCount = 1,
8
16
  ): Promise<void> => {
9
17
  switch (event.type) {
10
18
  case "SEND_MESSAGE":
11
- return handleSendMessage(event, log);
19
+ return handleSendMessage(event, log, receiveCount);
12
20
  case "PROCESS_OUTBOX":
13
21
  // Future: batch process all queued messages
14
22
  log.info(