@remit/imap-worker 0.0.85 → 0.0.87

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.
Files changed (73) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.test.ts +32 -4
  8. package/src/emit.ts +58 -23
  9. package/src/failure-kind.ts +1 -1
  10. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  11. package/src/handlers/append-sent-message.ts +3 -24
  12. package/src/handlers/delete-account-objects.test.ts +1 -1
  13. package/src/handlers/delete-account-objects.ts +1 -1
  14. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  15. package/src/handlers/empty-trash.ts +3 -24
  16. package/src/handlers/flag-push.ts +11 -18
  17. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  18. package/src/handlers/mailbox-management.ts +12 -48
  19. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  20. package/src/handlers/message-copy.ts +31 -49
  21. package/src/handlers/message-delete-terminal.ts +12 -10
  22. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  23. package/src/handlers/message-delete.ts +26 -32
  24. package/src/handlers/message-move-terminal.ts +12 -10
  25. package/src/handlers/message-move.sqlite.test.ts +271 -0
  26. package/src/handlers/message-move.test.ts +76 -863
  27. package/src/handlers/message-move.ts +49 -39
  28. package/src/handlers/placement-move-push.test.ts +96 -329
  29. package/src/handlers/placement-move-push.ts +75 -29
  30. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  31. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  32. package/src/handlers/probe-message-id.ts +15 -0
  33. package/src/handlers/sync-mailboxes.test.ts +0 -1
  34. package/src/handlers/sync-mailboxes.ts +1 -1
  35. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  36. package/src/handlers/sync-message-body.test.ts +3 -299
  37. package/src/handlers/sync-message-body.ts +21 -11
  38. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  39. package/src/handlers/sync-messages.ts +35 -34
  40. package/src/index.ts +5 -3
  41. package/src/mail-sync-selection.sqlite.test.ts +20 -18
  42. package/src/pending-sync.sqlite.test.ts +167 -0
  43. package/src/poller.ts +5 -4
  44. package/src/processor.test.ts +1 -1
  45. package/src/processor.ts +1 -1
  46. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  47. package/src/scheduler/attachment-sweep.ts +1 -1
  48. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  49. package/src/scheduler/calendar-subscriptions.ts +6 -6
  50. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  51. package/src/scheduler/handler.ts +12 -1
  52. package/src/scheduler/loop.test.ts +2 -0
  53. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  54. package/src/scheduler/run-tick.ts +39 -1
  55. package/src/scheduler/runner.ts +21 -1
  56. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  57. package/src/stranded-placement.sqlite.test.ts +795 -0
  58. package/src/test-helpers/test-world.ts +398 -0
  59. package/src/thread-join.sqlite.test.ts +115 -0
  60. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  61. package/src/with-oauth-lifecycle.ts +1 -1
  62. package/src/handlers/append-sent-message.test.ts +0 -529
  63. package/src/handlers/empty-trash.test.ts +0 -728
  64. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  65. package/src/handlers/flag-push.test.ts +0 -325
  66. package/src/handlers/mailbox-management.test.ts +0 -850
  67. package/src/handlers/message-copy.test.ts +0 -654
  68. package/src/handlers/message-delete.test.ts +0 -1869
  69. package/src/handlers/message-move-terminal.test.ts +0 -412
  70. package/src/handlers/sync-messages.test.ts +0 -567
  71. package/src/scheduler/attachment-sweep.test.ts +0 -169
  72. package/src/scheduler/run-tick.test.ts +0 -478
  73. package/src/with-oauth-lifecycle.test.ts +0 -318
package/README.md CHANGED
@@ -48,7 +48,9 @@ npm run cli -- -t SYNC_MESSAGE_BODY -a <accountId> -m <mailboxId> --messageIds i
48
48
  | Variable | Required | Description |
49
49
  | ----------------------------- | -------- | -------------------------------------------------------- |
50
50
  | `DATA_BACKEND` | Yes | Relational backend to run against (`sqlite`) |
