@remit/imap-worker 0.0.86 → 0.0.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +194 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +21 -21
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +199 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +106 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +146 -15
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -0,0 +1,398 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ _resetForTest,
8
+ getClient,
9
+ type RemitClient,
10
+ setClient,
11
+ } from "@remit/backend/client";
12
+ import type {
13
+ AccountItem,
14
+ MailboxItem,
15
+ MailboxSpecialUseValue,
16
+ MessageItem,
17
+ OutboxMessageItem,
18
+ } from "@remit/data-ports";
19
+ import {
20
+ AccountAuthType,
21
+ ConnectionState,
22
+ MailboxCursorState,
23
+ } from "@remit/domain-enums";
24
+ import { createShippedSqliteFiles } from "@remit/drizzle-service/test-sqlite";
25
+ import type { Logger } from "@remit/logger";
26
+ import type { ManagedConnectionFactory } from "@remit/mailbox-service";
27
+ import {
28
+ eml,
29
+ FakeImapServer,
30
+ } from "@remit/mailbox-service/test-helpers/fake-imap";
31
+ import {
32
+ captureQueue,
33
+ type QueueCapture,
34
+ } from "@remit/mailbox-service/test-helpers/test-queue";
35
+ import { serializeEncryptedPayload } from "@remit/secrets-service";
36
+ import type { createConnectionScopeWithCredentials } from "../connection-scope.js";
37
+ import { syncMessages } from "../handlers/sync-messages.js";
38
+
39
+ export interface LogLine {
40
+ level: string;
41
+ fields: Record<string, unknown>;
42
+ }
43
+
44
+ export const capturingLogger = (lines: LogLine[]): Logger => {
45
+ const at =
46
+ (level: string) =>
47
+ (fields: Record<string, unknown>): void => {
48
+ lines.push({ level, fields });
49
+ };
50
+ const log = {
51
+ info: at("info"),
52
+ warn: at("warn"),
53
+ error: at("error"),
54
+ debug: at("debug"),
55
+ fatal: at("fatal"),
56
+ trace: at("trace"),
57
+ child: () => log,
58
+ } as unknown as Logger;
59
+ return log;
60
+ };
61
+
62
+ /** A message the way a correspondent sends one, naming it by its Message-ID. */
63
+ export const mail = (messageIdHeader: string): Buffer =>
64
+ eml({
65
+ "Message-ID": messageIdHeader,
66
+ From: "Friend <friend@example.com>",
67
+ To: "Person <person@example.com>",
68
+ Date: "Tue, 14 Nov 2023 22:13:20 +0000",
69
+ Subject: "test",
70
+ "Content-Type": "text/plain; charset=utf-8",
71
+ });
72
+
73
+ export interface FolderOptions {
74
+ specialUse?: MailboxSpecialUseValue;
75
+ uidValidity?: number;
76
+ cursorState?: MailboxItem["cursorState"];
77
+ }
78
+
79
+ export interface SeededMessage {
80
+ messageId: string;
81
+ uid: number;
82
+ messageIdHeader: string;
83
+ threadMessageId: string;
84
+ }
85
+
86
+ /**
87
+ * One SQLite store per test file, over the shipped migrations, composed by the
88
+ * backend's own SQLite composition root over filesystem storage — every
89
+ * repository and service a handler reaches is the one a self-host process
90
+ * runs. Each `world()` is a fresh account on it, with its own in-memory mail
91
+ * server at the IMAP port, and a message reaches the store the way mail does:
92
+ * delivered to the server, then synced.
93
+ */
94
+ export const openStore = async (): Promise<{
95
+ client: RemitClient;
96
+ world: (accountConfigId?: string) => Promise<World>;
97
+ close: () => void;
98
+ }> => {
99
+ const directory = mkdtempSync(join(tmpdir(), "imap-worker-world-"));
100
+ createShippedSqliteFiles(directory);
101
+ delete process.env.S3_BUCKET_NAME;
102
+ process.env.STORAGE_LOCAL_PATH = join(directory, "storage");
103
+ process.env.BETTER_AUTH_URL = "https://mail.example.com";
104
+ process.env.BETTER_AUTH_SECRET = "a-signing-secret-of-at-least-32-characters";
105
+ _resetForTest();
106
+ const client = await getClient();
107
+ setClient(client);
108
+ return {
109
+ client,
110
+ world: (accountConfigId = `cfg-${randomUUID()}`) =>
111
+ createWorld(client, accountConfigId),
112
+ close: () => {
113
+ _resetForTest();
114
+ rmSync(directory, { recursive: true, force: true });
115
+ },
116
+ };
117
+ };
118
+
119
+ export interface World {
120
+ client: RemitClient;
121
+ imap: FakeImapServer;
122
+ account: AccountItem;
123
+ logs: LogLine[];
124
+ log: Logger;
125
+ folder: (path: string, options?: FolderOptions) => Promise<MailboxItem>;
126
+ /** A folder of a second account on this world's account config. */
127
+ siblingFolder: (path: string) => Promise<MailboxItem>;
128
+ message: (
129
+ mailbox: MailboxItem,
130
+ options?: { messageIdHeader?: string },
131
+ ) => Promise<SeededMessage>;
132
+ row: (messageId: string) => Promise<MessageItem | undefined>;
133
+ threadRows: (
134
+ messageId: string,
135
+ ) => Promise<Array<{ mailboxId: string; uid?: number; isDeleted: boolean }>>;
136
+ queued: () => Array<Record<string, unknown>>;
137
+ refuseSends: (error: Error | undefined) => void;
138
+ queue: QueueCapture;
139
+ draft: (
140
+ attachment?: Buffer,
141
+ ) => Promise<{ outbox: OutboxMessageItem; attachmentId?: string }>;
142
+ scope: typeof createConnectionScopeWithCredentials;
143
+ connectionFactory: () => ManagedConnectionFactory;
144
+ }
145
+
146
+ const createWorld = async (
147
+ client: RemitClient,
148
+ accountConfigId: string,
149
+ ): Promise<World> => {
150
+ const queue = captureQueue();
151
+
152
+ const imap = new FakeImapServer();
153
+ const logs: LogLine[] = [];
154
+ const password = await client.secrets.encrypt("app-password");
155
+ const account = await client.account.create({
156
+ accountConfigId,
157
+ username: "person@example.com",
158
+ email: "person@example.com",
159
+ authType: AccountAuthType.Password,
160
+ passwordHash: JSON.stringify(serializeEncryptedPayload(password)),
161
+ imapHost: "imap.example.com",
162
+ imapPort: 993,
163
+ imapTls: true,
164
+ imapStartTls: false,
165
+ isActive: true,
166
+ connectionState: ConnectionState.Authenticated,
167
+ });
168
+
169
+ const folder = async (
170
+ path: string,
171
+ options: FolderOptions = {},
172
+ ): Promise<MailboxItem> => {
173
+ const uidValidity = options.uidValidity ?? 1;
174
+ imap.addFolder(path, { uidValidity });
175
+ const created = await client.mailbox.create({
176
+ accountId: account.accountId,
177
+ namespacePrefix: "",
178
+ hierarchyDelimiter: "/",
179
+ fullPath: path,
180
+ uidValidity,
181
+ uidNext: 1,
182
+ highestModseq: "0",
183
+ messageCount: 0,
184
+ unseenCount: 0,
185
+ deletedCount: 0,
186
+ totalSize: 0,
187
+ lastSyncUid: 0,
188
+ highWaterMarkUid: 0,
189
+ lastMessageSyncAt: 0,
190
+ syncStatus: "synced",
191
+ });
192
+ if (created.outcome !== "Created") {
193
+ throw new Error(`${path} could not be created: ${created.outcome}`);
194
+ }
195
+ const mailbox = created.mailbox;
196
+ if (options.specialUse) {
197
+ await client.mailboxSpecialUse.create(
198
+ mailbox.mailboxId,
199
+ options.specialUse,
200
+ );
201
+ }
202
+ if (
203
+ options.cursorState &&
204
+ options.cursorState !== MailboxCursorState.normal
205
+ ) {
206
+ await client.mailbox.update(account.accountId, mailbox.mailboxId, {
207
+ cursorState: options.cursorState,
208
+ });
209
+ return client.mailbox.get(account.accountId, mailbox.mailboxId);
210
+ }
211
+ return mailbox;
212
+ };
213
+
214
+ const siblingFolder = async (path: string): Promise<MailboxItem> => {
215
+ const sibling = await client.account.create({
216
+ accountConfigId,
217
+ username: "person-too@example.com",
218
+ email: "person-too@example.com",
219
+ authType: AccountAuthType.Password,
220
+ passwordHash: JSON.stringify(serializeEncryptedPayload(password)),
221
+ imapHost: "imap.example.com",
222
+ imapPort: 993,
223
+ imapTls: true,
224
+ imapStartTls: false,
225
+ isActive: true,
226
+ connectionState: ConnectionState.Authenticated,
227
+ });
228
+ const created = await client.mailbox.create({
229
+ accountId: sibling.accountId,
230
+ namespacePrefix: "",
231
+ hierarchyDelimiter: "/",
232
+ fullPath: path,
233
+ uidValidity: 1,
234
+ uidNext: 1,
235
+ highestModseq: "0",
236
+ messageCount: 0,
237
+ unseenCount: 0,
238
+ deletedCount: 0,
239
+ totalSize: 0,
240
+ lastSyncUid: 0,
241
+ highWaterMarkUid: 0,
242
+ lastMessageSyncAt: 0,
243
+ syncStatus: "synced",
244
+ });
245
+ if (created.outcome !== "Created") {
246
+ throw new Error(`${path} could not be created: ${created.outcome}`);
247
+ }
248
+ return created.mailbox;
249
+ };
250
+
251
+ const message = async (
252
+ mailbox: MailboxItem,
253
+ options: { messageIdHeader?: string } = {},
254
+ ): Promise<SeededMessage> => {
255
+ const messageIdHeader =
256
+ options.messageIdHeader ?? `<${randomUUID()}@example.com>`;
257
+ const uid = imap.deliver(mailbox.fullPath, mail(messageIdHeader));
258
+ const baseline = {
259
+ connections: imap.connections,
260
+ commands: imap.log.length,
261
+ };
262
+ await syncMessages(
263
+ {
264
+ type: "SYNC_MESSAGES",
265
+ eventId: randomUUID(),
266
+ timestamp: Date.now(),
267
+ accountId: account.accountId,
268
+ mailboxId: mailbox.mailboxId,
269
+ },
270
+ capturingLogger([]),
271
+ connectionFactory,
272
+ );
273
+ imap.connections = baseline.connections;
274
+ imap.log.length = baseline.commands;
275
+ const synced = (
276
+ await client.message.listAllByMailbox(mailbox.mailboxId)
277
+ ).find((row) => row.uid === uid);
278
+ if (!synced)
279
+ throw new Error(`uid ${uid} in ${mailbox.fullPath} did not sync`);
280
+ const [threadMessage] = await client.threadMessage.findAllByMessageId(
281
+ accountConfigId,
282
+ synced.messageId,
283
+ );
284
+ if (!threadMessage)
285
+ throw new Error(`uid ${uid} synced without a listing row`);
286
+ return {
287
+ messageId: synced.messageId,
288
+ uid,
289
+ messageIdHeader,
290
+ threadMessageId: threadMessage.threadMessageId,
291
+ };
292
+ };
293
+
294
+ const row = async (messageId: string): Promise<MessageItem | undefined> =>
295
+ (await client.message.get([messageId]))[0];
296
+
297
+ const threadRows = async (messageId: string) =>
298
+ (
299
+ await client.threadMessage.findAllByMessageId(accountConfigId, messageId)
300
+ ).map((tm) => ({
301
+ mailboxId: tm.mailboxId,
302
+ uid: tm.uid,
303
+ isDeleted: tm.isDeleted,
304
+ }));
305
+
306
+ const queued = (): Array<Record<string, unknown>> =>
307
+ queue.sent.map((event) => event.body);
308
+
309
+ const refuseSends = (error: Error | undefined): void => {
310
+ queue.answer(async () => {
311
+ if (error) throw error;
312
+ return {};
313
+ });
314
+ };
315
+
316
+ const draft = async (
317
+ attachment?: Buffer,
318
+ ): Promise<{ outbox: OutboxMessageItem; attachmentId?: string }> => {
319
+ const outbox = await client.outboxQueue.createDraft({
320
+ accountId: account.accountId,
321
+ accountConfigId,
322
+ fromAddress: "person@example.com",
323
+ fromName: "Person",
324
+ toAddresses: ["friend@example.com"],
325
+ subject: "Quarterly numbers",
326
+ textBody: "See you Thursday.",
327
+ });
328
+ if (!attachment) return { outbox };
329
+ const minted = await client.outboxAttachment.mint({
330
+ accountConfigId,
331
+ outboxMessageId: outbox.outboxMessageId,
332
+ filename: "numbers.csv",
333
+ contentType: "text/csv",
334
+ sizeBytes: attachment.length,
335
+ });
336
+ if (minted.outcome !== "Minted") throw new Error("the upload was refused");
337
+ const attachmentId = minted.reservation.outboxAttachmentId;
338
+ await client.storage.storeOutboxAttachment({
339
+ accountConfigId,
340
+ accountId: account.accountId,
341
+ outboxMessageId: outbox.outboxMessageId,
342
+ outboxAttachmentId: attachmentId,
343
+ content: attachment,
344
+ });
345
+ const completed = await client.outboxAttachment.complete({
346
+ accountConfigId,
347
+ outboxMessageId: outbox.outboxMessageId,
348
+ outboxAttachmentId: attachmentId,
349
+ });
350
+ assert.equal(completed.outcome, "Completed");
351
+ return { outbox, attachmentId };
352
+ };
353
+
354
+ const scope = () => {
355
+ let open: ReturnType<FakeImapServer["connection"]> | undefined;
356
+ return {
357
+ getConnection: async () => {
358
+ open ??= imap.connection();
359
+ return open;
360
+ },
361
+ disconnect: async () => {
362
+ open = undefined;
363
+ },
364
+ };
365
+ };
366
+
367
+ const connectionFactory = (): ManagedConnectionFactory => {
368
+ let open: ReturnType<FakeImapServer["connection"]> | undefined;
369
+ return {
370
+ getConnection: () => {
371
+ open ??= imap.connection();
372
+ return open;
373
+ },
374
+ close: async () => {
375
+ open = undefined;
376
+ },
377
+ };
378
+ };
379
+
380
+ return {
381
+ client,
382
+ imap,
383
+ account,
384
+ logs,
385
+ log: capturingLogger(logs),
386
+ folder,
387
+ siblingFolder,
388
+ message,
389
+ row,
390
+ threadRows,
391
+ queued,
392
+ refuseSends,
393
+ queue,
394
+ draft,
395
+ scope,
396
+ connectionFactory,
397
+ };
398
+ };
@@ -0,0 +1,106 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IMailboxRepository,
5
+ IMailboxSpecialUseRepository,
6
+ MailboxItem,
7
+ } from "@remit/data-ports";
8
+ import { deriveThreadId } from "@remit/data-ports/id";
9
+ import { createSqliteRepositories } from "@remit/drizzle-service";
10
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
11
+ import { MessageSyncService, SyncWarningService } from "@remit/mailbox-service";
12
+ import type { ImapMessage } from "@remit/mailbox-service/types";
13
+
14
+ const ACCOUNT = "acct-1";
15
+ const CONFIG = "cfg-1";
16
+ const MAILBOX = { mailboxId: "mbx-1", fullPath: "INBOX" } as MailboxItem;
17
+ const ROLES = {
18
+ junkMailboxId: null,
19
+ trashMailboxId: null,
20
+ configJunkRoles: { junkMailboxIds: [], trashMailboxIds: [] },
21
+ };
22
+
23
+ const message = (
24
+ uid: number,
25
+ messageId: string,
26
+ inReplyTo: string,
27
+ references?: string[],
28
+ ): ImapMessage =>
29
+ ({
30
+ uid,
31
+ seq: uid,
32
+ size: 100,
33
+ internalDate: new Date(uid * 1000),
34
+ flags: [],
35
+ references,
36
+ envelope: {
37
+ date: new Date(uid * 1000).toISOString(),
38
+ messageId,
39
+ subject: `Subject ${uid}`,
40
+ from: [{ name: "Sender", mailbox: "sender", host: "example.com" }],
41
+ sender: [],
42
+ replyTo: [],
43
+ to: [],
44
+ cc: [],
45
+ bcc: [],
46
+ inReplyTo,
47
+ },
48
+ }) as unknown as ImapMessage;
49
+
50
+ const PARENT = message(1, "<p@example.com>", "<a@example.com>", [
51
+ "<a@example.com>",
52
+ ]);
53
+ const REPLY = message(2, "<r@example.com>", "<p@example.com>");
54
+
55
+ const syncInOrder = async (
56
+ messages: ImapMessage[],
57
+ ): Promise<{ threadIds: (messageId: string) => string[] }> => {
58
+ const { db, sqlite } = createShippedSqliteDb();
59
+ const repos = createSqliteRepositories({ app: db, mail: db, search: db });
60
+ const service = new MessageSyncService(
61
+ {} as never,
62
+ {} as IMailboxRepository,
63
+ {} as IMailboxSpecialUseRepository,
64
+ repos.message,
65
+ repos.envelope,
66
+ repos.address,
67
+ repos.threadMessage,
68
+ new SyncWarningService(repos.syncWarning),
69
+ );
70
+ const save = (
71
+ service as unknown as {
72
+ saveMessage: (...args: unknown[]) => Promise<unknown>;
73
+ }
74
+ ).saveMessage.bind(service);
75
+ for (const msg of messages) {
76
+ await save(MAILBOX, ACCOUNT, CONFIG, msg, ROLES, {
77
+ departed: new Set(),
78
+ settled: new Set(),
79
+ });
80
+ }
81
+ return {
82
+ threadIds: (messageIdHeader) =>
83
+ (
84
+ sqlite
85
+ .prepare(
86
+ "SELECT thread_id FROM thread_message WHERE message_id_header = ?",
87
+ )
88
+ .all(messageIdHeader) as { thread_id: string }[]
89
+ ).map((row) => row.thread_id),
90
+ };
91
+ };
92
+
93
+ describe("a reply naming only its parent, which is itself a reply", () => {
94
+ const rootThread = deriveThreadId(CONFIG, "<a@example.com>");
95
+
96
+ it("joins the root's thread when the parent synced first", async () => {
97
+ const { threadIds } = await syncInOrder([PARENT, REPLY]);
98
+ assert.deepEqual(threadIds("<r@example.com>"), [rootThread]);
99
+ });
100
+
101
+ it("joins the root's thread when it synced before its parent, and stays there on a re-save", async () => {
102
+ const { threadIds } = await syncInOrder([REPLY, PARENT, REPLY]);
103
+ assert.deepEqual(threadIds("<r@example.com>"), [rootThread]);
104
+ assert.deepEqual(threadIds("<p@example.com>"), [rootThread]);
105
+ });
106
+ });
@@ -12,8 +12,12 @@ import assert from "node:assert/strict";
12
12
  import { after, before, describe, it } from "node:test";
