@remit/imap-worker 0.0.15 → 0.0.17
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 +2 -2
- package/src/handlers/append-sent-message.test.ts +216 -0
- package/src/handlers/append-sent-message.ts +22 -0
- package/src/handlers/empty-trash.test.ts +220 -0
- package/src/handlers/empty-trash.ts +22 -0
- package/src/handlers/mailbox-management.test.ts +324 -0
- package/src/handlers/mailbox-management.ts +42 -3
- package/src/handlers/message-delete.test.ts +290 -1
- package/src/handlers/message-delete.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
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=
|
|
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=83 --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,
|