@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.
- package/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- 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
|
+
});
|