13
13
  import type { AccountItem } from "@remit/data-ports";
14
14
  import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
15
- import { AccountRepo } from "@remit/drizzle-service";
15
+ import {
16
+ type AccountRepo,
17
+ createSqliteRepositories,
18
+ } from "@remit/drizzle-service";
16
19
  import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
20
+ import { noopLogger } from "@remit/logger";
17
21
  import { RefreshTokenError } from "@remit/mail-oauth-service";
18
22
  import { MailConnectionError } from "@remit/mailbox-service/types";
19
23
  import type { SecretsService } from "@remit/secrets-service";
@@ -23,16 +27,6 @@ import {
23
27
  } from "./with-oauth-lifecycle.js";
24
28
  import { buildLifecycleDeps } from "./with-oauth-lifecycle-deps.js";
25
29
 
26
- const silentLogger = {
27
- info: () => {},
28
- warn: () => {},
29
- error: () => {},
30
- debug: () => {},
31
- trace: () => {},
32
- fatal: () => {},
33
- child: () => silentLogger,
34
- } as never;
35
-
36
30
  const secrets = {
37
31
  decrypt: async () => "decrypted",
38
32
  encrypt: async () => ({}) as never,
@@ -76,7 +70,11 @@ const account = async (): Promise<AccountItem> =>
76
70
  before(() => {
77
71
  const db = createShippedSqliteDb();
78
72
  close = db.close;
79
- repo = new AccountRepo(db.db);
73
+ repo = createSqliteRepositories({
74
+ app: db.db,
75
+ mail: db.db,
76
+ search: db.db,
77
+ }).account;
80
78
  });
81
79
 
82
80
  after(() => {
@@ -87,7 +85,7 @@ describe("the reason an account card reads back", () => {
87
85
  it("keeps the refusal the mail server gave", async () => {
88
86
  const created = await account();
89
87
 
90
- await withOAuthLifecycle(deps(), created, silentLogger, async () => {
88
+ await withOAuthLifecycle(deps(), created, noopLogger, async () => {
91
89
  throw new MailConnectionError("auth", REFUSAL);
92
90
  });
93
91
 
@@ -107,7 +105,7 @@ describe("the reason an account card reads back", () => {
107
105
  await withOAuthLifecycle(
108
106
  deps(),
109
107
  await repo.get(created.accountId),
110
- silentLogger,
108
+ noopLogger,
111
109
  async () => {
112
110
  throw new RefreshTokenError({
113
111
  kind: "reauth-required",
@@ -128,7 +126,7 @@ describe("the reason an account card reads back", () => {
128
126
  it("stores a chatty refusal at a length a card can hold", async () => {
129
127
  const created = await account();
130
128
 
131
- await withOAuthLifecycle(deps(), created, silentLogger, async () => {
129
+ await withOAuthLifecycle(deps(), created, noopLogger, async () => {
132
130
  throw new MailConnectionError("auth", "x".repeat(4000));
133
131
  });
134
132
 
@@ -136,3 +134,136 @@ describe("the reason an account card reads back", () => {
136
134
  assert.equal(stored.lastError?.length, 500);
137
135
  });
138
136
  });
137
+
138
+ describe("which failures stop the work, and what the account row says after", () => {
139
+ const passwordAccount = (passwordHash?: string): Promise<AccountItem> =>
140
+ repo.create({
141
+ accountConfigId: "cfg-1",
142
+ username: "person@example.com",
143
+ email: "person@example.com",
144
+ authType: AccountAuthType.Password,
145
+ passwordHash,
146
+ imapHost: "imap.example.com",
147
+ imapPort: 993,
148
+ imapTls: true,
149
+ imapStartTls: false,
150
+ isActive: true,
151
+ connectionState: ConnectionState.Authenticated,
152
+ });
153
+
154
+ it("runs nothing for an account that has to sign in again", async () => {
155
+ const created = await account();
156
+ await repo.update(created.accountId, {
157
+ connectionState: ConnectionState.ReauthRequired,
158
+ });
159
+ let worked = false;
160
+
161
+ await withOAuthLifecycle(
162
+ deps(),
163
+ await repo.get(created.accountId),
164
+ noopLogger,
165
+ async () => {
166
+ worked = true;
167
+ },
168
+ );
169
+
170
+ assert.equal(worked, false);
171
+ });
172
+
173
+ // Issue #1120: a password account with nothing stored can only fail, and a
174
+ // failing batch item fences the account's FIFO group.
175
+ it("acks a password account that stores no password and says the credential is missing", async () => {
176
+ const created = await passwordAccount();
177
+ let worked = false;
178
+
179
+ await withOAuthLifecycle(
180
+ buildLifecycleDeps(secrets, repo),
181
+ created,
182
+ noopLogger,
183
+ async () => {
184
+ worked = true;
185
+ },
186
+ );
187
+
188
+ assert.equal(worked, false);
189
+ assert.equal(
190
+ (await repo.get(created.accountId)).connectionState,
191
+ ConnectionState.CredentialsMissing,
192
+ );
193
+ });
194
+
195
+ it("acks an OAuth account that holds no refresh token and asks for a sign-in", async () => {
196
+ const created = await repo.create({
197
+ accountConfigId: "cfg-1",
198
+ username: "matthijs@example.com",
199
+ email: "matthijs@example.com",
200
+ authType: AccountAuthType.OauthMicrosoft,
201
+ imapHost: "outlook.office365.com",
202
+ imapPort: 993,
203
+ imapTls: true,
204
+ imapStartTls: false,
205
+ isActive: true,
206
+ connectionState: ConnectionState.Authenticated,
207
+ });
208
+
209
+ await withOAuthLifecycle(
210
+ buildLifecycleDeps(secrets, repo),
211
+ created,
212
+ noopLogger,
213
+ async () => {},
214
+ );
215
+
216
+ assert.equal(
217
+ (await repo.get(created.accountId)).connectionState,
218
+ ConnectionState.ReauthRequired,
219
+ );
220
+ });
221
+
222
+ // A password that stopped working is the user's to fix in settings; the
223
+ // state flip is reserved for OAuth, where a sign-in is the fix.
224
+ for (const [name, error] of [
225
+ ["a refused password", new MailConnectionError("auth", "auth failed")],
226
+ ["a network failure", new MailConnectionError("network", "timeout")],
227
+ ["any other error", new Error("boom")],
228
+ ] as const) {
229
+ it(`redelivers ${name} on a password account and leaves its state alone`, async () => {
230
+ const created = await passwordAccount('{"ciphertext":"x"}');
231
+
232
+ await assert.rejects(
233
+ withOAuthLifecycle(deps(), created, noopLogger, async () => {
234
+ throw error;
235
+ }),
236
+ error,
237
+ );
238
+
239
+ assert.equal(
240
+ (await repo.get(created.accountId)).connectionState,
241
+ ConnectionState.Authenticated,
242
+ );
243
+ });
244
+ }
245
+
246
+ it("redelivers a transient token failure and leaves the state alone", async () => {
247
+ const created = await account();
248
+
249
+ await assert.rejects(
250
+ withOAuthLifecycle(
251
+ {
252
+ ...deps(),
253
+ resolveCredentials: async () => {
254
+ throw new RefreshTokenError({ kind: "transient", code: "503" });
255
+ },
256
+ },
257
+ created,
258
+ noopLogger,
259
+ async () => {},
260
+ ),
261
+ /transient/,
262
+ );
263
+
264
+ assert.equal(
265
+ (await repo.get(created.accountId)).connectionState,
266
+ ConnectionState.Authenticated,
267
+ );
268
+ });
269
+ });