@remit/smtp-worker 1.0.6 → 1.0.8

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": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -226,10 +226,20 @@ const attach = async (
226
226
  });
227
227
  };
228
228
 
229
- const refusingSocket =
230
- (code: string): SendMessageContext["getSocket"] =>
229
+ const closedPort = (): Promise<number> =>
230
+ new Promise((resolve) => {
231
+ const server = net.createServer();
232
+ server.listen(0, "127.0.0.1", () => {
233
+ const address = server.address();
234
+ const port = typeof address === "object" && address ? address.port : 0;
235
+ server.close(() => resolve(port));
236
+ });
237
+ });
238
+
239
+ const socketTo =
240
+ (port: number): SendMessageContext["getSocket"] =>
231
241
  (_request, callback) => {
232
- callback(Object.assign(new Error(`connect ${code}`), { code }), false);
242
+ callback(null, { connection: net.connect(port, "127.0.0.1") });
233
243
  };
234
244
 
235
245
  const seedAddress = async (w: World, email: string): Promise<string> => {
@@ -675,7 +685,7 @@ describe("handleSendMessage retry-budget exhaustion (issue #951)", () => {
675
685
 
676
686
  await handleSendMessage(
677
687
  a.event,
678
- { ...w.ctx, getSocket: refusingSocket("ECONNREFUSED"), maxAttempts: 1 },
688
+ { ...w.ctx, getSocket: socketTo(await closedPort()), maxAttempts: 1 },
679
689
  1,
680
690
  );
681
691
 
@@ -685,24 +695,39 @@ describe("handleSendMessage retry-budget exhaustion (issue #951)", () => {
685
695
  assert.deepEqual(w.failures, ["network"]);
686
696
  });
687
697
 
688
- for (const code of ["ECONNRESET", "ETIMEDOUT"]) {
689
- it(`settles an exhausted ${code} at \`unfiled\` — the server may hold the message`, async () => {
690
- // Both classify as `network` and both can land after DATA, so a
691
- // `failed` row here would offer a Retry that delivers a second copy.
692
- const a = await arrange(w);
698
+ it("retries a connection the server drops before DATA, then settles it `failed`", async () => {
699
+ w.close();
700
+ w = await openWorld({ dropBeforeGreeting: true });
701
+ const a = await arrange(w);
693
702
 
694
- await handleSendMessage(
695
- a.event,
696
- { ...w.ctx, getSocket: refusingSocket(code) },
697
- MAX_ATTEMPTS,
698
- );
703
+ await assert.rejects(
704
+ () => handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS - 1),
705
+ SmtpConnectionError,
706
+ );
707
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
699
708
 
700
- const row = await outboxRow(w, a);
701
- assert.equal(row.status, OutboxMessageStatus.unfiled);
702
- assert.match(String(row.lastError), /may already have been delivered/);
703
- assert.match(String(row.lastError), new RegExp(code));
704
- });
705
- }
709
+ const row = await outboxRow(w, a);
710
+ assert.equal(row.status, OutboxMessageStatus.failed);
711
+ assert.equal(w.smtp.received.length, 0);
712
+ assert.deepEqual(w.failures, ["network"]);
713
+ });
714
+
715
+ it("settles a connection the server closes after DATA at `unfiled`, sending once", async () => {
716
+ w.close();
717
+ w = await openWorld({ dropAfterData: true });
718
+ const a = await arrange(w);
719
+
720
+ await handleSendMessage(a.event, w.ctx, 1);
721
+
722
+ const row = await outboxRow(w, a);
723
+ assert.equal(row.status, OutboxMessageStatus.unfiled);
724
+ assert.match(String(row.lastError), /may already have been delivered/);
725
+ assert.equal(
726
+ w.smtp.received.filter((chunk) => chunk.includes("\r\n.\r\n")).length,
727
+ 1,
728
+ );
729
+ assert.deepEqual(w.failures, ["network"]);
730
+ });
706
731
 
707
732
  it("settles an exhausted transient SMTP refusal at `failed` instead of leaving it `queued` forever", async () => {
708
733
  w.close();
@@ -148,35 +148,6 @@ const REAUTH_REQUIRED =
148
148
  const UNFILED_CONNECTION_LOST =
149
149
  "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.";
150
150
 
151
- /**
152
- * The connection failures that prove nothing was submitted: no session ever
153
- * opened, so the server holds no copy and `failed` is safe — Retry sends the
154
- * only copy there is.
155
- *
156
- * `ECONNRESET` and `ETIMEDOUT` classify as `network` too and are deliberately
157
- * absent. Either can land after DATA, with the message already queued on the
158
- * server, and a `failed` row invites a Retry that delivers it twice.
159
- */
160
- const NEVER_SUBMITTED_CODES: ReadonlySet<string> = new Set([
161
- "ECONNREFUSED",
162
- "ENOTFOUND",
163
- "EHOSTUNREACH",
164
- ]);
165
-
166
- const errorCode = (cause: unknown): string =>
167
- cause instanceof Error && "code" in cause && typeof cause.code === "string"
168
- ? cause.code
169
- : "";
170
-
171
- /**
172
- * Whether the message can be re-sent without risking a second copy. An auth
173
- * rejection is decided before the envelope; a connection failure carries the
174
- * code that says how far it got. Anything else counts as possibly delivered,
175
- * which is the answer that cannot produce a duplicate.
176
- */
177
- const neverSubmitted = (err: SmtpConnectionError): boolean =>
178
- err.kind === "auth" || NEVER_SUBMITTED_CODES.has(errorCode(err.cause));
179
-
180
151
  const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
181
152
  OutboxMessageStatus.draft,
182
153
  OutboxMessageStatus.queued,
@@ -408,27 +379,18 @@ export const handleSendMessage = async (
408
379
  await settleBlockedOnReauth(OutboxMessageStatus.sending);
409
380
  return; // ACK — do not retry
410
381
  }
411
- // A password account's auth failure and every network failure retry on
412
- // SQS redelivery until the queue's own budget runs out, then settle
413
- // here instead of dead-lettering with the row stuck at `sending`
414
- // (issue #951). Where it settles is the double-send question:
415
- // `neverSubmitted` says the message cannot be on the server, so
416
- // `failed` offers the Retry the user needs; anything else settles
417
- // `unfiled`, the state that says a copy may be out there and which
418
- // Retry is not offered on. Wait-or-reconcile
419
- // (docs/architecture/imap-mutations.md R2): neither applies — a
420
- // submission leaves no server-side handle to reconcile against, so the
421
- // row settles on what the failure itself proves.
382
+ // Once the whole message is written the server may hold a copy, so a
383
+ // redelivery or a Retry could deliver it twice.
422
384
  if (err instanceof SmtpConnectionError) {
423
- if (receiveCount < ctx.maxAttempts) {
385
+ if (!err.afterData && receiveCount < ctx.maxAttempts) {
424
386
  throw err;
425
387
  }
426
- const settled = neverSubmitted(err)
427
- ? { status: OutboxMessageStatus.failed, lastError: err.message }
428
- : {
388
+ const settled = err.afterData
389
+ ? {
429
390
  status: OutboxMessageStatus.unfiled,
430
391
  lastError: `${UNFILED_CONNECTION_LOST} (${err.message})`,
431
- };
392
+ }
393
+ : { status: OutboxMessageStatus.failed, lastError: err.message };
432
394
  await ports.outboxMessage.update(
433
395
  accountConfigId,
434
396
  outboxMessageId,
@@ -444,7 +406,7 @@ export const handleSendMessage = async (
444
406
  receiveCount,
445
407
  status: settled.status,
446
408
  },
447
- "SMTP send retry exhausted; settling the row",
409
+ "SMTP connection failed; settling the row",
448
410
  );
449
411
  return; // ACK — settled terminal, no more retries
450
412
  }