@remit/imap-worker 0.0.15 → 0.0.16

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/imap-worker",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
21
21
  "cli": "node --env-file=../../localhost-dev-aws.env src/cli.ts",
22
22
  "test:typecheck": "tsgo --noEmit",
23
- "test:run": "node --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=70 --test 'src/**/*.test.ts'",
23
+ "test:run": "node --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=75 --test 'src/**/*.test.ts'",
24
24
  "test": "npm run test:typecheck && npm run test:run",
25
25
  "dev": "node --import tsx src/e2e-processor-shim.ts"
26
26
  },
@@ -0,0 +1,216 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import type { Logger } from "@remit/logger-lambda";
4
+ import type { AppendSentMessageEvent } from "../events.js";
5
+ import {
6
+ type AppendSentMessageDeps,
7
+ handleAppendSentMessage,
8
+ } from "./append-sent-message.js";
9
+
10
+ const noopLog = {
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ debug: () => {},
15
+ fatal: () => {},
16
+ trace: () => {},
17
+ child: () => noopLog,
18
+ } as unknown as Logger;
19
+
20
+ interface Call {
21
+ method: string;
22
+ args: unknown[];
23
+ }
24
+
25
+ interface Harness {
26
+ calls: Call[];
27
+ account: {
28
+ accountId: string;
29
+ accountConfigId: string;
30
+ deletedAt?: number;
31
+ };
32
+ outbox: Record<string, unknown>;
33
+ specialUseSent: { mailboxId: string; fullPath: string } | null;
34
+ mailboxes: { mailboxId: string; fullPath: string }[];
35
+ append: (
36
+ path: string,
37
+ raw: Buffer,
38
+ flags: string[],
39
+ ) => Promise<{ uid: number; uidValidity: number }>;
40
+ disconnectCount: number;
41
+ }
42
+
43
+ let h: Harness;
44
+
45
+ const record =
46
+ (method: string) =>
47
+ async (...args: unknown[]) => {
48
+ h.calls.push({ method, args });
49
+ };
50
+
51
+ const fresh = (): Harness => ({
52
+ calls: [],
53
+ account: { accountId: "acc-1", accountConfigId: "cfg-1" },
54
+ outbox: {
55
+ status: "sent",
56
+ fromName: "Alice",
57
+ fromAddress: "alice@example.com",
58
+ toAddresses: ["bob@example.com"],
59
+ ccAddresses: ["carol@example.com"],
60
+ subject: "Quarterly numbers",
61
+ textBody: "See attached.",
62
+ messageIdValue: "generated-id@example.com",
63
+ references: ["parent@example.com"],
64
+ inReplyTo: "parent@example.com",
65
+ sentAt: 1700000000000,
66
+ },
67
+ specialUseSent: { mailboxId: "sent-mbx", fullPath: "Sent" },
68
+ mailboxes: [
69
+ { mailboxId: "inbox-mbx", fullPath: "INBOX" },
70
+ { mailboxId: "sent-items-mbx", fullPath: "Sent Items" },
71
+ ],
72
+ append: async (path, raw, flags) => {
73
+ h.calls.push({ method: "connection.append", args: [path, raw, flags] });
74
+ return { uid: 55, uidValidity: 7 };
75
+ },
76
+ disconnectCount: 0,
77
+ });
78
+
79
+ const deps = (): AppendSentMessageDeps =>
80
+ ({
81
+ getClient: async () => ({
82
+ account: {
83
+ get: async (accountId: string) => {
84
+ h.calls.push({ method: "account.get", args: [accountId] });
85
+ return h.account;
86
+ },
87
+ },
88
+ outboxMessage: {
89
+ get: async () => h.outbox,
90
+ delete: record("outboxMessage.delete"),
91
+ },
92
+ mailboxSpecialUse: {
93
+ findBySpecialUse: async () => h.specialUseSent,
94
+ },
95
+ mailbox: {
96
+ listByAccount: async () => ({ items: h.mailboxes }),
97
+ },
98
+ secrets: {},
99
+ }),
100
+ buildLifecycleDeps: () => ({}),
101
+ withOAuthLifecycle: async (
102
+ _deps: unknown,
103
+ _account: unknown,
104
+ _log: unknown,
105
+ cb: (credentials: unknown) => Promise<void>,
106
+ ) => cb({}),
107
+ createConnectionScope: () => ({
108
+ getConnection: async () => ({
109
+ append: (path: string, raw: Buffer, flags: string[]) =>
110
+ h.append(path, raw, flags),
111
+ }),
112
+ disconnect: async () => {
113
+ h.disconnectCount += 1;
114
+ },
115
+ }),
116
+ }) as unknown as AppendSentMessageDeps;
117
+
118
+ const event: AppendSentMessageEvent = {
119
+ type: "APPEND_SENT_MESSAGE",
120
+ accountId: "acc-1",
121
+ outboxMessageId: "out-1",
122
+ } as AppendSentMessageEvent;
123
+
124
+ const called = (method: string): Call[] =>
125
+ h.calls.filter((c) => c.method === method);
126
+
127
+ describe("handleAppendSentMessage", () => {
128
+ beforeEach(() => {
129
+ h = fresh();
130
+ });
131
+
132
+ it("appends a seen RFC822 copy to the Sent folder and drops the outbox row", async () => {
133
+ await handleAppendSentMessage(event, noopLog, deps());
134
+
135
+ const append = called("connection.append")[0];
136
+ assert.equal(append?.args[0], "Sent");
137
+ assert.deepEqual(append?.args[2], ["\\Seen"]);
138
+ assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
139
+ "cfg-1",
140
+ "out-1",
141
+ ]);
142
+ assert.equal(h.disconnectCount, 1);
143
+ });
144
+
145
+ it("builds the message from the outbox row's own headers", async () => {
146
+ await handleAppendSentMessage(event, noopLog, deps());
147
+
148
+ const raw = String(called("connection.append")[0]?.args[1] as Buffer);
149
+ assert.match(raw, /^From: Alice <alice@example\.com>$/m);
150
+ assert.match(raw, /^To: bob@example\.com$/m);
151
+ assert.match(raw, /^Cc: carol@example\.com$/m);
152
+ assert.match(raw, /^Subject: Quarterly numbers$/m);
153
+ assert.match(raw, /^Message-ID: <generated-id@example\.com>$/m);
154
+ assert.match(raw, /^In-Reply-To: <parent@example\.com>$/m);
155
+ assert.ok(raw.includes("See attached."));
156
+ });
157
+
158
+ it("uses a bare address when the outbox row carries no display name", async () => {
159
+ h.outbox = { ...h.outbox, fromName: undefined };
160
+
161
+ await handleAppendSentMessage(event, noopLog, deps());
162
+
163
+ const raw = String(called("connection.append")[0]?.args[1] as Buffer);
164
+ assert.match(raw, /^From: alice@example\.com$/m);
165
+ });
166
+
167
+ it("falls back to a conventionally-named Sent folder when no special-use flag is set", async () => {
168
+ h.specialUseSent = null;
169
+
170
+ await handleAppendSentMessage(event, noopLog, deps());
171
+
172
+ assert.equal(called("connection.append")[0]?.args[0], "Sent Items");
173
+ });
174
+
175
+ it("skips the append when the account has no Sent folder at all", async () => {
176
+ h.specialUseSent = null;
177
+ h.mailboxes = [{ mailboxId: "inbox-mbx", fullPath: "INBOX" }];
178
+
179
+ await handleAppendSentMessage(event, noopLog, deps());
180
+
181
+ assert.equal(called("connection.append").length, 0);
182
+ assert.equal(called("outboxMessage.delete").length, 0);
183
+ });
184
+
185
+ it("skips the append while the outbox row is not yet sent", async () => {
186
+ h.outbox = { ...h.outbox, status: "pending" };
187
+
188
+ await handleAppendSentMessage(event, noopLog, deps());
189
+
190
+ assert.equal(called("connection.append").length, 0);
191
+ assert.equal(called("outboxMessage.delete").length, 0);
192
+ });
193
+
194
+ it("returns early without touching the outbox when the account is soft-deleted", async () => {
195
+ h.account = { ...h.account, deletedAt: Date.now() };
196
+
197
+ await handleAppendSentMessage(event, noopLog, deps());
198
+
199
+ assert.equal(called("connection.append").length, 0);
200
+ assert.equal(called("outboxMessage.delete").length, 0);
201
+ });
202
+
203
+ it("keeps the outbox row when the APPEND fails so the send can be retried", async () => {
204
+ h.append = async () => {
205
+ throw new Error("server exploded");
206
+ };
207
+
208
+ await assert.rejects(
209
+ handleAppendSentMessage(event, noopLog, deps()),
210
+ /server exploded/,
211
+ );
212
+
213
+ assert.equal(called("outboxMessage.delete").length, 0);
214
+ assert.equal(h.disconnectCount, 1);
215
+ });
216
+ });
@@ -75,10 +75,32 @@ const buildRawMessage = async (outbox: OutboxMessageItem): Promise<Buffer> => {
75
75
  return Buffer.concat(chunks);
76
76
  };