51
- | `SQLITE_DB_PATH` | Yes | Path to the shared SQLite database file |
51
+ | `SQLITE_APP_DB_PATH` | Yes | Path to the app SQLite database |
52
+ | `SQLITE_MAIL_DB_PATH` | Yes | Path to the tenant mail SQLite database |
53
+ | `SQLITE_SEARCH_DB_PATH` | Yes | Path to the tenant search SQLite database |
52
54
  | `SQS_QUEUE_URL_MAILBOXES` | Yes | FIFO queue URL for `SYNC_MAILBOXES` events |
53
55
  | `SQS_QUEUE_URL_MESSAGES` | Yes | FIFO queue URL for `SYNC_MESSAGES` events |
54
56
  | `SQS_QUEUE_URL_BODY` | Yes | FIFO queue URL for `SYNC_MESSAGE_BODY` events |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.85",
3
+ "version": "0.0.87",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -11,6 +11,14 @@
11
11
  "./scheduler": {
12
12
  "types": "./src/scheduler/handler.ts",
13
13
  "default": "./src/scheduler/handler.ts"
14
+ },
15
+ "./mailbox-management": {
16
+ "types": "./src/handlers/mailbox-management.ts",
17
+ "default": "./src/handlers/mailbox-management.ts"
18
+ },
19
+ "./test-helpers/test-world": {
20
+ "types": "./src/test-helpers/test-world.ts",
21
+ "default": "./src/test-helpers/test-world.ts"
14
22
  }
15
23
  },
16
24
  "bin": {
@@ -32,13 +40,13 @@
32
40
  "esbuild": "^0.28.1"
33
41
  },
34
42
  "dependencies": {
43
+ "@remit/logger": "*",
35
44
  "@aws-sdk/client-s3": "^3.1055.0",
36
45
  "@remit/config-transfer": "*",
37
46
  "@remit/data-ports": "*",
38
47
  "@remit/smtp-service": "*",
39
48
  "@remit/logger-lambda": "*",
40
49
  "@remit/mailbox-service": "*",
41
- "@remit/search-index-worker": "*",
42
50
  "@remit/sqs-client": "*",
43
51
  "@remit/backend": "*",
44
52
  "@remit/calendar-service": "*",
@@ -1,6 +1,6 @@
1
1
  import type { AccountItem } from "@remit/data-ports";
2
2
  import { AccountService } from "@remit/domain-enums";
3
- import type { Logger } from "@remit/logger-lambda";
3
+ import type { Logger } from "@remit/logger";
4
4
 
5
5
  /**
6
6
  * RFC 2606 reserved placeholder namespaces that are guaranteed never to resolve
@@ -5,7 +5,7 @@ import {
5
5
  ParameterNotFound,
6
6
  SSMClient,
7
7
  } from "@aws-sdk/client-ssm";
8
- import type { Logger } from "@remit/logger-lambda";
8
+ import type { Logger } from "@remit/logger";
9
9
  import { mockClient } from "aws-sdk-client-mock";
10
10
  import { isBodySyncEnabled, resetBodySyncGateCache } from "./body-sync-gate.js";
11
11
 
@@ -1,5 +1,5 @@
1
1
  import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
2
- import type { Logger } from "@remit/logger-lambda";
2
+ import type { Logger } from "@remit/logger";
3
3
 
4
4
  const CACHE_TTL_MS = 30_000;
5
5
 
@@ -5,18 +5,15 @@ import {
5
5
  DeleteMessageCommand,
6
6
  ReceiveMessageCommand,
7
7
  } from "@aws-sdk/client-sqs";
8
- import { createLogger } from "@remit/logger-lambda";
9
- import { handler as searchIndexHandler } from "@remit/search-index-worker";
8
+ import { createLogger } from "@remit/logger";
9
+ import { installNodeLogger } from "@remit/logger-lambda/node";
10
10
  import { createQueueProducer } from "@remit/sqs-client/producer";
11
- import type {
12
- Context,
13
- SQSBatchResponse,
14
- SQSEvent,
15
- SQSHandler,
16
- } from "aws-lambda";
11
+ import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
17
12
  import { env } from "expect-env";
18
13
  import { handler } from "./index.js";
19
14
 
15
+ installNodeLogger();
16
+
20
17
  /**
21
18
  * E2E-only queue drainer.
22
19
  *
@@ -34,11 +31,6 @@ import { handler } from "./index.js";
34
31
  * fails loudly, which is the desired signal — there is no crash net.
35
32
  */
36
33
 
37
- // The search-index queue is drained by the production search-index-worker
38
- // handler instead of the imap handler. It is optional: when its URL is unset
39
- // (e.g. the e2e stack), the queue is simply not polled.
40
- const searchIndexQueueUrl = process.env.SQS_QUEUE_URL_SEARCH_INDEX;
41
-
42
34
  // Collect all unique queue URLs to poll. Every required queue URL crashes at
43
35
  // init via expect-env instead of silently dropping queues.
44
36
  const queueUrls = [
@@ -51,8 +43,6 @@ const queueUrls = [
51
43
  env.SQS_QUEUE_URL_BODY,
52
44
  env.SQS_QUEUE_URL_MAILBOX_MGMT,
53
45
  env.SQS_QUEUE_URL_MESSAGE_MGMT,
54
- // Standard queue for local search indexing (optional)
55
- ...(searchIndexQueueUrl ? [searchIndexQueueUrl] : []),
56
46
  ]),
57
47
  ];
