@remit/imap-worker 0.0.14 → 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.14",
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=50 --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,
@@ -1,81 +1,129 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { DeleteAccountObjectsEvent } from "./delete-account-objects.js";
4
-
5
- describe("DeleteAccountObjects handler", () => {
6
- it("event shape is correct", () => {
7
- const event: DeleteAccountObjectsEvent = {
8
- type: "DELETE_ACCOUNT_OBJECTS",
9
- accountConfigId: "test-account-config-id-12345",
10
- };
11
-
12
- assert.equal(event.type, "DELETE_ACCOUNT_OBJECTS");
13
- assert.equal(event.accountConfigId, "test-account-config-id-12345");
14
- assert.equal(event.continuationToken, undefined);
2
+ import { afterEach, describe, it } from "node:test";
3
+ import {
4
+ DeleteObjectsCommand,
5
+ ListObjectsV2Command,
6
+ S3Client,
7
+ } from "@aws-sdk/client-s3";
8
+ import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
9
+ import type { Logger } from "@remit/logger-lambda";
10
+ import { mockClient } from "aws-sdk-client-mock";
11
+ import {
12
+ type DeleteAccountObjectsEvent,
13
+ handleDeleteAccountObjects,
14
+ } from "./delete-account-objects.js";
15
+
16
+ const noopLog = {
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ debug: () => {},
21
+ fatal: () => {},
22
+ trace: () => {},
23
+ child: () => noopLog,
24
+ } as unknown as Logger;
25
+
26
+ const s3Mock = mockClient(S3Client);
27
+ const sqsMock = mockClient(SQSClient);
28
+
29
+ const event: DeleteAccountObjectsEvent = {
30
+ type: "DELETE_ACCOUNT_OBJECTS",
31
+ accountConfigId: "cfg-1",
32
+ };
33
+
34
+ describe("handleDeleteAccountObjects", () => {
35
+ afterEach(() => {
36
+ s3Mock.reset();
37
+ sqsMock.reset();
15
38
  });
16
39
 
17
- it("event with continuation token is correct", () => {
18
- const event: DeleteAccountObjectsEvent = {
19
- type: "DELETE_ACCOUNT_OBJECTS",
20
- accountConfigId: "test-account-config-id-12345",
21
- continuationToken: "abc123",
22
- };
23
-
24
- assert.equal(event.continuationToken, "abc123");
40
+ it("lists and deletes every object under the account prefix in one page", async () => {
41
+ s3Mock.on(ListObjectsV2Command).resolves({
42
+ Contents: [
43
+ { Key: "accounts/cfg-1/a.eml" },
44
+ { Key: "accounts/cfg-1/b.eml" },
45
+ ],
46
+ IsTruncated: false,
47
+ });
48
+ s3Mock.on(DeleteObjectsCommand).resolves({});
49
+
50
+ await handleDeleteAccountObjects(event, noopLog);
51
+
52
+ const listCall = s3Mock.commandCalls(ListObjectsV2Command)[0];
53
+ assert.equal(listCall.args[0].input.Prefix, "accounts/cfg-1/");
54
+
55
+ const deleteCall = s3Mock.commandCalls(DeleteObjectsCommand)[0];
56
+ assert.deepEqual(deleteCall.args[0].input.Delete?.Objects, [
57
+ { Key: "accounts/cfg-1/a.eml" },
58
+ { Key: "accounts/cfg-1/b.eml" },
59
+ ]);
60
+ assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 0);
25
61
  });
26
62
 
27
- it("S3 prefix follows expected pattern", () => {
28
- const accountConfigId = "test-account-config-id-12345";
29
- const prefix = `accounts/${accountConfigId}/`;
30
-
31
- assert.ok(prefix.startsWith("accounts/"));
32
- assert.ok(prefix.endsWith("/"));
33
- assert.ok(prefix.includes(accountConfigId));
63
+ it("follows the continuation token across truncated pages", async () => {
64
+ s3Mock
65
+ .on(ListObjectsV2Command)
66
+ .resolvesOnce({
67
+ Contents: [{ Key: "accounts/cfg-1/p1.eml" }],
68
+ IsTruncated: true,
69
+ NextContinuationToken: "tok-2",
70
+ })
71
+ .resolvesOnce({
72
+ Contents: [{ Key: "accounts/cfg-1/p2.eml" }],
73
+ IsTruncated: false,
74
+ });
75
+ s3Mock.on(DeleteObjectsCommand).resolves({});
76
+
77
+ await handleDeleteAccountObjects(event, noopLog);
78
+
79
+ const listCalls = s3Mock.commandCalls(ListObjectsV2Command);
80
+ assert.equal(listCalls.length, 2);
81
+ assert.equal(listCalls[1]?.args[0].input.ContinuationToken, "tok-2");
82
+ assert.equal(s3Mock.commandCalls(DeleteObjectsCommand).length, 2);
34
83
  });
35
84
 
36
- it("batch size calculation respects limit", () => {
37
- const BATCH_SIZE = 1_000;
38
- const keys = Array.from({ length: 2500 }, (_, i) => `key-${i}`);
85
+ it("skips the delete call when a page holds no keys", async () => {
86
+ s3Mock.on(ListObjectsV2Command).resolves({ IsTruncated: false });
39
87
 
40
- // Simulate batching
41
- const batches: string[][] = [];
42
- for (let i = 0; i < keys.length; i += BATCH_SIZE) {
43
- batches.push(keys.slice(i, i + BATCH_SIZE));
44
- }
88
+ await handleDeleteAccountObjects(event, noopLog);
45
89
 
46
- assert.equal(batches.length, 3);
47
- assert.equal(batches[0].length, 1000);
48
- assert.equal(batches[1].length, 1000);
49
- assert.equal(batches[2].length, 500);
90
+ assert.equal(s3Mock.commandCalls(DeleteObjectsCommand).length, 0);
50
91
  });
51
92
 
52
- it("re-enqueue event preserves continuation token", () => {
53
- const accountConfigId = "test-id";
54
- const continuationToken = "next-page-token";
55
-
56
- const reenqueueEvent: DeleteAccountObjectsEvent = {
57
- type: "DELETE_ACCOUNT_OBJECTS",
58
- accountConfigId,
59
- continuationToken,
60
- };
61
-
62
- const body = JSON.stringify(reenqueueEvent);
63
- const parsed = JSON.parse(body) as DeleteAccountObjectsEvent;
64
-
65
- assert.equal(parsed.type, "DELETE_ACCOUNT_OBJECTS");
66
- assert.equal(parsed.accountConfigId, accountConfigId);
67
- assert.equal(parsed.continuationToken, continuationToken);
93
+ it("re-enqueues with the continuation token when time is nearly up", async () => {
94
+ s3Mock.on(ListObjectsV2Command).resolves({ IsTruncated: false });
95
+ sqsMock.on(SendMessageCommand).resolves({});
96
+
97
+ await handleDeleteAccountObjects(
98
+ { ...event, continuationToken: "tok-mid" },
99
+ noopLog,
100
+ () => 5_000,
101
+ );
102
+
103
+ assert.equal(
104
+ s3Mock.commandCalls(ListObjectsV2Command).length,
105
+ 0,
106
+ "bails before starting a new page",
107
+ );
108
+ const send = sqsMock.commandCalls(SendMessageCommand)[0];
109
+ const body = JSON.parse(
110
+ send?.args[0].input.MessageBody ?? "{}",
111
+ ) as DeleteAccountObjectsEvent;
112
+ assert.equal(body.type, "DELETE_ACCOUNT_OBJECTS");
113
+ assert.equal(body.accountConfigId, "cfg-1");
114
+ assert.equal(body.continuationToken, "tok-mid");
68
115
  });
69
116
 
70
- it("timeout detection triggers re-enqueue", () => {
71
- const MIN_REMAINING_MS = 30_000;
117
+ it("keeps paging while time remains", async () => {
118
+ s3Mock.on(ListObjectsV2Command).resolves({
119
+ Contents: [{ Key: "accounts/cfg-1/x.eml" }],
120
+ IsTruncated: false,
121
+ });
122
+ s3Mock.on(DeleteObjectsCommand).resolves({});
72
123
 
73
- // Simulate near-timeout scenario
74
- const getRemainingTimeMs = () => 25_000;
75
- assert.ok(getRemainingTimeMs() < MIN_REMAINING_MS);
124
+ await handleDeleteAccountObjects(event, noopLog, () => 120_000);
76
125
 
77
- // Simulate enough time
78
- const getRemainingTimeMsOk = () => 60_000;
79
- assert.ok(getRemainingTimeMsOk() >= MIN_REMAINING_MS);
126
+ assert.equal(s3Mock.commandCalls(ListObjectsV2Command).length, 1);
127
+ assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 0);
80
128
  });
81
129
  });
@@ -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
+ });