@remit/drizzle-service 0.0.49 → 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 +1 -1
- package/src/repair/junk-only-address.sqlite.test.ts +535 -0
- package/src/repair/junk-only-address.ts +158 -0
- package/src/repos/address-search-predicates.ts +15 -0
- package/src/repos/i4-address-junk-move.sqlite.test.ts +203 -0
- package/src/repos/i4-address.test.ts +217 -0
- package/src/repos/i4-address.ts +105 -0
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +146 -5
- package/src/repos/i4-mailbox-special-use.ts +111 -69
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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,21 +3,26 @@ import type {
|
|
|
3
3
|
MailboxSpecialUseItem,
|
|
4
4
|
MailboxSpecialUseValue,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
-
import {
|
|
7
|
-
|
|
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";
|
|
8
15
|
import type { Db } from "../db.js";
|
|
9
16
|
import { randomId } from "../id.js";
|
|
17
|
+
import { accountTable } from "../schema/i4-account-config.js";
|
|
10
18
|
import { mailboxSpecialUseTable, mailboxTable } from "../schema/i4-mailbox.js";
|
|
19
|
+
import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
11
20
|
|
|
12
21
|
type DB = Db<Record<string, unknown>>;
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"junk e-mail",
|
|
18
|
-
"junk email",
|
|
19
|
-
"bulk mail",
|
|
20
|
-
];
|
|
23
|
+
interface RoleCandidate extends RoleMailboxCandidate {
|
|
24
|
+
fullPath: string;
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
function rowToSpecialUse(
|
|
23
28
|
row: typeof mailboxSpecialUseTable.$inferSelect,
|
|
@@ -30,7 +35,11 @@ function rowToSpecialUse(
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
33
|
-
|
|
38
|
+
private readonly accountSetting: AccountSettingRepo;
|
|
39
|
+
|
|
40
|
+
constructor(private db: DB) {
|
|
41
|
+
this.accountSetting = new AccountSettingRepo(db);
|
|
42
|
+
}
|
|
34
43
|
|
|
35
44
|
async create(
|
|
36
45
|
mailboxId: string,
|
|
@@ -81,92 +90,125 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
81
90
|
return existing.length;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
findInboxMailbox(
|
|
85
94
|
accountId: string,
|
|
86
|
-
specialUse: MailboxSpecialUseValue,
|
|
87
95
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
88
|
-
|
|
89
|
-
.select()
|
|
90
|
-
.from(mailboxTable)
|
|
91
|
-
.where(eq(mailboxTable.accountId, accountId));
|
|
92
|
-
|
|
93
|
-
for (const mailbox of mailboxes) {
|
|
94
|
-
const entries = await this.listByMailboxId(mailbox.mailboxId);
|
|
95
|
-
if (entries.some((e) => e.specialUse === specialUse)) {
|
|
96
|
-
return { mailboxId: mailbox.mailboxId, fullPath: mailbox.fullPath };
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return null;
|
|
96
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Inbox);
|
|
100
97
|
}
|
|
101
98
|
|
|
102
|
-
|
|
99
|
+
findSentMailbox(
|
|
103
100
|
accountId: string,
|
|
104
101
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
105
|
-
|
|
106
|
-
.select()
|
|
107
|
-
.from(mailboxTable)
|
|
108
|
-
.where(eq(mailboxTable.accountId, accountId));
|
|
109
|
-
const found = rows.find((r) => r.fullPath.toUpperCase() === "INBOX");
|
|
110
|
-
return found
|
|
111
|
-
? { mailboxId: found.mailboxId, fullPath: found.fullPath }
|
|
112
|
-
: null;
|
|
102
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Sent);
|
|
113
103
|
}
|
|
114
104
|
|
|
115
|
-
|
|
105
|
+
findTrashMailbox(
|
|
116
106
|
accountId: string,
|
|
117
107
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const rows = await this.db
|
|
122
|
-
.select()
|
|
123
|
-
.from(mailboxTable)
|
|
124
|
-
.where(eq(mailboxTable.accountId, accountId));
|
|
108
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Trash);
|
|
109
|
+
}
|
|
125
110
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return found
|
|
135
|
-
? { mailboxId: found.mailboxId, fullPath: found.fullPath }
|
|
136
|
-
: 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
|
+
);
|
|
137
119
|
}
|
|
138
120
|
|
|
139
|
-
|
|
121
|
+
findArchiveMailbox(
|
|
140
122
|
accountId: string,
|
|
141
123
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
142
|
-
|
|
143
|
-
|
|
124
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Archive);
|
|
125
|
+
}
|
|
144
126
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
127
|
+
findJunkMailbox(
|
|
128
|
+
accountId: string,
|
|
129
|
+
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
130
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Junk);
|
|
131
|
+
}
|
|
149
132
|
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
|
152
150
|
return found
|
|
153
151
|
? { mailboxId: found.mailboxId, fullPath: found.fullPath }
|
|
154
152
|
: null;
|
|
155
153
|
}
|
|
156
154
|
|
|
157
|
-
|
|
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(
|
|
158
161
|
accountId: string,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
162
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[]> {
|
|
163
185
|
const rows = await this.db
|
|
164
186
|
.select()
|
|
165
187
|
.from(mailboxTable)
|
|
166
188
|
.where(eq(mailboxTable.accountId, accountId));
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
}));
|
|
171
213
|
}
|
|
172
214
|
}
|