@remit/smtp-worker 0.0.20 → 0.0.22
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 +2 -2
- package/src/handlers/send-message-core.ts +126 -11
- package/src/handlers/send-message.test.ts +200 -1
- package/src/handlers/send-message.ts +47 -41
- package/src/index.test.ts +93 -0
- package/src/index.ts +17 -1
- package/src/processor.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/smtp-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
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=
|
|
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
|
|
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.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
|
390
|
+
"Transient failure retry exhausted; settling as failed",
|
|
275
391
|
);
|
|
276
|
-
|
|
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 {
|
|
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: () => {},
|
|
@@ -324,6 +329,24 @@ describe("sendMessage handler", () => {
|
|
|
324
329
|
assert.equal(recorded.updates.length, 0);
|
|
325
330
|
});
|
|
326
331
|
|
|
332
|
+
it("drops the event for a row the enqueue settled at `failed`", async () => {
|
|
333
|
+
// The other half of #936. An error from SQS `SendMessage` says the response
|
|
334
|
+
// was lost, not that the broker refused the event, so the row is settled
|
|
335
|
+
// outside this fence and the event that landed anyway dies here. Settling
|
|
336
|
+
// it at `draft` instead would send it — and leave the user a row they can
|
|
337
|
+
// send a second time.
|
|
338
|
+
const { deps, recorded } = buildDeps({
|
|
339
|
+
outbox: buildOutbox({ status: "failed" }),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await sendMessage(event, silentLogger, deps);
|
|
343
|
+
|
|
344
|
+
assert.equal(recorded.sendCalls, 0, "must not send");
|
|
345
|
+
assert.equal(recorded.marked.length, 0);
|
|
346
|
+
assert.equal(recorded.updates.length, 0);
|
|
347
|
+
assert.equal(recorded.statuses.length, 0);
|
|
348
|
+
});
|
|
349
|
+
|
|
327
350
|
it("settles the row as unfiled when the append event cannot be queued", async () => {
|
|
328
351
|
const { deps, recorded } = buildDeps({
|
|
329
352
|
account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
|
|
@@ -653,3 +676,179 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
|
|
|
653
676
|
);
|
|
654
677
|
});
|
|
655
678
|
});
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* A connection failure as `smtp-client.ts` raises one: the code nodemailer put
|
|
682
|
+
* on the underlying error is what says how far the submission got, and it
|
|
683
|
+
* survives only on the cause.
|
|
684
|
+
*/
|
|
685
|
+
const connectionError = (code: string): SmtpConnectionError =>
|
|
686
|
+
new SmtpConnectionError(
|
|
687
|
+
"network",
|
|
688
|
+
`SMTP connection failed: ${code}`,
|
|
689
|
+
Object.assign(new Error(code), { code }),
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
describe("sendMessage retry-budget exhaustion (issue #951)", () => {
|
|
693
|
+
it("rethrows a password account's auth failure below the retry budget, leaving the row `sending`", async () => {
|
|
694
|
+
const { deps, recorded } = buildDeps({
|
|
695
|
+
account: buildAccount({
|
|
696
|
+
smtpHost: "smtp.example.com",
|
|
697
|
+
smtpPort: 587,
|
|
698
|
+
authType: AccountAuthType.Password,
|
|
699
|
+
}),
|
|
700
|
+
send: async () => {
|
|
701
|
+
throw new SmtpConnectionError("auth", "535 authentication failed");
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
await assert.rejects(
|
|
706
|
+
() =>
|
|
707
|
+
sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS - 1),
|
|
708
|
+
/535 authentication failed/,
|
|
709
|
+
);
|
|
710
|
+
assert.equal(
|
|
711
|
+
recorded.updates.length,
|
|
712
|
+
0,
|
|
713
|
+
"must not settle the row before the retry budget is spent",
|
|
714
|
+
);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("settles a password account's exhausted auth failure at `failed`, not stranded at `sending`", async () => {
|
|
718
|
+
const { deps, recorded } = buildDeps({
|
|
719
|
+
account: buildAccount({
|
|
720
|
+
smtpHost: "smtp.example.com",
|
|
721
|
+
smtpPort: 587,
|
|
722
|
+
authType: AccountAuthType.Password,
|
|
723
|
+
}),
|
|
724
|
+
send: async () => {
|
|
725
|
+
throw new SmtpConnectionError("auth", "535 authentication failed");
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
|
|
730
|
+
|
|
731
|
+
assert.equal(recorded.connectionStateUpdates.length, 0);
|
|
732
|
+
const failedUpdate = recorded.updates.find(
|
|
733
|
+
(u) => u.patch.status === "failed",
|
|
734
|
+
);
|
|
735
|
+
assert.ok(failedUpdate, "should settle the row as failed");
|
|
736
|
+
assert.match(String(failedUpdate.patch.lastError), /authentication failed/);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("settles an exhausted refused connection at `failed`, not stranded at `sending`", async () => {
|
|
740
|
+
const { deps, recorded } = buildDeps({
|
|
741
|
+
account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
|
|
742
|
+
send: async () => {
|
|
743
|
+
throw connectionError("ECONNREFUSED");
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
|
|
748
|
+
|
|
749
|
+
const failedUpdate = recorded.updates.find(
|
|
750
|
+
(u) => u.patch.status === "failed",
|
|
751
|
+
);
|
|
752
|
+
assert.ok(failedUpdate, "should settle the row as failed");
|
|
753
|
+
assert.match(String(failedUpdate.patch.lastError), /ECONNREFUSED/);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
for (const code of ["ECONNRESET", "ETIMEDOUT"]) {
|
|
757
|
+
it(`settles an exhausted ${code} at \`unfiled\` — the server may hold the message`, async () => {
|
|
758
|
+
// Both classify as `network` and both can land after DATA, so a
|
|
759
|
+
// `failed` row here would offer a Retry that delivers a second copy.
|
|
760
|
+
const { deps, recorded } = buildDeps({
|
|
761
|
+
account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
|
|
762
|
+
send: async () => {
|
|
763
|
+
throw connectionError(code);
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
|
|
768
|
+
|
|
769
|
+
assert.equal(
|
|
770
|
+
recorded.updates.find((u) => u.patch.status === "failed"),
|
|
771
|
+
undefined,
|
|
772
|
+
"a row that may have been delivered must not be re-sendable",
|
|
773
|
+
);
|
|
774
|
+
const settled = recorded.updates.at(-1)?.patch;
|
|
775
|
+
assert.equal(settled?.status, "unfiled");
|
|
776
|
+
assert.match(
|
|
777
|
+
String(settled?.lastError),
|
|
778
|
+
/may already have been delivered/,
|
|
779
|
+
);
|
|
780
|
+
assert.match(String(settled?.lastError), new RegExp(code));
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
it("settles an exhausted connection failure that names no code at `unfiled`", async () => {
|
|
785
|
+
// Nothing says the message did not reach the server, and the answer that
|
|
786
|
+
// cannot produce a second copy is the one to settle on.
|
|
787
|
+
const { deps, recorded } = buildDeps({
|
|
788
|
+
account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
|
|
789
|
+
send: async () => {
|
|
790
|
+
throw new SmtpConnectionError("network", "SMTP connection failed");
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
|
|
795
|
+
|
|
796
|
+
assert.equal(recorded.updates.at(-1)?.patch.status, "unfiled");
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("settles an exhausted transient SMTP failure at `failed` instead of leaving it `queued` forever", async () => {
|
|
800
|
+
const { deps, recorded } = buildDeps({
|
|
801
|
+
account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
|
|
802
|
+
sendResult: {
|
|
803
|
+
success: false,
|
|
804
|
+
error: new Error("temporarily unavailable"),
|
|
805
|
+
smtpCode: 421,
|
|
806
|
+
isTransient: true,
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
|
|
811
|
+
|
|
812
|
+
const failedUpdate = recorded.updates.find(
|
|
813
|
+
(u) => u.patch.status === "failed",
|
|
814
|
+
);
|
|
815
|
+
assert.ok(failedUpdate, "should settle the row as failed");
|
|
816
|
+
assert.equal(failedUpdate.patch.lastSmtpCode, 421);
|
|
817
|
+
assert.equal(
|
|
818
|
+
recorded.statuses.find((s) => s.status === "queued"),
|
|
819
|
+
undefined,
|
|
820
|
+
"must not leave the row requeued once the budget is spent",
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
describe("the retry budget is the queue's, read from the environment", () => {
|
|
826
|
+
it("takes the deployment's own maxReceiveCount when it is set", () => {
|
|
827
|
+
// The e2e stack sets 1 (`deploy/vps/e2e.env`): its pollers hold a record
|
|
828
|
+
// for 300s, so a spec cannot wait out three deliveries.
|
|
829
|
+
assert.equal(
|
|
830
|
+
getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "1" }),
|
|
831
|
+
1,
|
|
832
|
+
);
|
|
833
|
+
assert.equal(
|
|
834
|
+
getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "5" }),
|
|
835
|
+
5,
|
|
836
|
+
);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("falls back to remit-smtp's maxReceiveCount when it is unset or unusable", () => {
|
|
840
|
+
assert.equal(getSendMessageMaxAttempts({}), 3);
|
|
841
|
+
assert.equal(
|
|
842
|
+
getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "" }),
|
|
843
|
+
3,
|
|
844
|
+
);
|
|
845
|
+
assert.equal(
|
|
846
|
+
getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "not-a-number" }),
|
|
847
|
+
3,
|
|
848
|
+
);
|
|
849
|
+
assert.equal(
|
|
850
|
+
getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "0" }),
|
|
851
|
+
3,
|
|
852
|
+
);
|
|
853
|
+
});
|
|
854
|
+
});
|
|
@@ -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(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
(
|
|
145
|
-
accountConfigId,
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
175
|
-
|
|
150
|
+
id,
|
|
151
|
+
status,
|
|
176
152
|
),
|
|
177
|
-
|
|
178
|
-
|
|
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(
|