58
48
 
@@ -109,11 +99,6 @@ if (cluster.isPrimary) {
109
99
  const queueName = new URL(queueUrl).pathname.split("/").pop();
110
100
  const log = createLogger().child({ queue: queueName });
111
101
 
112
- // The search-index queue is owned by the search-index-worker handler; every
113
- // other queue is an imap operation.
114
- const activeHandler: SQSHandler =
115
- queueUrl === searchIndexQueueUrl ? searchIndexHandler : handler;
116
-
117
102
  const maxMessages = 10; // SQS API limit
118
103
 
119
104
  const sqs = createQueueProducer({ queueUrl });
@@ -206,7 +191,7 @@ if (cluster.isPrimary) {
206
191
  })),
207
192
  };
208
193
 
209
- const result = (await activeHandler(event, nextContext(), () => {})) as
194
+ const result = (await handler(event, nextContext(), () => {})) as
210
195
  | SQSBatchResponse
211
196
  | undefined;
212
197
 
package/src/emit.test.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
- import { before, beforeEach, describe, it } from "node:test";
2
+ import { afterEach, before, beforeEach, describe, it } from "node:test";
3
3
  import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
+ import {
5
+ _resetForTest,
6
+ type RemitClient,
7
+ setClient,
8
+ } from "@remit/backend/client";
9
+ import { MailboxLockRepo } from "@remit/drizzle-service";
10
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
4
11
  import { mockClient } from "aws-sdk-client-mock";