77
77
 
78
+ export interface AppendSentMessageDeps {
79
+ getClient: typeof getClient;
80
+ buildLifecycleDeps: typeof buildLifecycleDeps;
81
+ withOAuthLifecycle: typeof withOAuthLifecycle;
82
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
83
+ }
84
+
85
+ const defaultDeps: AppendSentMessageDeps = {
86
+ getClient,
87
+ buildLifecycleDeps,
88
+ withOAuthLifecycle,
89
+ createConnectionScope: createConnectionScopeWithCredentials,
90
+ };
91
+
78
92
  export const handleAppendSentMessage = async (
79
93
  event: AppendSentMessageEvent,
80
94
  log: Logger,
95
+ deps: AppendSentMessageDeps = defaultDeps,
81
96
  ): Promise<void> => {
97
+ const {
98
+ getClient,
99
+ buildLifecycleDeps,
100
+ withOAuthLifecycle,
101
+ createConnectionScope: createConnectionScopeWithCredentials,
102
+ } = deps;
103
+
82
104
  const {
83
105
  account: accountService,
84
106
  outboxMessage: outboxMessageService,
@@ -0,0 +1,220 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import type { Logger } from "@remit/logger-lambda";
4
+ import type { EmptyTrashEvent } from "../events.js";
5
+ import { type EmptyTrashDeps, handleEmptyTrash } from "./empty-trash.js";
6
+
7
+ const noopLog = {
8
+ info: () => {},
9
+ warn: () => {},
10
+ error: () => {},
11
+ debug: () => {},
12
+ fatal: () => {},
13
+ trace: () => {},
14
+ child: () => noopLog,
15
+ } as unknown as Logger;
16
+
17
+ interface Call {
18
+ method: string;
19
+ args: unknown[];
20
+ }
21
+
22
+ interface Connection {
23
+ openBox: (
24
+ path: string,
25
+ readOnly?: boolean,
26
+ ) => Promise<{ uidvalidity: number }>;
27
+ search: (criteria: string[]) => Promise<number[]>;
28
+ deleteMessages: (uids: number[]) => Promise<void>;
29
+ }
30
+
31
+ interface Harness {
32
+ calls: Call[];
33
+ account: {
34
+ accountId: string;
35
+ accountConfigId: string;
36
+ deletedAt?: number;
37
+ } | null;
38
+ mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
39
+ connection: Connection;
40
+ localMessages: { messageId: string }[];
41
+ threadMessage: { accountConfigId: string; threadMessageId: string } | null;
42
+ getConnectionCount: number;
43
+ disconnectCount: number;
44
+ }
45
+
46
+ let h: Harness;
47
+
48
+ const record =
49
+ (method: string) =>
50
+ async (...args: unknown[]) => {
51
+ h.calls.push({ method, args });
52
+ };
53
+
54
+ const buildConnection = (): Connection => ({
55
+ openBox: async () => ({ uidvalidity: 1 }),
56
+ search: async () => [10, 11],
57
+ deleteMessages: record(
58
+ "connection.deleteMessages",
59
+ ) as Connection["deleteMessages"],
60
+ });
61
+
62
+ const fresh = (): Harness => ({
63
+ calls: [],
64
+ account: { accountId: "acc-1", accountConfigId: "cfg-1" },
65
+ mailbox: { mailboxId: "trash-mbx", uidValidity: 1, cursorState: undefined },
66
+ connection: buildConnection(),
67
+ localMessages: [{ messageId: "msg-1" }, { messageId: "msg-2" }],
68
+ threadMessage: { accountConfigId: "cfg-1", threadMessageId: "tm-1" },
69
+ getConnectionCount: 0,
70
+ disconnectCount: 0,
71
+ });
72
+
73
+ const deps = (): EmptyTrashDeps =>
74
+ ({
75
+ getClient: async () => ({
76
+ account: {
77
+ get: async (accountId: string) => {
78
+ h.calls.push({ method: "account.get", args: [accountId] });
79
+ return h.account;
80
+ },
81
+ },
82
+ message: {
83
+ listAllByMailbox: async () => h.localMessages,
84
+ delete: record("message.delete"),
85
+ },
86
+ threadMessage: {
87
+ findByMessageId: async () => h.threadMessage,
88
+ delete: record("threadMessage.delete"),
89
+ },
90
+ mailbox: {
91
+ get: async () => h.mailbox,
92
+ update: record("mailbox.update"),
93
+ },
94
+ secrets: {},
95
+ }),
96
+ buildLifecycleDeps: () => ({}),
97
+ withOAuthLifecycle: async (
98
+ _deps: unknown,
99
+ _account: unknown,
100
+ _log: unknown,
101
+ cb: (credentials: unknown) => Promise<void>,
102
+ ) => cb({}),
103
+ createConnectionScope: () => ({
104
+ getConnection: async () => {
105
+ h.getConnectionCount += 1;
106
+ return h.connection;
107
+ },
108
+ disconnect: async () => {
109
+ h.disconnectCount += 1;
110
+ },
111
+ }),
112
+ }) as unknown as EmptyTrashDeps;
113
+
114
+ const event: EmptyTrashEvent = {
115
+ type: "EMPTY_TRASH",
116
+ accountId: "acc-1",
117
+ trashMailboxId: "trash-mbx",
118
+ trashMailboxPath: "Trash",
119
+ } as EmptyTrashEvent;
120
+
121
+ const called = (method: string): Call[] =>
122
+ h.calls.filter((c) => c.method === method);
123
+
124
+ describe("handleEmptyTrash", () => {
125
+ beforeEach(() => {
126
+ h = fresh();
127
+ });
128
+
129
+ it("expunges every server uid and both local rows for each trashed message", async () => {
130
+ await handleEmptyTrash(event, noopLog, deps());
131
+
132
+ assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
133
+ assert.deepEqual(
134
+ called("message.delete").map((c) => c.args[0]),
135
+ ["msg-1", "msg-2"],
136
+ );
137
+ assert.equal(called("threadMessage.delete").length, 2);
138
+ assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
139
+ });
140
+
141
+ it("skips the IMAP expunge when the trash is already empty on the server", async () => {
142
+ h.connection.search = async () => [];
143
+
144
+ await handleEmptyTrash(event, noopLog, deps());
145
+
146
+ assert.equal(called("connection.deleteMessages").length, 0);
147
+ assert.equal(
148
+ called("message.delete").length,
149
+ 2,
150
+ "local rows are still cleaned up",
151
+ );
152
+ });
153
+
154
+ it("deletes the message even when it has no thread row", async () => {
155
+ h.threadMessage = null;
156
+
157
+ await handleEmptyTrash(event, noopLog, deps());
158
+
159
+ assert.equal(called("message.delete").length, 2);
160
+ assert.equal(called("threadMessage.delete").length, 0);
161
+ });
162
+
163
+ it("returns early without connecting when the account is soft-deleted", async () => {
164
+ h.account = {
165
+ accountId: "acc-1",
166
+ accountConfigId: "cfg-1",
167
+ deletedAt: Date.now(),
168
+ };
169
+
170
+ await handleEmptyTrash(event, noopLog, deps());
171
+
172
+ assert.equal(h.getConnectionCount, 0);
173
+ });
174
+
175
+ it("throws when the account no longer exists", async () => {
176
+ h.account = null;
177
+
178
+ await assert.rejects(handleEmptyTrash(event, noopLog, deps()), /not found/);
179
+ });
180
+
181
+ it("skips without opening a connection when the cursor is rebuilding", async () => {
182
+ h.mailbox = {
183
+ mailboxId: "trash-mbx",
184
+ uidValidity: 1,
185
+ cursorState: "rebuilding",
186
+ };
187
+
188
+ await handleEmptyTrash(event, noopLog, deps());
189
+
190
+ assert.equal(h.getConnectionCount, 0);
191
+ assert.equal(called("message.delete").length, 0);
192
+ });
193
+
194
+ it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
195
+ h.connection.openBox = async () => ({ uidvalidity: 999 });
196
+
197
+ await handleEmptyTrash(event, noopLog, deps());
198
+
199
+ assert.equal(
200
+ (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
201
+ ?.cursorState,
202
+ "cursor_invalid",
203
+ );
204
+ assert.equal(called("message.delete").length, 0);
205
+ assert.equal(h.disconnectCount, 1);
206
+ });
207
+
208
+ it("rethrows an unclassified IMAP error so the event is retried", async () => {
209
+ h.connection.search = async () => {
210
+ throw new Error("server exploded");
211
+ };
212
+
213
+ await assert.rejects(
214
+ handleEmptyTrash(event, noopLog, deps()),
215
+ /server exploded/,
216
+ );
217
+
218
+ assert.equal(h.disconnectCount, 1);
219
+ });
220
+ });
@@ -11,6 +11,20 @@ import type { EmptyTrashEvent } from "../events.js";
11
11
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
12
12
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
13
13
 
14
+ export interface EmptyTrashDeps {
15
+ getClient: typeof getClient;
16
+ buildLifecycleDeps: typeof buildLifecycleDeps;
17
+ withOAuthLifecycle: typeof withOAuthLifecycle;
18
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
19
+ }
20
+
21
+ const defaultDeps: EmptyTrashDeps = {
22
+ getClient,
23
+ buildLifecycleDeps,
24
+ withOAuthLifecycle,
25
+ createConnectionScope: createConnectionScopeWithCredentials,
26
+ };
27
+
14
28
  /**
15
29
  * Handle EMPTY_TRASH events.
16
30
  * Permanently deletes all messages in the Trash mailbox.
@@ -18,7 +32,15 @@ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
32
  export const handleEmptyTrash = async (
19
33
  event: EmptyTrashEvent,
20
34
  log: Logger,
35
+ deps: EmptyTrashDeps = defaultDeps,
21
36
  ): Promise<void> => {
37
+ const {
38
+ getClient,
39
+ buildLifecycleDeps,
40
+ withOAuthLifecycle,
41
+ createConnectionScope: createConnectionScopeWithCredentials,
42
+ } = deps;
43
+
22
44
  const {
23
45
  account: accountService,
24
46
  message: messageService,
@@ -0,0 +1,324 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import type { Logger } from "@remit/logger-lambda";
4
+ import type {
5
+ MailboxCreateEvent,
6
+ MailboxDeleteEvent,
7
+ MailboxRenameEvent,
8
+ } from "../events.js";
9
+ import {
10
+ type MailboxManagementDeps,
11
+ processMailboxManagement,
12
+ } from "./mailbox-management.js";
13
+
14
+ const noopLog = {
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ debug: () => {},
19
+ fatal: () => {},
20
+ trace: () => {},
21
+ child: () => noopLog,
22
+ } as unknown as Logger;
23
+
24
+ interface Call {
25
+ method: string;
26
+ args: unknown[];
27
+ }
28
+
29
+ interface Connection {
30
+ createMailbox: (path: string) => Promise<{ created: boolean }>;
31
+ subscribeMailbox: (path: string) => Promise<void>;
32
+ listMailboxes: () => Promise<{ fullPath: string }[]>;
33
+ openBox: (
34
+ path: string,
35
+ readOnly?: boolean,
36
+ ) => Promise<{
37
+ uidvalidity: number;
38
+ uidnext: number;
39
+ messages: { total: number };
40
+ }>;
41
+ closeBox: () => Promise<void>;
42
+ renameMailbox: (oldPath: string, newPath: string) => Promise<void>;
43
+ deleteMailbox: (path: string) => Promise<void>;
44
+ }
45
+
46
+ interface Harness {
47
+ calls: Call[];
48
+ account: {
49
+ accountId: string;
50
+ accountConfigId: string;
51
+ deletedAt?: number;
52
+ } | null;
53
+ connection: Connection;
54
+ disconnectCount: number;
55
+ }
56
+
57
+ let h: Harness;
58
+
59
+ const record =
60
+ (method: string) =>
61
+ async (...args: unknown[]) => {
62
+ h.calls.push({ method, args });
63
+ };
64
+
65
+ const buildConnection = (): Connection => ({
66
+ createMailbox: async (path: string) => {
67
+ h.calls.push({ method: "connection.createMailbox", args: [path] });
68
+ return { created: true };
69
+ },
70
+ subscribeMailbox: record("connection.subscribeMailbox"),
71
+ listMailboxes: async () => [{ fullPath: "Archive" }],
72
+ openBox: async () => ({
73
+ uidvalidity: 7,
74
+ uidnext: 42,
75
+ messages: { total: 3 },
76
+ }),
77
+ closeBox: record("connection.closeBox"),
78
+ renameMailbox: record("connection.renameMailbox"),
79
+ deleteMailbox: record("connection.deleteMailbox"),
80
+ });
81
+
82
+ const fresh = (): Harness => ({
83
+ calls: [],
84
+ account: { accountId: "acc-1", accountConfigId: "cfg-1" },
85
+ connection: buildConnection(),
86
+ disconnectCount: 0,
87
+ });
88
+
89
+ const deps = (): MailboxManagementDeps =>
90
+ ({
91
+ getClient: async () => ({
92
+ account: {
93
+ get: async (accountId: string) => {
94
+ h.calls.push({ method: "account.get", args: [accountId] });
95
+ return h.account;
96
+ },
97
+ },
98
+ mailbox: {
99
+ update: record("mailbox.update"),
100
+ delete: record("mailbox.delete"),
101
+ },
102
+ secrets: {},
103
+ }),
104
+ buildLifecycleDeps: () => ({}),
105
+ withOAuthLifecycle: async (
106
+ _deps: unknown,
107
+ _account: unknown,
108
+ _log: unknown,
109
+ cb: (credentials: unknown) => Promise<void>,
110
+ ) => cb({}),
111
+ createConnectionScope: () => ({
112
+ getConnection: async () => h.connection,
113
+ disconnect: async () => {
114
+ h.disconnectCount += 1;
115
+ },
116
+ }),
117
+ }) as unknown as MailboxManagementDeps;
118
+
119
+ const createEvent: MailboxCreateEvent = {
120
+ type: "MAILBOX_CREATE",
121
+ accountId: "acc-1",
122
+ mailboxId: "mbx-1",
123
+ path: "Archive",
124
+ } as MailboxCreateEvent;
125
+
126
+ const renameEvent: MailboxRenameEvent = {
127
+ type: "MAILBOX_RENAME",
128
+ accountId: "acc-1",
129
+ mailboxId: "mbx-1",
130
+ oldPath: "Archive",
131
+ newPath: "Archive 2024",
132
+ } as MailboxRenameEvent;
133
+
134
+ const deleteEvent: MailboxDeleteEvent = {
135
+ type: "MAILBOX_DELETE",
136
+ accountId: "acc-1",
137
+ mailboxId: "mbx-1",
138
+ path: "Archive",
139
+ } as MailboxDeleteEvent;
140
+
141
+ const called = (method: string): Call[] =>
142
+ h.calls.filter((c) => c.method === method);
143
+
144
+ const lastUpdate = (): Record<string, unknown> =>
145
+ (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
146
+
147
+ describe("processMailboxManagement — MAILBOX_CREATE", () => {
148
+ beforeEach(() => {
149
+ h = fresh();
150
+ });
151
+
152
+ it("creates the folder and writes back the server's UIDVALIDITY and counts", async () => {
153
+ await processMailboxManagement(createEvent, noopLog, deps());
154
+
155
+ assert.equal(called("connection.createMailbox")[0]?.args[0], "Archive");
156
+ assert.deepEqual(lastUpdate(), {
157
+ uidValidity: 7,
158
+ uidNext: 42,
159
+ messageCount: 3,
160
+ syncStatus: "synced",
161
+ });
162
+ assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
163
+ });
164
+
165
+ it("subscribes only when the event asks for it", async () => {
166
+ await processMailboxManagement(createEvent, noopLog, deps());
167
+ assert.equal(called("connection.subscribeMailbox").length, 0);
168
+
169
+ h = fresh();
170
+ await processMailboxManagement(
171
+ { ...createEvent, subscribe: true },
172
+ noopLog,
173
+ deps(),
174
+ );
175
+ assert.equal(called("connection.subscribeMailbox")[0]?.args[0], "Archive");
176
+ });
177
+
178
+ it("still marks the mailbox synced when the server does not list the new folder", async () => {
179
+ h.connection.listMailboxes = async () => [];
180
+
181
+ await processMailboxManagement(createEvent, noopLog, deps());
182
+
183
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
184
+ });
185
+
186
+ it("treats an already-existing folder as success rather than a failure", async () => {
187
+ h.connection.createMailbox = async () => {
188
+ throw new Error("Mailbox already exists");
189
+ };
190
+
191
+ await processMailboxManagement(createEvent, noopLog, deps());
192
+
193
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
194
+ });
195
+
196
+ it("marks the mailbox failed and rethrows on any other create error", async () => {
197
+ h.connection.createMailbox = async () => {
198
+ throw new Error("server exploded");
199
+ };
200
+
201
+ await assert.rejects(
202
+ processMailboxManagement(createEvent, noopLog, deps()),
203
+ /server exploded/,
204
+ );
205
+
206
+ assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
207
+ assert.equal(h.disconnectCount, 1);
208
+ });
209
+
210
+ it("returns early without connecting when the account is soft-deleted", async () => {
211
+ h.account = {
212
+ accountId: "acc-1",
213
+ accountConfigId: "cfg-1",
214
+ deletedAt: Date.now(),
215
+ };
216
+
217
+ await processMailboxManagement(createEvent, noopLog, deps());
218
+
219
+ assert.equal(called("connection.createMailbox").length, 0);
220
+ });
221
+
222
+ it("throws when the account no longer exists", async () => {
223
+ h.account = null;
224
+
225
+ await assert.rejects(
226
+ processMailboxManagement(createEvent, noopLog, deps()),
227
+ /not found/,
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("processMailboxManagement — MAILBOX_RENAME", () => {
233
+ beforeEach(() => {
234
+ h = fresh();
235
+ });
236
+
237
+ it("renames on the server and clears the pending oldPath", async () => {
238
+ await processMailboxManagement(renameEvent, noopLog, deps());
239
+
240
+ assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
241
+ "Archive",
242
+ "Archive 2024",
243
+ ]);
244
+ assert.deepEqual(lastUpdate(), {
245
+ oldPath: undefined,
246
+ syncStatus: "synced",
247
+ });
248
+ });
249
+
250
+ it("drops the local row when the source folder is gone on the server", async () => {
251
+ h.connection.renameMailbox = async () => {
252
+ throw new Error("Mailbox not found");
253
+ };
254
+
255
+ await processMailboxManagement(renameEvent, noopLog, deps());
256
+
257
+ assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
258
+ assert.equal(called("mailbox.update").length, 0);
259
+ });
260
+
261
+ it("rolls the local path back and rethrows on any other rename error", async () => {
262
+ h.connection.renameMailbox = async () => {
263
+ throw new Error("server exploded");
264
+ };
265
+
266
+ await assert.rejects(
267
+ processMailboxManagement(renameEvent, noopLog, deps()),
268
+ /server exploded/,
269
+ );
270
+
271
+ assert.deepEqual(lastUpdate(), {
272
+ fullPath: "Archive",
273
+ oldPath: undefined,
274
+ syncStatus: "failed",
275
+ });
276
+ });
277
+ });
278
+
279
+ describe("processMailboxManagement — MAILBOX_DELETE", () => {
280
+ beforeEach(() => {
281
+ h = fresh();
282
+ });
283
+
284
+ it("deletes on the server and drops the local row", async () => {
285
+ await processMailboxManagement(deleteEvent, noopLog, deps());
286
+
287
+ assert.equal(called("connection.deleteMailbox")[0]?.args[0], "Archive");
288
+ assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
289
+ });
290
+
291
+ it("drops the local row when the folder is already gone on the server", async () => {
292
+ h.connection.deleteMailbox = async () => {
293
+ throw new Error("Mailbox not found");
294
+ };
295
+
296
+ await processMailboxManagement(deleteEvent, noopLog, deps());
297
+
298
+ assert.equal(called("mailbox.delete").length, 1);
299
+ });
300
+
301
+ it("restores the mailbox and swallows the error when the server refuses to delete INBOX", async () => {
302
+ h.connection.deleteMailbox = async () => {
303
+ throw new Error("Cannot delete INBOX");
304
+ };
305
+
306
+ await processMailboxManagement(deleteEvent, noopLog, deps());
307
+
308
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
309
+ assert.equal(called("mailbox.delete").length, 0);
310
+ });
311
+
312
+ it("marks the mailbox failed and rethrows on any other delete error", async () => {
313
+ h.connection.deleteMailbox = async () => {
314
+ throw new Error("server exploded");
315
+ };
316
+
317
+ await assert.rejects(
318
+ processMailboxManagement(deleteEvent, noopLog, deps()),
319
+ /server exploded/,
320
+ );
321
+
322
+ assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
323
+ });
324
+ });
@@ -13,13 +13,35 @@ import type {
13
13
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
14
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
15
15
 
16
+ export interface MailboxManagementDeps {
17
+ getClient: typeof getClient;
18
+ buildLifecycleDeps: typeof buildLifecycleDeps;
19
+ withOAuthLifecycle: typeof withOAuthLifecycle;
20
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
21
+ }
22
+
23
+ const defaultDeps: MailboxManagementDeps = {
24
+ getClient,
25
+ buildLifecycleDeps,
26
+ withOAuthLifecycle,
27
+ createConnectionScope: createConnectionScopeWithCredentials,
28
+ };
29
+
16
30
  /**
17
31
  * Handle MAILBOX_CREATE event
18
32
  */
19
33
  const handleCreate = async (
20
34
  event: MailboxCreateEvent,
21
35
  log: Logger,
36
+ deps: MailboxManagementDeps,
22
37
  ): Promise<void> => {
38
+ const {
39
+ getClient,
40
+ buildLifecycleDeps,
41
+ withOAuthLifecycle,
42
+ createConnectionScope: createConnectionScopeWithCredentials,
43
+ } = deps;
44
+
23
45
  const {
24
46
  account: accountService,
25
47
  mailbox: mailboxService,
@@ -93,7 +115,15 @@ const handleCreate = async (
93
115
  const handleRename = async (
94
116
  event: MailboxRenameEvent,
95
117
  log: Logger,
118
+ deps: MailboxManagementDeps,
96
119
  ): Promise<void> => {
120
+ const {
121
+ getClient,
122
+ buildLifecycleDeps,
123
+ withOAuthLifecycle,
124
+ createConnectionScope: createConnectionScopeWithCredentials,
125
+ } = deps;
126
+
97
127
  const {
98
128
  account: accountService,
99
129
  mailbox: mailboxService,
@@ -171,7 +201,15 @@ const handleRename = async (
171
201
  const handleDelete = async (
172
202
  event: MailboxDeleteEvent,
173
203
  log: Logger,
204
+ deps: MailboxManagementDeps,
174
205
  ): Promise<void> => {
206
+ const {
207
+ getClient,
208
+ buildLifecycleDeps,
209
+ withOAuthLifecycle,
210
+ createConnectionScope: createConnectionScopeWithCredentials,
211
+ } = deps;
212
+
175
213
  const {
176
214
  account: accountService,
177
215
  mailbox: mailboxService,
@@ -254,13 +292,14 @@ const handleDelete = async (
254
292
  export const processMailboxManagement = async (
255
293
  event: MailboxManagementEvent,
256
294
  log: Logger,
295
+ deps: MailboxManagementDeps = defaultDeps,
257
296
  ): Promise<void> => {
258
297
  switch (event.type) {
259
298
  case "MAILBOX_CREATE":
260
- return handleCreate(event, log);
299
+ return handleCreate(event, log, deps);
261
300
  case "MAILBOX_RENAME":
262
- return handleRename(event, log);
301
+ return handleRename(event, log, deps);
263
302
  case "MAILBOX_DELETE":
264
- return handleDelete(event, log);
303
+ return handleDelete(event, log, deps);
265
304
  }
266
305
  };
@@ -1,9 +1,13 @@
1
1
  import assert from "node:assert";
2
- import { describe, it, mock } from "node:test";
2
+ import { beforeEach, describe, it, mock } from "node:test";
3
3
  import type { ThreadMessageItem } from "@remit/data-ports";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import type { MessageDeleteEvent } from "../events.js";
4
6
  import {
5
7
  buildThreadMessageTrashUpdate,
6
8
  deleteAllThreadMessagesForMessage,
9
+ handleMessageDelete,
10
+ type MessageDeleteDeps,
7
11
  } from "./message-delete.js";
8
12
 
9
13
  const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
@@ -174,3 +178,288 @@ describe("deleteAllThreadMessagesForMessage (#212)", () => {
174
178
  assert.equal(deleteRow.mock.calls.length, 0);
175
179
  });
176
180
  });
181
+
182
+ const noopLog = {
183
+ info: () => {},
184
+ warn: () => {},
185
+ error: () => {},
186
+ debug: () => {},
187
+ fatal: () => {},
188
+ trace: () => {},
189
+ child: () => noopLog,
190
+ } as unknown as Logger;
191
+
192
+ interface Call {
193
+ method: string;
194
+ args: unknown[];
195
+ }
196
+
197
+ interface Connection {
198
+ openBox: (
199
+ path: string,
200
+ readOnly?: boolean,
201
+ ) => Promise<{ uidvalidity: number }>;
202
+ moveMessages: (
203
+ uids: number[],
204
+ dest: string,
205
+ ) => Promise<{ uidMap: Map<number, number> }>;
206
+ deleteMessages: (uids: number[]) => Promise<void>;
207
+ createMailbox: (path: string) => Promise<void>;
208
+ }
209
+
210
+ interface Harness {
211
+ calls: Call[];
212
+ account: {
213
+ accountId: string;
214
+ accountConfigId: string;
215
+ deletedAt?: number;
216
+ } | null;
217
+ mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
218
+ connection: Connection;
219
+ threadMessage: Record<string, unknown> | null;
220
+ allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
221
+ getConnectionCount: number;
222
+ disconnectCount: number;
223
+ }
224
+
225
+ let h: Harness;
226
+
227
+ const record =
228
+ (method: string) =>
229
+ async (...args: unknown[]) => {
230
+ h.calls.push({ method, args });
231
+ };
232
+
233
+ const buildConnection = (): Connection => ({
234
+ openBox: async () => ({ uidvalidity: 1 }),
235
+ moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
236
+ deleteMessages: record(
237
+ "connection.deleteMessages",
238
+ ) as Connection["deleteMessages"],
239
+ createMailbox: record(
240
+ "connection.createMailbox",
241
+ ) as Connection["createMailbox"],
242
+ });
243
+
244
+ const fresh = (): Harness => ({
245
+ calls: [],
246
+ account: { accountId: "acc-1", accountConfigId: "cfg-1" },
247
+ mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
248
+ connection: buildConnection(),
249
+ threadMessage: {
250
+ ...baseThreadMessage,
251
+ accountConfigId: "cfg-1",
252
+ threadMessageId: "tm-1",
253
+ },
254
+ allThreadMessages: [
255
+ { accountConfigId: "cfg-1", threadMessageId: "tm-1" },
256
+ { accountConfigId: "cfg-1", threadMessageId: "tm-2" },
257
+ ],
258
+ getConnectionCount: 0,
259
+ disconnectCount: 0,
260
+ });
261
+
262
+ const deps = (): MessageDeleteDeps =>
263
+ ({
264
+ getClient: async () => ({
265
+ account: {
266
+ get: async (accountId: string) => {
267
+ h.calls.push({ method: "account.get", args: [accountId] });
268
+ return h.account;
269
+ },
270
+ },
271
+ message: {
272
+ updateUid: record("message.updateUid"),
273
+ update: record("message.update"),
274
+ delete: record("message.delete"),
275
+ },
276
+ threadMessage: {
277
+ findByMessageId: async () => h.threadMessage,
278
+ findAllByMessageId: async () => h.allThreadMessages,
279
+ update: record("threadMessage.update"),
280
+ delete: record("threadMessage.delete"),
281
+ },
282
+ mailbox: {
283
+ get: async () => h.mailbox,
284
+ update: record("mailbox.update"),
285
+ },
286
+ secrets: {},
287
+ }),
288
+ buildLifecycleDeps: () => ({}),
289
+ withOAuthLifecycle: async (
290
+ _deps: unknown,
291
+ _account: unknown,
292
+ _log: unknown,
293
+ cb: (credentials: unknown) => Promise<void>,
294
+ ) => cb({}),
295
+ createConnectionScope: () => ({
296
+ getConnection: async () => {
297
+ h.getConnectionCount += 1;
298
+ return h.connection;
299
+ },
300
+ disconnect: async () => {
301
+ h.disconnectCount += 1;
302
+ },
303
+ }),
304
+ }) as unknown as MessageDeleteDeps;
305
+
306
+ const moveEvent: MessageDeleteEvent = {
307
+ type: "MESSAGE_DELETE",
308
+ accountId: "acc-1",
309
+ messageId: "msg-1",
310
+ mailboxId: "src-mbx",
311
+ mailboxPath: "INBOX",
312
+ uid: 10,
313
+ operation: "move_to_trash",
314
+ destinationMailboxId: "trash-mbx",
315
+ destinationMailboxPath: "Trash",
316
+ } as MessageDeleteEvent;
317
+
318
+ const permanentEvent: MessageDeleteEvent = {
319
+ type: "MESSAGE_DELETE",
320
+ accountId: "acc-1",
321
+ messageId: "msg-1",
322
+ mailboxId: "src-mbx",
323
+ mailboxPath: "INBOX",
324
+ uid: 10,
325
+ operation: "permanent_delete",
326
+ } as MessageDeleteEvent;
327
+
328
+ const called = (method: string): Call[] =>
329
+ h.calls.filter((c) => c.method === method);
330
+
331
+ describe("handleMessageDelete", () => {
332
+ beforeEach(() => {
333
+ h = fresh();
334
+ });
335
+
336
+ it("moves to trash, rewrites the uid, and flips the thread row to deleted", async () => {
337
+ await handleMessageDelete(moveEvent, noopLog, deps());
338
+
339
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
340
+ "msg-1",
341
+ 20,
342
+ "trash-mbx",
343
+ ]);
344
+ const update = called("threadMessage.update")[0];
345
+ assert.deepEqual(update?.args[2], {
346
+ uid: 20,
347
+ mailboxId: "trash-mbx",
348
+ isDeleted: true,
349
+ });
350
+ assert.equal(h.disconnectCount, 1);
351
+ });
352
+
353
+ it("marks the message failed when the MOVE returns no new uid", async () => {
354
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
355
+
356
+ await handleMessageDelete(moveEvent, noopLog, deps());
357
+
358
+ assert.equal(called("message.updateUid").length, 0);
359
+ assert.equal(
360
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
361
+ ?.syncStatus,
362
+ "failed",
363
+ );
364
+ });
365
+
366
+ it("expunges on the server and removes every thread row before the message row", async () => {
367
+ await handleMessageDelete(permanentEvent, noopLog, deps());
368
+
369
+ assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
370
+ assert.equal(called("threadMessage.delete").length, 2);
371
+ assert.ok(
372
+ h.calls.findIndex((c) => c.method === "threadMessage.delete") <
373
+ h.calls.findIndex((c) => c.method === "message.delete"),
374
+ "thread rows go first so no row outlives its message",
375
+ );
376
+ });
377
+
378
+ it("cleans up locally and swallows the error when the message is already gone on IMAP", async () => {
379
+ h.connection.deleteMessages = async () => {
380
+ throw new Error("NONEXISTENT uid");
381
+ };
382
+
383
+ await handleMessageDelete(permanentEvent, noopLog, deps());
384
+
385
+ assert.equal(called("message.delete").length, 1);
386
+ assert.equal(called("threadMessage.delete").length, 2);
387
+ });
388
+
389
+ it("creates the trash mailbox and rethrows on TRYCREATE", async () => {
390
+ h.connection.moveMessages = async () => {
391
+ throw new Error("TRYCREATE: no such mailbox");
392
+ };
393
+
394
+ await assert.rejects(
395
+ handleMessageDelete(moveEvent, noopLog, deps()),
396
+ /TRYCREATE/,
397
+ );
398
+
399
+ assert.equal(called("connection.createMailbox")[0]?.args[0], "Trash");
400
+ assert.equal(h.getConnectionCount, 2, "reconnects to create the mailbox");
401
+ });
402
+
403
+ it("marks failed and rethrows on an unclassified IMAP error", async () => {
404
+ h.connection.moveMessages = async () => {
405
+ throw new Error("server exploded");
406
+ };
407
+
408
+ await assert.rejects(
409
+ handleMessageDelete(moveEvent, noopLog, deps()),
410
+ /server exploded/,
411
+ );
412
+
413
+ assert.equal(
414
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
415
+ ?.syncStatus,
416
+ "failed",
417
+ );
418
+ });
419
+
420
+ it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
421
+ h.connection.openBox = async () => ({ uidvalidity: 999 });
422
+
423
+ await handleMessageDelete(moveEvent, noopLog, deps());
424
+
425
+ assert.equal(
426
+ (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
427
+ ?.cursorState,
428
+ "cursor_invalid",
429
+ );
430
+ assert.equal(called("message.updateUid").length, 0);
431
+ });
432
+
433
+ it("skips without opening a connection when the cursor is rebuilding", async () => {
434
+ h.mailbox = {
435
+ mailboxId: "src-mbx",
436
+ uidValidity: 1,
437
+ cursorState: "rebuilding",
438
+ };
439
+
440
+ await handleMessageDelete(moveEvent, noopLog, deps());
441
+
442
+ assert.equal(h.getConnectionCount, 0);
443
+ });
444
+
445
+ it("returns early without connecting when the account is soft-deleted", async () => {
446
+ h.account = {
447
+ accountId: "acc-1",
448
+ accountConfigId: "cfg-1",
449
+ deletedAt: Date.now(),
450
+ };
451
+
452
+ await handleMessageDelete(moveEvent, noopLog, deps());
453
+
454
+ assert.equal(h.getConnectionCount, 0);
455
+ });
456
+
457
+ it("throws when the account no longer exists", async () => {
458
+ h.account = null;
459
+
460
+ await assert.rejects(
461
+ handleMessageDelete(moveEvent, noopLog, deps()),
462
+ /not found/,
463
+ );
464
+ });
465
+ });
@@ -83,6 +83,20 @@ export const buildThreadMessageTrashUpdate = (
83
83
  },
84
84
  });
85
85
 
86
+ export interface MessageDeleteDeps {
87
+ getClient: typeof getClient;
88
+ buildLifecycleDeps: typeof buildLifecycleDeps;
89
+ withOAuthLifecycle: typeof withOAuthLifecycle;
90
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
91
+ }
92
+
93
+ const defaultDeps: MessageDeleteDeps = {
94
+ getClient,
95
+ buildLifecycleDeps,
96
+ withOAuthLifecycle,
97
+ createConnectionScope: createConnectionScopeWithCredentials,
98
+ };
99
+
86
100
  /**
87
101
  * Handle MESSAGE_DELETE events.
88
102
  * Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
@@ -90,7 +104,15 @@ export const buildThreadMessageTrashUpdate = (
90
104
  export const handleMessageDelete = async (
91
105
  event: MessageDeleteEvent,
92
106
  log: Logger,
107
+ deps: MessageDeleteDeps = defaultDeps,
93
108
  ): Promise<void> => {
109
+ const {
110
+ getClient,
111
+ buildLifecycleDeps,
112
+ withOAuthLifecycle,
113
+ createConnectionScope: createConnectionScopeWithCredentials,
114
+ } = deps;
115
+
94
116
  const {
95
117
  account: accountService,
96
118
  message: messageService,