@remit/backend 0.0.1

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 (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. package/tsconfig.json +7 -0
@@ -0,0 +1,294 @@
1
+ import type {
2
+ CreateFilterInput,
3
+ FilterResponse,
4
+ UpdateFilterInput,
5
+ } from "@remit/api-openapi-types";
6
+ import type { FilterItem } from "@remit/data-ports";
7
+ import { ClientError } from "@remit/data-ports/errors";
8
+ import { FilterScope } from "@remit/domain-enums";
9
+ import type { AnchorPayload } from "@remit/search-service";
10
+ import type { APIGatewayProxyEvent } from "aws-lambda";
11
+ import { getAccountConfigIdFromEvent } from "../auth.js";
12
+ import { getClient } from "../service/dynamodb.js";
13
+ import { buildFilterAnchor } from "../service/filter.js";
14
+ import type {
15
+ FilterDetailOperationIds,
16
+ FilterOperationIds,
17
+ OperationHandler,
18
+ } from "../types.js";
19
+ import { assertAccountOwnership } from "./account-ownership.js";
20
+
21
+ /**
22
+ * Minimal filter-service surface the CRUD handlers need — declared as a `Pick`
23
+ * so tests can stub it without a live table.
24
+ */
25
+ export interface FilterCrudDeps {
26
+ filter: {
27
+ create(input: {
28
+ accountConfigId: string;
29
+ name: string;
30
+ scope: FilterItem["scope"];
31
+ expiresAt?: string;
32
+ ttl?: number;
33
+ matchOperator: FilterItem["matchOperator"];
34
+ literalClauses: FilterItem["literalClauses"];
35
+ actionLabelId: string;
36
+ actionMailboxId: string;
37
+ hasAnchor: boolean;
38
+ }): Promise<FilterItem>;
39
+ get(accountConfigId: string, filterId: string): Promise<FilterItem>;
40
+ update(
41
+ accountConfigId: string,
42
+ filterId: string,
43
+ input: Partial<UpdateFilterInput>,
44
+ ): Promise<FilterItem>;
45
+ delete(accountConfigId: string, filterId: string): Promise<void>;
46
+ refreshExpiry(item: FilterItem): Promise<FilterItem>;
47
+ listPageByAccountConfig(
48
+ accountConfigId: string,
49
+ options?: { limit?: number; continuationToken?: string },
50
+ ): Promise<{ items: FilterItem[]; continuationToken: string | undefined }>;
51
+ };
52
+ filterAnchor: {
53
+ put(input: {
54
+ accountConfigId: string;
55
+ filterId: string;
56
+ anchorMessageId: string;
57
+ anchorEmbedding: number[];
58
+ anchorEmbeddingId: string;
59
+ anchorSourceText: string;
60
+ }): Promise<unknown>;
61
+ };
62
+ buildAnchor(
63
+ accountConfigId: string,
64
+ anchorMessageId: string,
65
+ ): Promise<AnchorPayload | null>;
66
+ }
67
+
68
+ /**
69
+ * Epoch-seconds `ttl` derived from `expiresAt`, set only for a `Temporary`
70
+ * filter (RFC 034 Decision 1.3). A `Standing` filter never carries `ttl` — the
71
+ * reserved table-wide TTL attribute must stay absent, or the row would be swept
72
+ * (Decision 1.4).
73
+ */
74
+ export const deriveFilterTtl = (
75
+ scope: string,
76
+ expiresAt: string | undefined,
77
+ ): number | undefined => {
78
+ if (scope !== FilterScope.Temporary || !expiresAt) return undefined;
79
+ const ms = new Date(expiresAt).getTime();
80
+ if (Number.isNaN(ms)) {
81
+ throw new ClientError(`Invalid expiresAt: ${expiresAt}`);
82
+ }
83
+ return Math.floor(ms / 1000);
84
+ };
85
+
86
+ /**
87
+ * Reduce a PATCH body to the fields a filter update may set (RFC 034). Preserves
88
+ * absence: a key not present in the body is not present in the patch, so a
89
+ * name-only rename yields `{ name }` and never touches a predicate/action field
90
+ * — the service's `changesPredicateOrAction` guard then leaves `ruleChangedAt`
91
+ * untouched (Decision 3.2). Any field outside this set — most notably a
92
+ * server-derived `state`/`ruleChangedAt` smuggled into the body — is dropped.
93
+ */
94
+ export const pickFilterUpdate = (
95
+ body: Partial<UpdateFilterInput>,
96
+ ): Partial<UpdateFilterInput> => {
97
+ const patch: Partial<UpdateFilterInput> = {};
98
+ if (Object.hasOwn(body, "name")) patch.name = body.name;
99
+ if (Object.hasOwn(body, "matchOperator")) {
100
+ patch.matchOperator = body.matchOperator;
101
+ }
102
+ if (Object.hasOwn(body, "literalClauses")) {
103
+ patch.literalClauses = body.literalClauses;
104
+ }
105
+ if (Object.hasOwn(body, "actionLabelId")) {
106
+ patch.actionLabelId = body.actionLabelId;
107
+ }
108
+ if (Object.hasOwn(body, "actionMailboxId")) {
109
+ patch.actionMailboxId = body.actionMailboxId;
110
+ }
111
+ return patch;
112
+ };
113
+
114
+ const toFilterResponse = (item: FilterItem): FilterResponse => ({
115
+ filterId: item.filterId,
116
+ accountConfigId: item.accountConfigId,
117
+ name: item.name,
118
+ scope: item.scope,
119
+ expiresAt: item.expiresAt,
120
+ state: item.state,
121
+ hasAnchor: item.hasAnchor,
122
+ ruleChangedAt: item.ruleChangedAt,
123
+ matchOperator: item.matchOperator,
124
+ literalClauses: item.literalClauses,
125
+ actionLabelId: item.actionLabelId,
126
+ actionMailboxId: item.actionMailboxId,
127
+ createdAt: item.createdAt,
128
+ updatedAt: item.updatedAt,
129
+ });
130
+
131
+ /**
132
+ * Create a filter, wiring the semantic anchor when `anchorMessageId` is set
133
+ * (RFC 034 Decision 2). The anchor is built first, before the row is written,
134
+ * so `hasAnchor` is set correctly at creation and the sibling `FilterAnchor`
135
+ * row is written against the new `filterId` — never a second round-trip that
136
+ * would bump `ruleChangedAt`. A message with no indexed chunks yields no anchor:
137
+ * the filter is created as purely literal (`hasAnchor: false`) rather than with
138
+ * an empty anchor.
139
+ */
140
+ export const createFilterWithAnchor = async (
141
+ deps: FilterCrudDeps,
142
+ accountConfigId: string,
143
+ input: CreateFilterInput,
144
+ ): Promise<FilterItem> => {
145
+ const { anchorMessageId } = input;
146
+ const anchor = anchorMessageId
147
+ ? await deps.buildAnchor(accountConfigId, anchorMessageId)
148
+ : null;
149
+
150
+ const filter = await deps.filter.create({
151
+ accountConfigId,
152
+ name: input.name,
153
+ scope: input.scope,
154
+ expiresAt: input.expiresAt,
155
+ ttl: deriveFilterTtl(input.scope, input.expiresAt),
156
+ matchOperator: input.matchOperator,
157
+ literalClauses: input.literalClauses,
158
+ actionLabelId: input.actionLabelId,
159
+ actionMailboxId: input.actionMailboxId,
160
+ hasAnchor: anchor !== null,
161
+ });
162
+
163
+ if (anchor && anchorMessageId) {
164
+ await deps.filterAnchor.put({
165
+ accountConfigId,
166
+ filterId: filter.filterId,
167
+ anchorMessageId,
168
+ anchorEmbedding: anchor.anchorEmbedding,
169
+ anchorEmbeddingId: anchor.anchorEmbeddingId,
170
+ anchorSourceText: anchor.anchorSourceText,
171
+ });
172
+ }
173
+
174
+ return filter;
175
+ };
176
+
177
+ export const FilterOperations: Record<
178
+ FilterOperationIds,
179
+ OperationHandler<FilterOperationIds>
180
+ > = {
181
+ FilterOperations_listFilters: async (context, ...args: unknown[]) => {
182
+ const event = args[0] as APIGatewayProxyEvent;
183
+ const accountConfigId = getAccountConfigIdFromEvent(event);
184
+ const { accountId } = context.request.params as { accountId: string };
185
+ const { continuationToken } = context.request.query as {
186
+ continuationToken?: string;
187
+ };
188
+
189
+ const client = await getClient();
190
+ const account = await client.account.get(accountId);
191
+ assertAccountOwnership(account, accountConfigId, "read");
192
+
193
+ const { filter } = client;
194
+ const page = await filter.listPageByAccountConfig(accountConfigId, {
195
+ continuationToken,
196
+ });
197
+
198
+ // Reading the list is one of the touch points that lazily expires a past
199
+ // Temporary filter (RFC 034 Decision 1.2) — a no-op write for Standing or
200
+ // not-yet-expired filters.
201
+ const items = await Promise.all(
202
+ page.items.map((item) => filter.refreshExpiry(item)),
203
+ );
204
+
205
+ return {
206
+ items: items.map(toFilterResponse),
207
+ continuationToken: page.continuationToken,
208
+ };
209
+ },
210
+
211
+ FilterOperations_createFilter: async (context, ...args: unknown[]) => {
212
+ const event = args[0] as APIGatewayProxyEvent;
213
+ const accountConfigId = getAccountConfigIdFromEvent(event);
214
+ const { accountId } = context.request.params as { accountId: string };
215
+ const input = context.request.requestBody as CreateFilterInput;
216
+
217
+ const client = await getClient();
218
+ const account = await client.account.get(accountId);
219
+ assertAccountOwnership(account, accountConfigId, "act");
220
+
221
+ const filter = await createFilterWithAnchor(
222
+ {
223
+ filter: client.filter,
224
+ filterAnchor: client.filterAnchor,
225
+ buildAnchor: buildFilterAnchor,
226
+ },
227
+ accountConfigId,
228
+ input,
229
+ );
230
+ return toFilterResponse(filter);
231
+ },
232
+ };
233
+
234
+ export const FilterDetailOperations: Record<
235
+ FilterDetailOperationIds,
236
+ OperationHandler<FilterDetailOperationIds>
237
+ > = {
238
+ FilterDetailOperations_getFilter: async (context, ...args: unknown[]) => {
239
+ const event = args[0] as APIGatewayProxyEvent;
240
+ const accountConfigId = getAccountConfigIdFromEvent(event);
241
+ const { accountId, filterId } = context.request.params as {
242
+ accountId: string;
243
+ filterId: string;
244
+ };
245
+
246
+ const client = await getClient();
247
+ const account = await client.account.get(accountId);
248
+ assertAccountOwnership(account, accountConfigId, "read");
249
+
250
+ const { filter } = client;
251
+ const item = await filter.get(accountConfigId, filterId);
252
+ const refreshed = await filter.refreshExpiry(item);
253
+ return toFilterResponse(refreshed);
254
+ },
255
+
256
+ FilterDetailOperations_updateFilter: async (context, ...args: unknown[]) => {
257
+ const event = args[0] as APIGatewayProxyEvent;
258
+ const accountConfigId = getAccountConfigIdFromEvent(event);
259
+ const { accountId, filterId } = context.request.params as {
260
+ accountId: string;
261
+ filterId: string;
262
+ };
263
+ const body = context.request.requestBody as Partial<UpdateFilterInput>;
264
+
265
+ const client = await getClient();
266
+ const account = await client.account.get(accountId);
267
+ assertAccountOwnership(account, accountConfigId, "act");
268
+
269
+ const { filter } = client;
270
+ const updated = await filter.update(
271
+ accountConfigId,
272
+ filterId,
273
+ pickFilterUpdate(body),
274
+ );
275
+ return toFilterResponse(updated);
276
+ },
277
+
278
+ FilterDetailOperations_deleteFilter: async (context, ...args: unknown[]) => {
279
+ const event = args[0] as APIGatewayProxyEvent;
280
+ const accountConfigId = getAccountConfigIdFromEvent(event);
281
+ const { accountId, filterId } = context.request.params as {
282
+ accountId: string;
283
+ filterId: string;
284
+ };
285
+
286
+ const client = await getClient();
287
+ const account = await client.account.get(accountId);
288
+ assertAccountOwnership(account, accountConfigId, "act");
289
+
290
+ const { filter } = client;
291
+ await filter.delete(accountConfigId, filterId);
292
+ return { statusCode: 204 };
293
+ },
294
+ };
@@ -0,0 +1,250 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AccountSettingItem } from "@remit/data-ports";
4
+ import { CanonicalMailboxRole, MailboxSpecialUse } from "@remit/domain-enums";
5
+ import {
6
+ CANONICAL_ROLES,
7
+ type FolderCandidate,
8
+ findFolderForRole,
9
+ groupFolderAppointmentsByAccount,
10
+ loadFolderAppointmentsForAccount,
11
+ resolveFolderAppointments,
12
+ writeFolderRoleAppointment,
13
+ } from "./folder-role-appointments.js";
14
+
15
+ const setting = (name: string, value: string): AccountSettingItem =>
16
+ ({
17
+ accountSettingId: `s-${name}`,
18
+ accountConfigId: "cfg-1",
19
+ name,
20
+ value: { kind: "String", value },
21
+ createdAt: 0,
22
+ updatedAt: 0,
23
+ }) as AccountSettingItem;
24
+
25
+ describe("CANONICAL_ROLES", () => {
26
+ it("carries every RFC 032 anchor role, Custom excluded", () => {
27
+ assert.deepEqual(
28
+ [...CANONICAL_ROLES].sort(),
29
+ [
30
+ CanonicalMailboxRole.Inbox,
31
+ CanonicalMailboxRole.Drafts,
32
+ CanonicalMailboxRole.Sent,
33
+ CanonicalMailboxRole.Archive,
34
+ CanonicalMailboxRole.Junk,
35
+ CanonicalMailboxRole.Trash,
36
+ CanonicalMailboxRole.All,
37
+ CanonicalMailboxRole.Flagged,
38
+ ].sort(),
39
+ );
40
+ });
41
+ });
42
+
43
+ describe("findFolderForRole", () => {
44
+ const folders: FolderCandidate[] = [
45
+ { mailboxId: "mb-inbox", fullPath: "INBOX" },
46
+ {
47
+ mailboxId: "mb-drafts-empty",
48
+ fullPath: "INBOX/Drafts",
49
+ specialUse: [MailboxSpecialUse.Drafts],
50
+ },
51
+ { mailboxId: "mb-concepten", fullPath: "INBOX/Concepten" },
52
+ { mailboxId: "mb-sent", fullPath: "INBOX/Sent" },
53
+ { mailboxId: "mb-sent-messages", fullPath: "INBOX/Sent Messages" },
54
+ { mailboxId: "mb-news", fullPath: "INBOX/Nieuwsbrieven" },
55
+ ];
56
+
57
+ it("matches the reserved INBOX name for Inbox", () => {
58
+ assert.equal(
59
+ findFolderForRole(CanonicalMailboxRole.Inbox, folders),
60
+ "mb-inbox",
61
+ );
62
+ });
63
+
64
+ it("prefers the SPECIAL-USE flag over a name hint", () => {
65
+ assert.equal(
66
+ findFolderForRole(CanonicalMailboxRole.Drafts, folders),
67
+ "mb-drafts-empty",
68
+ );
69
+ });
70
+
71
+ it("falls back to a weak name hint when no flag is present", () => {
72
+ assert.equal(
73
+ findFolderForRole(CanonicalMailboxRole.Sent, folders),
74
+ "mb-sent",
75
+ );
76
+ });
77
+
78
+ it("returns null when nothing matches", () => {
79
+ assert.equal(findFolderForRole(CanonicalMailboxRole.Junk, folders), null);
80
+ });
81
+
82
+ it("never matches a plain user folder", () => {
83
+ assert.equal(
84
+ findFolderForRole(CanonicalMailboxRole.Archive, folders),
85
+ null,
86
+ );
87
+ });
88
+ });
89
+
90
+ describe("resolveFolderAppointments", () => {
91
+ const folders: FolderCandidate[] = [
92
+ { mailboxId: "mb-inbox", fullPath: "INBOX" },
93
+ { mailboxId: "mb-concepten", fullPath: "INBOX/Concepten" },
94
+ {
95
+ mailboxId: "mb-spam",
96
+ fullPath: "INBOX/Spam",
97
+ specialUse: [MailboxSpecialUse.Junk],
98
+ },
99
+ ];
100
+
101
+ it("carries one entry per canonical role, even when unfilled", () => {
102
+ const result = resolveFolderAppointments(new Map(), folders);
103
+ assert.deepEqual(
104
+ result.map((r) => r.role).sort(),
105
+ [...CANONICAL_ROLES].sort(),
106
+ );
107
+ });
108
+
109
+ it("prefers the persisted appointment over a fresh proposal", () => {
110
+ const persisted = new Map([[CanonicalMailboxRole.Drafts, "mb-concepten"]]);
111
+ const result = resolveFolderAppointments(persisted, folders);
112
+ const drafts = result.find((r) => r.role === CanonicalMailboxRole.Drafts);
113
+ assert.equal(drafts?.mailboxId, "mb-concepten");
114
+ });
115
+
116
+ it("re-proposes when the persisted mailbox no longer exists", () => {
117
+ const persisted = new Map([
118
+ [CanonicalMailboxRole.Junk, "mb-deleted-long-ago"],
119
+ ]);
120
+ const result = resolveFolderAppointments(persisted, folders);
121
+ const junk = result.find((r) => r.role === CanonicalMailboxRole.Junk);
122
+ assert.equal(junk?.mailboxId, "mb-spam");
123
+ });
124
+
125
+ it("leaves a role unfilled when nothing persisted or proposed matches", () => {
126
+ const result = resolveFolderAppointments(new Map(), folders);
127
+ const archive = result.find((r) => r.role === CanonicalMailboxRole.Archive);
128
+ assert.equal(archive?.mailboxId, undefined);
129
+ });
130
+ });
131
+
132
+ describe("groupFolderAppointmentsByAccount", () => {
133
+ it("groups by accountId then role from the composite setting name", () => {
134
+ const settings = [
135
+ setting(
136
+ `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Drafts}`,
137
+ "mb-1",
138
+ ),
139
+ setting(
140
+ `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Sent}`,
141
+ "mb-2",
142
+ ),
143
+ setting(
144
+ `FolderRoleAppointment#acc-2#${CanonicalMailboxRole.Inbox}`,
145
+ "mb-3",
146
+ ),
147
+ setting("AccountDisplayName#acc-1", "Alice"),
148
+ ];
149
+ const grouped = groupFolderAppointmentsByAccount(settings);
150
+ assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
151
+ [CanonicalMailboxRole.Drafts]: "mb-1",
152
+ [CanonicalMailboxRole.Sent]: "mb-2",
153
+ });
154
+ assert.deepEqual(Object.fromEntries(grouped.get("acc-2") ?? []), {
155
+ [CanonicalMailboxRole.Inbox]: "mb-3",
156
+ });
157
+ });
158
+
159
+ it("ignores a leftover MailboxRole#<mailboxId> row instead of throwing", () => {
160
+ // The #963/#964 backfill wrote `MailboxRole#<mailboxId>` rows that are no
161
+ // longer written but still persist in production. `FolderRoleAppointment`
162
+ // superseded `MailboxRole` (RFC 032 exclusive-folder-appointment, #976);
163
+ // a leftover row alongside real appointments must not make GET /config
164
+ // throw — it is simply not a folder-role appointment and gets skipped.
165
+ const settings = [
166
+ setting(
167
+ `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Drafts}`,
168
+ "mb-1",
169
+ ),
170
+ setting("MailboxRole#mb-legacy", "custom"),
171
+ ];
172
+ const grouped = groupFolderAppointmentsByAccount(settings);
173
+ assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
174
+ [CanonicalMailboxRole.Drafts]: "mb-1",
175
+ });
176
+ });
177
+ });
178
+
179
+ describe("loadFolderAppointmentsForAccount", () => {
180
+ it("reads each role's row and collects only the ones that exist", async () => {
181
+ const stored = new Map<string, string>([
182
+ [`FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Sent}`, "mb-sent"],
183
+ ]);
184
+ const accountSetting = {
185
+ get: async (_accountConfigId: string, name: string) => {
186
+ const value = stored.get(name);
187
+ return value ? setting(name, value) : null;
188
+ },
189
+ };
190
+ const roles = await loadFolderAppointmentsForAccount(
191
+ accountSetting,
192
+ "cfg-1",
193
+ "acc-1",
194
+ );
195
+ assert.deepEqual(Object.fromEntries(roles), {
196
+ [CanonicalMailboxRole.Sent]: "mb-sent",
197
+ });
198
+ });
199
+ });
200
+
201
+ describe("writeFolderRoleAppointment", () => {
202
+ it("upserts a String-valued row for a value", async () => {
203
+ const calls: unknown[] = [];
204
+ const accountSetting = {
205
+ upsert: async (input: unknown) => {
206
+ calls.push(input);
207
+ return input as never;
208
+ },
209
+ delete: async () => {
210
+ throw new Error("should not delete");
211
+ },
212
+ };
213
+ await writeFolderRoleAppointment(
214
+ accountSetting,
215
+ "cfg-1",
216
+ "acc-1",
217
+ CanonicalMailboxRole.Archive,
218
+ "mb-archive",
219
+ );
220
+ assert.deepEqual(calls, [
221
+ {
222
+ accountConfigId: "cfg-1",
223
+ name: `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Archive}`,
224
+ value: { kind: "String", value: "mb-archive" },
225
+ },
226
+ ]);
227
+ });
228
+
229
+ it("deletes the row when mailboxId is null (clear)", async () => {
230
+ const calls: unknown[][] = [];
231
+ const accountSetting = {
232
+ upsert: async () => {
233
+ throw new Error("should not upsert");
234
+ },
235
+ delete: async (accountConfigId: string, name: string) => {
236
+ calls.push([accountConfigId, name]);
237
+ },
238
+ };
239
+ await writeFolderRoleAppointment(
240
+ accountSetting,
241
+ "cfg-1",
242
+ "acc-1",
243
+ CanonicalMailboxRole.Archive,
244
+ null,
245
+ );
246
+ assert.deepEqual(calls, [
247
+ ["cfg-1", `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Archive}`],
248
+ ]);
249
+ });
250
+ });