@remit/smtp-worker 0.0.19 → 0.0.20

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.19",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -4,7 +4,7 @@ import type {
4
4
  OutboxMessageItem,
5
5
  UpdateOutboxMessageInput,
6
6
  } from "@remit/data-ports";
7
- import { AccountAuthType } from "@remit/domain-enums";
7
+ import { AccountAuthType, OutboxMessageStatus } from "@remit/domain-enums";
8
8
  import type { Logger } from "@remit/logger-lambda";
9
9
  import { RefreshTokenError } from "@remit/mail-oauth-service";
10
10
  import type { SecretsService } from "@remit/secrets-service";
@@ -90,6 +90,15 @@ export interface SendMessageDeps {
90
90
  engagement: EngagementCounterDeps;
91
91
  }
92
92
 
93
+ const UNFILED_NOT_QUEUED =
94
+ "Sent, but not filed: the copy for the Sent folder could not be queued.";
95
+
96
+ const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
97
+ OutboxMessageStatus.draft,
98
+ OutboxMessageStatus.queued,
99
+ OutboxMessageStatus.sending,
100
+ ]);
101
+
93
102
  export const sendMessage = async (
94
103
  event: SendMessageEvent,
95
104
  log: Logger,
@@ -106,8 +115,15 @@ export const sendMessage = async (
106
115
  const { accountConfigId } = account;
107
116
 
108
117
  const outbox = await deps.getOutbox(accountConfigId, outboxMessageId);
109
- if (outbox.status === "sent") {
110
- log.info({ outboxMessageId }, "Message already sent, skipping");
118
+ // The fence against SQS at-least-once redelivery, stated as the states a
119
+ // send may proceed FROM. Every other state has already been on the wire, so
120
+ // naming them one by one is how a state added later silently starts sending
121
+ // twice.
122
+ if (!SENDABLE_STATUSES.has(outbox.status)) {
123
+ log.info(
124
+ { outboxMessageId, status: outbox.status },
125
+ "Message already left the queue, skipping",
126
+ );
111
127
  return;
112
128
  }
113
129
 
@@ -229,13 +245,21 @@ export const sendMessage = async (
229
245
  );
230
246
  });
231
247
 
248
+ // The filing is what deletes this row, so an enqueue that never lands
249
+ // leaves it at `sent` — a delivered message the Outbox hides and no Sent
250
+ // folder holds. Settle it here instead: the send itself is done, so
251
+ // throwing would only redeliver an event that cannot re-send.
232
252
  await deps
233
253
  .emitAppendSentMessage(accountId, outboxMessageId)
234
- .catch((error: unknown) => {
235
- log.warn(
254
+ .catch(async (error: unknown) => {
255
+ log.error(
236
256
  { outboxMessageId, error: String(error) },
237
- "Failed to enqueue APPEND_SENT_MESSAGE (best-effort)",
257
+ "Failed to enqueue APPEND_SENT_MESSAGE; settling the row as unfiled",
238
258
  );
259
+ await deps.updateOutbox(accountConfigId, outboxMessageId, {
260
+ status: OutboxMessageStatus.unfiled,
261
+ lastError: UNFILED_NOT_QUEUED,
262
+ });
239
263
  });
240
264
  return;
241
265
  }
@@ -309,6 +309,37 @@ describe("sendMessage handler", () => {
309
309
  assert.equal(recorded.statuses.length, 0);
310
310
  });
311
311
 
312
+ it("never re-sends a message that was sent but could not be filed", async () => {
313
+ // The redelivery fence is the only thing between SQS at-least-once and a
314
+ // second copy on the wire. `unfiled` is past the send, so it belongs on
315
+ // the same side of the fence as `sent`.
316
+ const { deps, recorded } = buildDeps({
317
+ outbox: buildOutbox({ status: "unfiled" }),
318
+ });
319
+
320
+ await sendMessage(event, silentLogger, deps);
321
+
322
+ assert.equal(recorded.sendCalls, 0);
323
+ assert.equal(recorded.marked.length, 0);
324
+ assert.equal(recorded.updates.length, 0);
325
+ });
326
+
327
+ it("settles the row as unfiled when the append event cannot be queued", async () => {
328
+ const { deps, recorded } = buildDeps({
329
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
330
+ appendThrows: new Error("queue unreachable"),
331
+ });
332
+
333
+ await sendMessage(event, silentLogger, deps);
334
+
335
+ // The send itself succeeded. Without a settle the row keeps the `sent`
336
+ // status the Outbox list hides, and no APPEND is ever going to delete it.
337
+ assert.equal(recorded.marked.length, 1);
338
+ const patch = recorded.updates.at(-1)?.patch;
339
+ assert.equal(patch?.status, "unfiled");
340
+ assert.match(String(patch?.lastError), /could not be queued/);
341
+ });
342
+
312
343
  it("drops event when account is deleted (tombstone fence)", async () => {
313
344
  const { deps, recorded } = buildDeps({
314
345
  account: buildAccount({