@remit/drizzle-service 0.0.50 → 0.0.51

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/drizzle-service",
3
- "version": "0.0.50",
3
+ "version": "0.0.51",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,8 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { after, before, describe, test } from "node:test";
4
- import { mailboxSpecialUseTable, mailboxTable } from "../schema.js";
4
+ import { composeFolderRoleAppointmentName } from "@remit/data-ports/folder-role";
5
+ import { CanonicalMailboxRole } from "@remit/domain-enums";
6
+ import {
7
+ accountSettingTable,
8
+ accountTable,
9
+ mailboxSpecialUseTable,
10
+ mailboxTable,
11
+ } from "../schema.js";
5
12
  import { createSqliteTestDb } from "../test-db-sqlite.js";
13
+ import { AccountSettingRepo } from "./i4-account-setting.js";
6
14
  import { MailboxRepo } from "./i4-mailbox.js";
7
15
  import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
8
16
 
@@ -24,27 +32,160 @@ const makeMailboxInput = (accountId: string, fullPath: string) => ({
24
32
  lastMessageSyncAt: Date.now(),
25
33
  });
26
34
 
27
- describe("MailboxSpecialUseRepo.findJunkMailbox (sqlite)", () => {
35
+ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
28
36
  let close: () => Promise<void>;
29
37
  let repo: MailboxSpecialUseRepo;
30
38
  let mailboxes: MailboxRepo;
39
+ let accountSettings: AccountSettingRepo;
40
+ let db: Awaited<ReturnType<typeof createSqliteTestDb>>["db"];
31
41
 
32
42
  before(async () => {
33
43
  const testDb = await createSqliteTestDb({
44
+ account: accountTable,
45
+ accountSetting: accountSettingTable,
34
46
  mailbox: mailboxTable,
35
47
  mailboxSpecialUse: mailboxSpecialUseTable,
36
48
  });
37
49
  close = testDb.close;
50
+ db = testDb.db;
38
51
  repo = new MailboxSpecialUseRepo(testDb.db as never);
39
52
  mailboxes = new MailboxRepo(testDb.db as never);
53
+ accountSettings = new AccountSettingRepo(testDb.db as never);
40
54
  });
41
55
 
42
56
  after(async () => {
43
57
  await close();
44
58
  });
45
59
 
46
- test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
60
+ const makeAccount = async (): Promise<{
61
+ accountId: string;
62
+ accountConfigId: string;
63
+ }> => {
47
64
  const accountId = randomUUID();
65
+ const accountConfigId = randomUUID();
66
+ const now = Date.now();
67
+ await db.insert(accountTable).values({
68
+ accountId,
69
+ accountConfigId,
70
+ username: `${accountId}@remit.test`,
71
+ email: `${accountId}@remit.test`,
72
+ imapHost: "imap.remit.test",
73
+ imapPort: 993,
74
+ imapTls: true,
75
+ imapStartTls: false,
76
+ isActive: true,
77
+ connectionState: "authenticated",
78
+ createdAt: now,
79
+ updatedAt: now,
80
+ } as never);
81
+ return { accountId, accountConfigId };
82
+ };
83
+
84
+ const appoint = (
85
+ accountConfigId: string,
86
+ accountId: string,
87
+ role: (typeof CanonicalMailboxRole)[keyof typeof CanonicalMailboxRole],
88
+ mailboxId: string,
89
+ ): Promise<unknown> =>
90
+ accountSettings.upsert({
91
+ accountConfigId,
92
+ name: composeFolderRoleAppointmentName(accountId, role),
93
+ value: { kind: "String", value: mailboxId },
94
+ });
95
+
96
+ test("the appointment beats the folder the server flagged", async () => {
97
+ const { accountId, accountConfigId } = await makeAccount();
98
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
99
+ const flagged = await mailboxes.create(makeMailboxInput(accountId, "Junk"));
100
+ await repo.create(flagged.mailboxId, "Junk");
101
+ const chosen = await mailboxes.create(
102
+ makeMailboxInput(accountId, "INBOX/Rubbish"),
103
+ );
104
+ await appoint(
105
+ accountConfigId,
106
+ accountId,
107
+ CanonicalMailboxRole.Junk,
108
+ chosen.mailboxId,
109
+ );
110
+
111
+ const found = await repo.findJunkMailbox(accountId);
112
+ assert.equal(found?.mailboxId, chosen.mailboxId);
113
+ });
114
+
115
+ test("proposes [Gmail]/Trash beside a top-level Bin, and yields to the appointment", async () => {
116
+ // #837: joining Trash to the leaf-name resolver made depth outrank the
117
+ // name, so `Bin` beat `[Gmail]/Trash` and deletes landed in the folder
118
+ // the user keeps mail in. `bin` is no longer a hint, and an appointment
119
+ // overrides the proposal either way.
120
+ const { accountId, accountConfigId } = await makeAccount();
121
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
122
+ const bin = await mailboxes.create(makeMailboxInput(accountId, "Bin"));
123
+ const gmailTrash = await mailboxes.create(
124
+ makeMailboxInput(accountId, "[Gmail]/Trash"),
125
+ );
126
+
127
+ assert.equal(
128
+ (await repo.findTrashMailbox(accountId))?.mailboxId,
129
+ gmailTrash.mailboxId,
130
+ );
131
+
132
+ await appoint(
133
+ accountConfigId,
134
+ accountId,
135
+ CanonicalMailboxRole.Trash,
136
+ bin.mailboxId,
137
+ );
138
+ assert.equal(
139
+ (await repo.findTrashMailbox(accountId))?.mailboxId,
140
+ bin.mailboxId,
141
+ );
142
+ });
143
+
144
+ test("an appointment on a mailbox that is gone falls back rather than resolving to nothing", async () => {
145
+ const { accountId, accountConfigId } = await makeAccount();
146
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
147
+ const archive = await mailboxes.create(
148
+ makeMailboxInput(accountId, "INBOX/Archive"),
149
+ );
150
+ await appoint(
151
+ accountConfigId,
152
+ accountId,
153
+ CanonicalMailboxRole.Archive,
154
+ randomUUID(),
155
+ );
156
+
157
+ const found = await repo.findArchiveMailbox(accountId);
158
+ assert.equal(found?.mailboxId, archive.mailboxId);
159
+ });
160
+
161
+ test("never offers a user folder named Deleted as the Trash to expunge", async () => {
162
+ // Empty Trash EXPUNGES what this returns. An ordinary folder called
163
+ // `Deleted` matches the name proposal, and emptying it would destroy mail
164
+ // the user never put in a trash folder (audit #841).
165
+ const { accountId, accountConfigId } = await makeAccount();
166
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
167
+ const keepsakes = await mailboxes.create(
168
+ makeMailboxInput(accountId, "Deleted"),
169
+ );
170
+ const trash = await mailboxes.create(
171
+ makeMailboxInput(accountId, "[Gmail]/Trash"),
172
+ );
173
+
174
+ assert.equal(await repo.findConfirmedTrashMailbox(accountId), null);
175
+
176
+ await appoint(
177
+ accountConfigId,
178
+ accountId,
179
+ CanonicalMailboxRole.Trash,
180
+ trash.mailboxId,
181
+ );
182
+ const confirmed = await repo.findConfirmedTrashMailbox(accountId);
183
+ assert.equal(confirmed?.mailboxId, trash.mailboxId);
184
+ assert.notEqual(confirmed?.mailboxId, keepsakes.mailboxId);
185
+ });
186
+
187
+ test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
188
+ const { accountId } = await makeAccount();
48
189
  await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
49
190
  const spam = await mailboxes.create(
50
191
  makeMailboxInput(accountId, "INBOX/Spam"),
@@ -59,7 +200,7 @@ describe("MailboxSpecialUseRepo.findJunkMailbox (sqlite)", () => {
59
200
  // The name fallback used to compare the whole path against a list of
60
201
  // bare names, so `INBOX/Spam` resolved to nothing on a server that
61
202
  // advertises no \Junk.
62
- const accountId = randomUUID();
203
+ const { accountId } = await makeAccount();
63
204
  await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
64
205
  const spam = await mailboxes.create(
65
206
  makeMailboxInput(accountId, "INBOX/Spam"),
@@ -70,7 +211,7 @@ describe("MailboxSpecialUseRepo.findJunkMailbox (sqlite)", () => {
70
211
  });
71
212
 
72
213
  test("answers null when the account has no Junk folder at all", async () => {
73
- const accountId = randomUUID();
214
+ const { accountId } = await makeAccount();
74
215
  await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
75
216
  await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work"));
76
217
 
@@ -3,15 +3,27 @@ import type {
3
3
  MailboxSpecialUseItem,
4
4
  MailboxSpecialUseValue,
5
5
  } from "@remit/data-ports";
6
- import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
7
- import { JUNK_FOLDER_NAMES } from "@remit/data-ports/mailbox-role";
8
- import { eq } from "drizzle-orm";
6
+ import {
7
+ type CanonicalMailboxRoleValue,
8
+ composeFolderRoleAppointmentName,
9
+ type RoleMailboxCandidate,
10
+ resolveConfirmedMailboxForRole,
11
+ resolveMailboxForRole,
12
+ } from "@remit/data-ports/folder-role";
13
+ import { CanonicalMailboxRole } from "@remit/domain-enums";
14
+ import { eq, inArray } from "drizzle-orm";
9
15
  import type { Db } from "../db.js";
10
16
  import { randomId } from "../id.js";
17
+ import { accountTable } from "../schema/i4-account-config.js";
11
18
  import { mailboxSpecialUseTable, mailboxTable } from "../schema/i4-mailbox.js";
19
+ import { AccountSettingRepo } from "./i4-account-setting.js";
12
20
 
13
21
  type DB = Db<Record<string, unknown>>;
14
22
 
23
+ interface RoleCandidate extends RoleMailboxCandidate {
24
+ fullPath: string;
25
+ }
26
+
15
27
  function rowToSpecialUse(
16
28
  row: typeof mailboxSpecialUseTable.$inferSelect,
17
29
  ): MailboxSpecialUseItem {
@@ -23,7 +35,11 @@ function rowToSpecialUse(
23
35
  }
24
36
 
25
37
  export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
26
- constructor(private db: DB) {}
38
+ private readonly accountSetting: AccountSettingRepo;
39
+
40
+ constructor(private db: DB) {
41
+ this.accountSetting = new AccountSettingRepo(db);
42
+ }
27
43
 
28
44
  async create(
29
45
  mailboxId: string,
@@ -74,92 +90,125 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
74
90
  return existing.length;
75
91
  }
76
92
 
77
- async findBySpecialUse(
93
+ findInboxMailbox(
78
94
  accountId: string,
79
- specialUse: MailboxSpecialUseValue,
80
95
  ): Promise<{ mailboxId: string; fullPath: string } | null> {
81
- const mailboxes = await this.db
82
- .select()
83
- .from(mailboxTable)
84
- .where(eq(mailboxTable.accountId, accountId));
85
-
86
- for (const mailbox of mailboxes) {
87
- const entries = await this.listByMailboxId(mailbox.mailboxId);
88
- if (entries.some((e) => e.specialUse === specialUse)) {
89
- return { mailboxId: mailbox.mailboxId, fullPath: mailbox.fullPath };
90
- }
91
- }
92
- return null;
96
+ return this.findMailboxForRole(accountId, CanonicalMailboxRole.Inbox);
93
97
  }
94
98
 
95
- async findInboxMailbox(
99
+ findSentMailbox(
96
100
  accountId: string,
97
101
  ): Promise<{ mailboxId: string; fullPath: string } | null> {
98
- const rows = await this.db
99
- .select()
100
- .from(mailboxTable)
101
- .where(eq(mailboxTable.accountId, accountId));
102
- const found = rows.find((r) => r.fullPath.toUpperCase() === "INBOX");
103
- return found
104
- ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
105
- : null;
102
+ return this.findMailboxForRole(accountId, CanonicalMailboxRole.Sent);
106
103
  }
107
104
 
108
- async findTrashMailbox(
105
+ findTrashMailbox(
109
106
  accountId: string,
110
107
  ): Promise<{ mailboxId: string; fullPath: string } | null> {
111
- const bySpecialUse = await this.findBySpecialUse(accountId, "Trash");
112
- if (bySpecialUse) return bySpecialUse;
113
-
114
- const rows = await this.db
115
- .select()
116
- .from(mailboxTable)
117
- .where(eq(mailboxTable.accountId, accountId));
108
+ return this.findMailboxForRole(accountId, CanonicalMailboxRole.Trash);
109
+ }
118
110
 
119
- const names = [
120
- "trash",
121
- "deleted items",
122
- "deleted",
123
- "[gmail]/trash",
124
- "[gmail]/bin",
125
- ];
126
- const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
127
- return found
128
- ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
129
- : null;
111
+ findConfirmedTrashMailbox(
112
+ accountId: string,
113
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
114
+ return this.findMailboxForRole(
115
+ accountId,
116
+ CanonicalMailboxRole.Trash,
117
+ resolveConfirmedMailboxForRole,
118
+ );
130
119
  }
131
120
 
132
- async findArchiveMailbox(
121
+ findArchiveMailbox(
133
122
  accountId: string,
134
123
  ): Promise<{ mailboxId: string; fullPath: string } | null> {
135
- const bySpecialUse = await this.findBySpecialUse(accountId, "Archive");
136
- if (bySpecialUse) return bySpecialUse;
124
+ return this.findMailboxForRole(accountId, CanonicalMailboxRole.Archive);
125
+ }
137
126
 
138
- const rows = await this.db
139
- .select()
140
- .from(mailboxTable)
141
- .where(eq(mailboxTable.accountId, accountId));
127
+ findJunkMailbox(
128
+ accountId: string,
129
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
130
+ return this.findMailboxForRole(accountId, CanonicalMailboxRole.Junk);
131
+ }
142
132
 
143
- const names = ["archive", "archives", "[gmail]/all mail"];
144
- const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
133
+ /**
134
+ * The one read behind every `find<Role>Mailbox`: the account's mailboxes and
135
+ * its appointment for this role, handed to the shared precedence rule. Read
136
+ * fresh each time — the appointment is a single indexed row next to reads
137
+ * this path already makes, and a cache would leave a role the user just
138
+ * re-appointed in the UI pointing at the old folder until it expired.
139
+ */
140
+ private async findMailboxForRole(
141
+ accountId: string,
142
+ role: CanonicalMailboxRoleValue,
143
+ resolve: typeof resolveMailboxForRole = resolveMailboxForRole,
144
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
145
+ const [candidates, appointedMailboxId] = await Promise.all([
146
+ this.roleCandidates(accountId),
147
+ this.appointedMailboxId(accountId, role),
148
+ ]);
149
+ const found = resolve(role, candidates, appointedMailboxId);
145
150
  return found
146
151
  ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
147
152
  : null;
148
153
  }
149
154
 
150
- async findJunkMailbox(
155
+ /**
156
+ * `undefined` whenever the account has no appointment for the role — and
157
+ * equally when the account row itself is gone, which is a caller racing a
158
+ * delete rather than a reason to fail a lookup the proposal can still answer.
159
+ */
160
+ private async appointedMailboxId(
151
161
  accountId: string,
152
- ): Promise<{ mailboxId: string; fullPath: string } | null> {
153
- const bySpecialUse = await this.findBySpecialUse(accountId, "Junk");
154
- if (bySpecialUse) return bySpecialUse;
162
+ role: CanonicalMailboxRoleValue,
163
+ ): Promise<string | undefined> {
164
+ const [account] = await this.db
165
+ .select({ accountConfigId: accountTable.accountConfigId })
166
+ .from(accountTable)
167
+ .where(eq(accountTable.accountId, accountId));
168
+ if (!account) return undefined;
169
+
170
+ const setting = await this.accountSetting.get(
171
+ account.accountConfigId,
172
+ composeFolderRoleAppointmentName(accountId, role),
173
+ );
174
+ if (!setting || setting.value.kind !== "String") return undefined;
175
+ return setting.value.value;
176
+ }
155
177
 
178
+ /**
179
+ * The account's mailboxes with their SPECIAL-USE designations attached. The
180
+ * designations come from `mailboxSpecialUseEntry` rather than the mailbox
181
+ * row's denormalized column, because that entry table is what `create` and
182
+ * `createMany` here write.
183
+ */
184
+ private async roleCandidates(accountId: string): Promise<RoleCandidate[]> {
156
185
  const rows = await this.db
157
186
  .select()
158
187
  .from(mailboxTable)
159
188
  .where(eq(mailboxTable.accountId, accountId));
160
- const found = resolveMailboxByLeafName(rows, JUNK_FOLDER_NAMES);
161
- return found
162
- ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
163
- : null;
189
+ if (rows.length === 0) return [];
190
+
191
+ const entries = await this.db
192
+ .select()
193
+ .from(mailboxSpecialUseTable)
194
+ .where(
195
+ inArray(
196
+ mailboxSpecialUseTable.mailboxId,
197
+ rows.map((row) => row.mailboxId),
198
+ ),
199
+ );
200
+ const byMailbox = new Map<string, string[]>();
201
+ for (const entry of entries) {
202
+ const designations = byMailbox.get(entry.mailboxId) ?? [];
203
+ designations.push(entry.specialUse);
204
+ byMailbox.set(entry.mailboxId, designations);
205
+ }
206
+
207
+ return rows.map((row) => ({
208
+ mailboxId: row.mailboxId,
209
+ fullPath: row.fullPath,
210
+ hierarchyDelimiter: row.hierarchyDelimiter,
211
+ specialUse: byMailbox.get(row.mailboxId) ?? [],
212
+ }));
164
213
  }
165
214
  }