@remit/drizzle-service 0.0.50 → 0.0.52
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,8 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AccountConfigDescription,
|
|
3
3
|
AccountConfigItem,
|
|
4
|
-
AccountItem,
|
|
5
|
-
AddressItem,
|
|
6
4
|
CreateAccountConfigInput,
|
|
7
5
|
IAccountConfigRepository,
|
|
8
6
|
ResultList,
|
|
@@ -18,6 +16,8 @@ import {
|
|
|
18
16
|
accountTable,
|
|
19
17
|
} from "../schema/i4-account-config.js";
|
|
20
18
|
import { addressTable } from "../schema/i4-address.js";
|
|
19
|
+
import { rowToAccount } from "./i4-account.js";
|
|
20
|
+
import { rowToAddress } from "./i4-address.js";
|
|
21
21
|
|
|
22
22
|
type DB = Db<Record<string, unknown>>;
|
|
23
23
|
|
|
@@ -36,62 +36,6 @@ function rowToAccountConfig(
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function rowToAccount(row: typeof accountTable.$inferSelect): AccountItem {
|
|
40
|
-
return {
|
|
41
|
-
accountId: row.accountId,
|
|
42
|
-
accountConfigId: row.accountConfigId,
|
|
43
|
-
username: row.username,
|
|
44
|
-
email: row.email,
|
|
45
|
-
authType: row.authType as AccountItem["authType"],
|
|
46
|
-
passwordHash: row.passwordHash ?? undefined,
|
|
47
|
-
oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
|
|
48
|
-
oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
|
|
49
|
-
imapHost: row.imapHost,
|
|
50
|
-
imapPort: row.imapPort,
|
|
51
|
-
imapTls: row.imapTls,
|
|
52
|
-
imapStartTls: row.imapStartTls,
|
|
53
|
-
smtpEnabled: row.smtpEnabled,
|
|
54
|
-
smtpHost: row.smtpHost,
|
|
55
|
-
smtpPort: row.smtpPort,
|
|
56
|
-
smtpTls: row.smtpTls,
|
|
57
|
-
smtpStartTls: row.smtpStartTls,
|
|
58
|
-
smtpUsername: row.smtpUsername,
|
|
59
|
-
smtpPasswordHash: row.smtpPasswordHash ?? undefined,
|
|
60
|
-
isActive: row.isActive,
|
|
61
|
-
connectionState: row.connectionState as AccountItem["connectionState"],
|
|
62
|
-
lastConnectedAt: row.lastConnectedAt ?? undefined,
|
|
63
|
-
lastSyncAt: row.lastSyncAt ?? undefined,
|
|
64
|
-
lastError: row.lastError ?? undefined,
|
|
65
|
-
syncPhase: (row.syncPhase as AccountItem["syncPhase"]) ?? undefined,
|
|
66
|
-
mailboxCountTotal: row.mailboxCountTotal ?? undefined,
|
|
67
|
-
mailboxCountSynced: row.mailboxCountSynced ?? undefined,
|
|
68
|
-
createdAt: row.createdAt,
|
|
69
|
-
updatedAt: row.updatedAt,
|
|
70
|
-
deletedAt: row.deletedAt ?? undefined,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function rowToAddress(row: typeof addressTable.$inferSelect): AddressItem {
|
|
75
|
-
return {
|
|
76
|
-
addressId: row.addressId,
|
|
77
|
-
accountConfigId: row.accountConfigId,
|
|
78
|
-
displayName: row.displayName ?? undefined,
|
|
79
|
-
localPart: row.localPart,
|
|
80
|
-
domain: row.domain,
|
|
81
|
-
normalizedEmail: row.normalizedEmail,
|
|
82
|
-
normalizedCompound: row.normalizedCompound,
|
|
83
|
-
flags: (row.flags ?? {}) as AddressItem["flags"],
|
|
84
|
-
inboundCount: row.inboundCount,
|
|
85
|
-
outboundCount: row.outboundCount,
|
|
86
|
-
replyCount: row.replyCount,
|
|
87
|
-
lastInboundAt: row.lastInboundAt,
|
|
88
|
-
lastOutboundAt: row.lastOutboundAt ?? undefined,
|
|
89
|
-
lastReplyAt: row.lastReplyAt,
|
|
90
|
-
createdAt: row.createdAt,
|
|
91
|
-
updatedAt: row.updatedAt,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
39
|
export class AccountConfigRepo implements IAccountConfigRepository {
|
|
96
40
|
constructor(private db: DB) {}
|
|
97
41
|
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
UpdateAddressInput,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
12
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
13
|
+
import { shouldPromoteWellknown } from "@remit/data-ports/wellknown";
|
|
13
14
|
import {
|
|
14
15
|
and,
|
|
15
16
|
asc,
|
|
@@ -39,7 +40,6 @@ import {
|
|
|
39
40
|
addressRecency,
|
|
40
41
|
addressSearchMatch,
|
|
41
42
|
} from "./address-search-predicates.js";
|
|
42
|
-
import { shouldPromoteWellknown } from "./i4-address-wellknown.js";
|
|
43
43
|
|
|
44
44
|
type DB = Db<Record<string, unknown>>;
|
|
45
45
|
|
|
@@ -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,15 +3,27 @@ import type {
|
|
|
3
3
|
MailboxSpecialUseItem,
|
|
4
4
|
MailboxSpecialUseValue,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
+
findInboxMailbox(
|
|
78
94
|
accountId: string,
|
|
79
|
-
specialUse: MailboxSpecialUseValue,
|
|
80
95
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
81
|
-
|
|
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
|
-
|
|
99
|
+
findSentMailbox(
|
|
96
100
|
accountId: string,
|
|
97
101
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
98
|
-
|
|
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
|
-
|
|
105
|
+
findTrashMailbox(
|
|
109
106
|
accountId: string,
|
|
110
107
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
121
|
+
findArchiveMailbox(
|
|
133
122
|
accountId: string,
|
|
134
123
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
135
|
-
|
|
136
|
-
|
|
124
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Archive);
|
|
125
|
+
}
|
|
137
126
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
127
|
+
findJunkMailbox(
|
|
128
|
+
accountId: string,
|
|
129
|
+
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
130
|
+
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Junk);
|
|
131
|
+
}
|
|
142
132
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
export const WELLKNOWN_INBOUND_THRESHOLD = 3;
|
|
2
|
-
export const WELLKNOWN_REPLY_THRESHOLD = 1;
|
|
3
|
-
export const WELLKNOWN_INBOUND_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
|
|
4
|
-
|
|
5
|
-
export interface WellknownEngagementSnapshot {
|
|
6
|
-
inboundCount?: number;
|
|
7
|
-
replyCount?: number;
|
|
8
|
-
lastInboundAt?: number;
|
|
9
|
-
isBulk?: boolean;
|
|
10
|
-
flags?: { wellknown?: { value: boolean } | undefined };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const shouldPromoteWellknown = (
|
|
14
|
-
snapshot: WellknownEngagementSnapshot,
|
|
15
|
-
now: number,
|
|
16
|
-
): boolean => {
|
|
17
|
-
if (snapshot.flags?.wellknown?.value === true) return false;
|
|
18
|
-
|
|
19
|
-
const replyCount = snapshot.replyCount ?? 0;
|
|
20
|
-
const inboundCount = snapshot.inboundCount ?? 0;
|
|
21
|
-
const replied = replyCount >= WELLKNOWN_REPLY_THRESHOLD;
|
|
22
|
-
const inboundEnough =
|
|
23
|
-
!snapshot.isBulk && inboundCount >= WELLKNOWN_INBOUND_THRESHOLD;
|
|
24
|
-
if (!replied && !inboundEnough) return false;
|
|
25
|
-
|
|
26
|
-
const lastInboundAt = snapshot.lastInboundAt;
|
|
27
|
-
if (lastInboundAt === undefined) return false;
|
|
28
|
-
if (now - lastInboundAt > WELLKNOWN_INBOUND_WINDOW_MS) return false;
|
|
29
|
-
|
|
30
|
-
return true;
|
|
31
|
-
};
|