5
- import type { emitEvent as EmitEvent } from "./emit.js";
12
+ import type * as Emit from "./emit.js";
6
13
  import type {
7
14
  FlagPushEvent,
8
15
  SyncMailboxesEvent,
@@ -20,7 +27,10 @@ const fifo = (name: string) =>
20
27
 
21
28
  const sqsMock = mockClient(SQSClient);
22
29
 
23
- let emitEvent: typeof EmitEvent;
30
+ let emitEvent: typeof Emit.emitEvent;
31
+ let SYNC_MESSAGES_PENDING: typeof Emit.SYNC_MESSAGES_PENDING;
32
+ let locks: MailboxLockRepo;
33
+ let closeStore: () => void;
24
34
 
25
35
  const sentCommands = (): SendMessageCommand["input"][] =>
26
36
  sqsMock.commandCalls(SendMessageCommand).map((call) => call.args[0].input);
@@ -32,11 +42,21 @@ before(async () => {
32
42
  process.env.SQS_QUEUE_URL_MAILBOXES = fifo("mailboxes");
33
43
  process.env.SQS_QUEUE_URL_MESSAGES = fifo("messages");
34
44
  process.env.SQS_QUEUE_URL_FLAGS = fifo("flags");
35
- ({ emitEvent } = await import("./emit.js"));
45
+ ({ emitEvent, SYNC_MESSAGES_PENDING } = await import("./emit.js"));
36
46
  });
37
47
 
38
48
  beforeEach(() => {
39
49
  sqsMock.reset();
50
+ sqsMock.on(SendMessageCommand).resolves({});
51
+ const store = createShippedSqliteDb();
52
+ closeStore = store.close;
53
+ locks = new MailboxLockRepo(store.db);
54
+ setClient({ mailboxLock: locks } as unknown as RemitClient);
55
+ });
56
+
57
+ afterEach(() => {
58
+ _resetForTest();
59
+ closeStore();
40
60
  });
41
61
 
42
62
  describe("emitEvent on a FIFO queue", () => {
@@ -68,6 +88,14 @@ describe("emitEvent on a FIFO queue", () => {
68
88
  };
69
89
 
70
90
  await emitEvent(event);
91
+ const [first] = sentCommands();
92
+ if (!first) throw new Error("expected a send");
93
+ await locks.releaseLock(
94
+ "acc-1",
95
+ "mbx-1",
96
+ SYNC_MESSAGES_PENDING,
97
+ String(bodyOf(first).eventId),
98
+ );
71
99
  await emitEvent(event);
72
100
 
73
101
  const sent = sentCommands();
package/src/emit.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { SendMessageCommand } from "@aws-sdk/client-sqs";
3
+ import { getClient } from "@remit/backend/client";
4
+ import { recordImapFailure } from "@remit/logger-lambda/metrics";
3
5
  import {
4
6
  createQueueProducer,
5
7
  isLocalEndpoint,
@@ -9,6 +11,10 @@ import type { ImapEvent } from "./events.js";
9
11
 
10
12
  type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
11
13
 
14
+ export const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
15
+
16
+ const SYNC_MESSAGES_PENDING_TTL_SECONDS = 300;
17
+
12
18
  const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
13
19
  const messagesQueueUrl = env.SQS_QUEUE_URL_MESSAGES;
14
20
  // SYNC_MESSAGE_BODY events route to the single standard body queue (#612). It is
@@ -77,29 +83,58 @@ export const emitEvent = async (
77
83
  // ElasticMQ FIFO queues don't support per-message DelaySeconds
78
84
  const useDelay = options?.delaySeconds && !isLocal;
79
85
 
80
- await sqs.send(
81
- new SendMessageCommand({
82
- QueueUrl: queueUrl,
83
- MessageBody: JSON.stringify(fullEvent),
84
- // Delay delivery for retry backoff (skip for local ElasticMQ)
85
- ...(useDelay && { DelaySeconds: options.delaySeconds }),
86
- // FIFO queue parameters — only set if the queue is FIFO. The
87
- // deduplication id is the event's own id, so the queue suppresses a
88
- // re-send of one event (the retry SQS's own idempotency guard is for)
89
- // and nothing else. A shared id per account or per mailbox instead made
90
- // the 5-minute window a rate limiter: the second sync of a mailbox
91
- // within five minutes was discarded before any worker saw it, so mail
92
- // that arrived after a sync could not be fetched until the window
93
- // elapsed (issue #37).
94
- //
95
- // Dropping events is not how repeated work is bounded — that is the
96
- // freshness gate in the sync-mailboxes fan-out, which decides whether
97
- // a mailbox is worth enumerating at all. MessageGroupId only orders an
98
- // account's events; it makes duplicates serial, not cheap.
99
- ...(useFifo && {
100
- MessageGroupId: event.accountId,
101
- MessageDeduplicationId: fullEvent.eventId,
86
+ const send = () =>
87
+ sqs.send(
88
+ new SendMessageCommand({
89
+ QueueUrl: queueUrl,
90
+ MessageBody: JSON.stringify(fullEvent),
91
+ // Delay delivery for retry backoff (skip for local ElasticMQ)
92
+ ...(useDelay && { DelaySeconds: options.delaySeconds }),
93
+ // FIFO queue parameters — only set if the queue is FIFO. The
94
+ // deduplication id is the event's own id, so the queue suppresses a
95
+ // re-send of one event (the retry SQS's own idempotency guard is for)
96
+ // and nothing else. A shared id per account or per mailbox instead made
97
+ // the 5-minute window a rate limiter: the second sync of a mailbox
98
+ // within five minutes was discarded before any worker saw it, so mail
99
+ // that arrived after a sync could not be fetched until the window
100
+ // elapsed (issue #37).
101
+ ...(useFifo && {
102
+ MessageGroupId: event.accountId,
103
+ MessageDeduplicationId: fullEvent.eventId,
104
+ }),
102
105
  }),
103
- }),
106
+ );
107
+ if (fullEvent.type !== "SYNC_MESSAGES") {
108
+ await send();
109
+ return;
110
+ }
111
+
112
+ const { mailboxLock } = await getClient();
113
+ const { accountId, mailboxId, eventId } = fullEvent;
114
+ const previous = await mailboxLock.get(
115
+ accountId,
116
+ mailboxId,
117
+ SYNC_MESSAGES_PENDING,
104
118
  );
119
+ const acquired = await mailboxLock.tryAcquireLock(
120
+ mailboxId,
121
+ SYNC_MESSAGES_PENDING,
122
+ accountId,
123
+ eventId,
124
+ SYNC_MESSAGES_PENDING_TTL_SECONDS,
125
+ );
126
+ if (!acquired) return;
127
+ if (previous && previous.ttl < Math.floor(Date.now() / 1000)) {
128
+ recordImapFailure("SYNC_PENDING_EXPIRED", "other");
129
+ }
130
+
131
+ await send().catch(async (error: unknown) => {
132
+ await mailboxLock.releaseLock(
133
+ accountId,
134
+ mailboxId,
135
+ SYNC_MESSAGES_PENDING,
136
+ eventId,
137
+ );
138
+ throw error;
139
+ });
105
140
  };
@@ -1,4 +1,4 @@
1
- import type { MailFailureKind } from "@remit/logger-lambda";
1
+ import type { MailFailureKind } from "@remit/logger-lambda/metrics";
2
2
  import { RefreshTokenError } from "@remit/mail-oauth-service";
3
3
  import { MailConnectionError } from "@remit/mailbox-service";
4
4
 
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import type { OutboxMessageItem } from "@remit/data-ports";
4
+ import { MailboxSpecialUse, OutboxMessageStatus } from "@remit/domain-enums";
5
+ import type { AppendSentMessageEvent } from "../events.js";
6
+ import { openStore, type World } from "../test-helpers/test-world.js";
7
+ import { handleAppendSentMessage } from "./append-sent-message.js";
8
+
9
+ let store: Awaited<ReturnType<typeof openStore>>;
10
+
11
+ before(async () => {
12
+ store = await openStore();
13
+ });
14
+
15
+ after(() => store.close());
16
+
17
+ interface Arranged {
18
+ w: World;
19
+ outbox: OutboxMessageItem;
20
+ event: AppendSentMessageEvent;
21
+ }
22
+
23
+ /**
24
+ * A message composed and sent through the service the API calls, then marked
25
+ * sent the way the SMTP worker marks it once the relay accepted it.
26
+ */
27
+ const arrange = async (
28
+ options: { sentFolder?: boolean; attachment?: Buffer } = {},
29
+ ): Promise<Arranged> => {
30
+ const w = await store.world();
31
+ await w.folder("INBOX");
32
+ if (options.sentFolder !== false) {
33
+ await w.folder("Sent", { specialUse: MailboxSpecialUse.Sent });
34
+ }
35
+ const { outbox: draft } = await w.draft(options.attachment);
36
+ const outbox = await w.client.outboxQueue.send(
37
+ w.account.accountConfigId,
38
+ draft.outboxMessageId,
39
+ );
40
+ await w.client.outboxMessage.update(
41
+ w.account.accountConfigId,
42
+ outbox.outboxMessageId,
43
+ { status: OutboxMessageStatus.sent, sentAt: Date.now() },
44
+ );
45
+ return {
46
+ w,
47
+ outbox,
48
+ event: {
49
+ type: "APPEND_SENT_MESSAGE",
50
+ eventId: `evt-${outbox.outboxMessageId}`,
51
+ timestamp: Date.now(),
52
+ accountId: w.account.accountId,
53
+ outboxMessageId: outbox.outboxMessageId,
54
+ } as AppendSentMessageEvent,
55
+ };
56
+ };
57
+
58
+ const run = (a: Arranged, receiveCount = 1) =>
59
+ handleAppendSentMessage(a.event, a.w.log, receiveCount, a.w.scope);
60
+
61
+ const outboxRow = (a: Arranged) =>
62
+ a.w.client.outboxMessage
63
+ .get(a.w.account.accountConfigId, a.outbox.outboxMessageId)
64
+ .catch(() => undefined);
65
+
66
+ describe("filing a sent message", () => {
67
+ it("stores a seen copy under its own Message-ID in Sent and drops the outbox row", async () => {
68
+ const a = await arrange();
69
+
70
+ await run(a);
71
+
72
+ const [copy] = a.w.imap.messages("Sent");
73
+ assert.equal(copy?.messageIdHeader, `<${a.outbox.messageIdValue}>`);
74
+ assert.ok(copy?.flags.includes("\\Seen"));
75
+ assert.match(
76
+ copy?.raw.toString("utf8") ?? "",
77
+ /Subject: Quarterly numbers/,
78
+ );
79
+ assert.equal(await outboxRow(a), undefined);
80
+ });
81
+
82
+ // #679: the Sent copy carries the files the wire copy carried, and the row
83
+ // was the only reference to them, so they go with it.
84
+ it("files the copy with its attachment and removes the stored file", async () => {
85
+ const a = await arrange({ attachment: Buffer.from("q1,q2\n1,2\n") });
86
+
87
+ await run(a);
88
+
89
+ const raw = a.w.imap.messages("Sent")[0]?.raw.toString("utf8") ?? "";
90
+ assert.match(raw, /^Content-Type: multipart\/mixed;/m);
91
+ assert.match(raw, /filename=numbers\.csv/);
92
+ assert.deepEqual(
93
+ await a.w.client.storage.listOutboxAttachments(
94
+ a.w.account.accountConfigId,
95
+ a.w.account.accountId,
96
+ a.outbox.outboxMessageId,
97
+ ),
98
+ [],
99
+ );
100
+ });
101
+
102
+ it("settles the row as unfiled when the account has no Sent folder", async () => {
103
+ const a = await arrange({ sentFolder: false });
104
+
105
+ await run(a);
106
+
107
+ assert.equal(a.w.imap.connections, 0);
108
+ const row = await outboxRow(a);
109
+ assert.equal(row?.status, OutboxMessageStatus.unfiled);
110
+ assert.match(
111
+ String(row?.lastError),
112
+ /no folder appointed to the Sent role/,
113
+ );
114
+ });
115
+
116
+ it("files nothing while the message has not been sent yet", async () => {
117
+ const a = await arrange();
118
+ await a.w.client.outboxMessage.update(
119
+ a.w.account.accountConfigId,
120
+ a.outbox.outboxMessageId,
121
+ { status: OutboxMessageStatus.queued },
122
+ );
123
+
124
+ await run(a);
125
+
126
+ assert.deepEqual(a.w.imap.commands("APPEND"), []);
127
+ assert.equal((await outboxRow(a))?.status, OutboxMessageStatus.queued);
128
+ });
129
+
130
+ it("keeps the row and redelivers a refused APPEND within the budget", async () => {
131
+ const a = await arrange();
132
+ a.w.imap.failNext.APPEND = new Error("Connection reset");
133
+
134
+ await assert.rejects(run(a), /Connection reset/);
135
+
136
+ assert.equal((await outboxRow(a))?.status, OutboxMessageStatus.sent);
137
+ });
138
+
139
+ it("settles the row as unfiled on the last attempt instead of dead-lettering it", async () => {
140
+ const a = await arrange();
141
+ a.w.imap.failNext.APPEND = new Error("Mailbox is full");
142
+
143
+ await run(a, 3);
144
+
145
+ const row = await outboxRow(a);
146
+ assert.equal(row?.status, OutboxMessageStatus.unfiled);
147
+ assert.match(String(row?.lastError), /Mailbox is full/);
148
+ });
149
+
150
+ // #858: a redelivery after the APPEND landed must not file a second copy.
151
+ it("files nothing a second time when the copy was already recorded", async () => {
152
+ const a = await arrange();
153
+ await a.w.client.outboxMessage.update(
154
+ a.w.account.accountConfigId,
155
+ a.outbox.outboxMessageId,
156
+ { appendedUid: 41 },
157
+ );
158
+
159
+ await run(a);
160
+
161
+ assert.deepEqual(a.w.imap.commands("APPEND"), []);
162
+ assert.equal(await outboxRow(a), undefined);
163
+ });
164
+
165
+ it("acks an event whose outbox row is already gone", async () => {
166
+ const a = await arrange();
167
+ await a.w.client.outboxMessage.delete(
168
+ a.w.account.accountConfigId,
169
+ a.outbox.outboxMessageId,
170
+ );
171
+
172
+ await run(a);
173
+
174
+ assert.equal(a.w.imap.connections, 0);
175
+ });
176
+ });
@@ -6,7 +6,7 @@ import {
6
6
  } from "@remit/data-ports";
7
7
  import { isNotFoundError } from "@remit/data-ports/errors";
8
8
  import { OutboxMessageStatus } from "@remit/domain-enums";
9
- import type { Logger } from "@remit/logger-lambda";
9
+ import type { Logger } from "@remit/logger";
10
10
  import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
11
11
  import {
12
12
  buildMailMessage,
@@ -39,20 +39,6 @@ export const getAppendSentMaxAttempts = (
39
39
 
40
40
  export const APPEND_SENT_MAX_ATTEMPTS = getAppendSentMaxAttempts();
41
41
 
42
- export interface AppendSentMessageDeps {
43
- getClient: typeof getClient;
44
- buildLifecycleDeps: typeof buildLifecycleDeps;
45
- withOAuthLifecycle: typeof withOAuthLifecycle;
46
- createConnectionScope: typeof createConnectionScopeWithCredentials;
47
- }
48
-
49
- const defaultDeps: AppendSentMessageDeps = {
50
- getClient,
51
- buildLifecycleDeps,
52
- withOAuthLifecycle,
53
- createConnectionScope: createConnectionScopeWithCredentials,
54
- };
55
-
56
42
  export const handleAppendSentMessage = async (
57
43
  event: AppendSentMessageEvent,
58
44
  log: Logger,
@@ -63,15 +49,8 @@ export const handleAppendSentMessage = async (
63
49
  * leaves the row at `sent`, which no view shows.
64
50
  */
65
51
  receiveCount = 1,
66
- deps: AppendSentMessageDeps = defaultDeps,
52
+ createConnectionScope = createConnectionScopeWithCredentials,
67
53
  ): Promise<void> => {
68
- const {
69
- getClient,
70
- buildLifecycleDeps,
71
- withOAuthLifecycle,
72
- createConnectionScope: createConnectionScopeWithCredentials,
73
- } = deps;
74
-
75
54
  const {
76
55
  account: accountService,
77
56
  outboxMessage: outboxMessageService,
@@ -199,7 +178,7 @@ export const handleAppendSentMessage = async (
199
178
  accountId,
200
179
  outboxMessageId,
201
180
  );
202
- const scope = createConnectionScopeWithCredentials(account, credentials);
181
+ const scope = createConnectionScope(account, credentials);
203
182
 
204
183
  await scope
205
184
  .getConnection()
@@ -6,7 +6,7 @@ import {
6
6
  S3Client,
7
7
  } from "@aws-sdk/client-s3";
8
8
  import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
9
- import { noopLogger } from "@remit/logger-lambda/noop-logger";
9
+ import { noopLogger } from "@remit/logger";
10
10
  import { mockClient } from "aws-sdk-client-mock";
11
11
  import type { DeleteAccountObjectsEvent } from "../events.js";
12
12
  import { handleDeleteAccountObjects } from "./delete-account-objects.js";
@@ -4,7 +4,7 @@ import {
4
4
  S3Client,
5
5
  } from "@aws-sdk/client-s3";
6
6
  import { SendMessageCommand } from "@aws-sdk/client-sqs";
7
- import type { Logger } from "@remit/logger-lambda";
7
+ import type { Logger } from "@remit/logger";
8
8
  import { createQueueProducer } from "@remit/sqs-client/producer";
9
9
  import { env } from "expect-env";
10
10
  import type { DeleteAccountObjectsEvent } from "../events.js";