@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,272 @@
1
+ import type {
2
+ CanonicalMailboxRole as CanonicalMailboxRoleValue,
3
+ FolderAppointment,
4
+ } from "@remit/api-openapi-types";
5
+ import type {
6
+ AccountSettingItem,
7
+ IAccountSettingRepository,
8
+ IMailboxRepository,
9
+ } from "@remit/data-ports";
10
+ import {
11
+ baseSettingName,
12
+ composeSettingName,
13
+ SETTING_NAME_SEPARATOR,
14
+ } from "@remit/data-ports/account-settings";
15
+ import {
16
+ AccountSettingName,
17
+ CanonicalMailboxRole,
18
+ MailboxSpecialUse,
19
+ } from "@remit/domain-enums";
20
+
21
+ /**
22
+ * RFC 032 exclusive-folder-appointment (#976): a per-account role→mailbox map.
23
+ * Each row is a `FolderRoleAppointment#<accountId>#<role>` AccountSetting (RFC
24
+ * 032 settings tiers), so a role can never be persisted twice for one account —
25
+ * writing it replaces whichever mailbox previously held it. This module owns
26
+ * that persistence plus the read-side proposal (`findFolderForRole`) that fills
27
+ * any role the user hasn't appointed yet.
28
+ */
29
+
30
+ const FOLDER_ROLE_APPOINTMENT = AccountSettingName.FolderRoleAppointment;
31
+
32
+ /** The fixed anchor set, in the RFC's canonical display order. */
33
+ export const CANONICAL_ROLES: readonly CanonicalMailboxRoleValue[] =
34
+ Object.values(CanonicalMailboxRole);
35
+
36
+ const composeAppointmentName = (
37
+ accountId: string,
38
+ role: CanonicalMailboxRoleValue,
39
+ ): string =>
40
+ composeSettingName(
41
+ FOLDER_ROLE_APPOINTMENT,
42
+ `${accountId}${SETTING_NAME_SEPARATOR}${role}`,
43
+ );
44
+
45
+ /**
46
+ * Split a stored `FolderRoleAppointment#<accountId>#<role>` name back into its
47
+ * two-part target. Unlike the single-target composites (`MailboxRole#<id>`),
48
+ * this setting composes two ids after the base, so it parses the suffix itself
49
+ * rather than reusing `targetIdOf`.
50
+ */
51
+ const parseAppointmentTarget = (
52
+ name: string,
53
+ ): { accountId: string; role: string } | undefined => {
54
+ if (baseSettingName(name) !== FOLDER_ROLE_APPOINTMENT) return undefined;
55
+ const idx = name.indexOf(SETTING_NAME_SEPARATOR);
56
+ if (idx === -1) return undefined;
57
+ const rest = name.slice(idx + SETTING_NAME_SEPARATOR.length);
58
+ const roleIdx = rest.lastIndexOf(SETTING_NAME_SEPARATOR);
59
+ if (roleIdx === -1) return undefined;
60
+ const accountId = rest.slice(0, roleIdx);
61
+ const role = rest.slice(roleIdx + SETTING_NAME_SEPARATOR.length);
62
+ if (!accountId || !role) return undefined;
63
+ return { accountId, role };
64
+ };
65
+
66
+ const stringValueOf = (item: AccountSettingItem): string | undefined => {
67
+ const { value } = item;
68
+ return value.kind === "String" ? value.value : undefined;
69
+ };
70
+
71
+ /**
72
+ * Group every persisted folder-role appointment in one config-wide settings
73
+ * read (e.g. GET /config's already-loaded `listByAccountConfig` result) by
74
+ * accountId, then by role. Mirrors `groupAccountOverrides` / `groupMailboxOverrides`.
75
+ */
76
+ export const groupFolderAppointmentsByAccount = (
77
+ settings: AccountSettingItem[],
78
+ ): Map<string, Map<string, string>> => {
79
+ const byAccount = new Map<string, Map<string, string>>();
80
+ for (const setting of settings) {
81
+ const target = parseAppointmentTarget(setting.name);
82
+ if (!target) continue;
83
+ const mailboxId = stringValueOf(setting);
84
+ if (mailboxId === undefined) continue;
85
+ const roles = byAccount.get(target.accountId) ?? new Map<string, string>();
86
+ roles.set(target.role, mailboxId);
87
+ byAccount.set(target.accountId, roles);
88
+ }
89
+ return byAccount;
90
+ };
91
+
92
+ /**
93
+ * Load one account's persisted appointments by reading each role's row
94
+ * directly (mirrors `loadMailboxOverrides`'s three-get pattern). Used by the
95
+ * create/update account handlers, which only ever need a single account.
96
+ */
97
+ export const loadFolderAppointmentsForAccount = async (
98
+ accountSetting: Pick<IAccountSettingRepository, "get">,
99
+ accountConfigId: string,
100
+ accountId: string,
101
+ ): Promise<Map<string, string>> => {
102
+ const entries = await Promise.all(
103
+ CANONICAL_ROLES.map(async (role) => {
104
+ const item = await accountSetting.get(
105
+ accountConfigId,
106
+ composeAppointmentName(accountId, role),
107
+ );
108
+ return [role, item ? stringValueOf(item) : undefined] as const;
109
+ }),
110
+ );
111
+ const roles = new Map<string, string>();
112
+ for (const [role, mailboxId] of entries) {
113
+ if (mailboxId !== undefined) roles.set(role, mailboxId);
114
+ }
115
+ return roles;
116
+ };
117
+
118
+ /**
119
+ * Persist (or clear) one role's appointment. `mailboxId: null` deletes the row
120
+ * — the role goes back to unfilled (RFC 032 settings tiers: absence is unset).
121
+ * A value upserts it, replacing whatever mailbox the role pointed at before;
122
+ * there is no second row a duplicate could live in.
123
+ */
124
+ export const writeFolderRoleAppointment = (
125
+ accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">,
126
+ accountConfigId: string,
127
+ accountId: string,
128
+ role: CanonicalMailboxRoleValue,
129
+ mailboxId: string | null,
130
+ ): Promise<unknown> => {
131
+ const name = composeAppointmentName(accountId, role);
132
+ if (mailboxId === null) {
133
+ return accountSetting.delete(accountConfigId, name);
134
+ }
135
+ return accountSetting.upsert({
136
+ accountConfigId,
137
+ name,
138
+ value: { kind: "String", value: mailboxId },
139
+ });
140
+ };
141
+
142
+ /** The minimal folder shape `findFolderForRole` needs to detect a role. */
143
+ export interface FolderCandidate {
144
+ mailboxId: string;
145
+ fullPath: string;
146
+ specialUse?: readonly string[];
147
+ }
148
+
149
+ // RFC 6154 SPECIAL-USE flag per role. Inbox has no SPECIAL-USE flag (RFC 3501
150
+ // reserves the name itself); a role with no entry here is matched by name hint
151
+ // only.
152
+ const ROLE_TO_SPECIAL_USE: Partial<Record<CanonicalMailboxRoleValue, string>> =
153
+ {
154
+ [CanonicalMailboxRole.Drafts]: MailboxSpecialUse.Drafts,
155
+ [CanonicalMailboxRole.Sent]: MailboxSpecialUse.Sent,
156
+ [CanonicalMailboxRole.Archive]: MailboxSpecialUse.Archive,
157
+ [CanonicalMailboxRole.Junk]: MailboxSpecialUse.Junk,
158
+ [CanonicalMailboxRole.Trash]: MailboxSpecialUse.Trash,
159
+ [CanonicalMailboxRole.All]: MailboxSpecialUse.All,
160
+ [CanonicalMailboxRole.Flagged]: MailboxSpecialUse.Flagged,
161
+ };
162
+
163
+ // Weak name hints (RFC 032's tier 3 of `findFolderForRole`): used solely to
164
+ // seed a PROPOSAL a human confirms, never to persist a role by itself. Kept
165
+ // intentionally small — this is a fallback for providers with no SPECIAL-USE
166
+ // support, not a substitute for server truth.
167
+ const ROLE_NAME_HINTS: Partial<
168
+ Record<CanonicalMailboxRoleValue, readonly string[]>
169
+ > = {
170
+ [CanonicalMailboxRole.Drafts]: ["drafts", "draft", "concepten"],
171
+ [CanonicalMailboxRole.Sent]: [
172
+ "sent",
173
+ "sent mail",
174
+ "sent items",
175
+ "sent messages",
176
+ ],
177
+ [CanonicalMailboxRole.Archive]: ["archive", "archives"],
178
+ [CanonicalMailboxRole.Junk]: ["junk", "spam"],
179
+ [CanonicalMailboxRole.Trash]: [
180
+ "trash",
181
+ "bin",
182
+ "deleted",
183
+ "deleted items",
184
+ "deleted messages",
185
+ ],
186
+ [CanonicalMailboxRole.All]: ["all mail", "all"],
187
+ };
188
+
189
+ const leafName = (fullPath: string): string => {
190
+ const parts = fullPath.split("/");
191
+ return (parts[parts.length - 1] || fullPath).toLowerCase();
192
+ };
193
+
194
+ /**
195
+ * The single best EXISTING folder for a canonical role (RFC 032
196
+ * exclusive-folder-appointment): the IMAP SPECIAL-USE flag first (server
197
+ * truth, language-independent), then — for Inbox only — the reserved `INBOX`
198
+ * name (RFC 3501), then a weak name hint used solely to seed a proposal a
199
+ * human confirms. `null` when nothing matches; the role stays unfilled.
200
+ */
201
+ export const findFolderForRole = (
202
+ role: CanonicalMailboxRoleValue,
203
+ folders: readonly FolderCandidate[],
204
+ ): string | null => {
205
+ const specialUse = ROLE_TO_SPECIAL_USE[role];
206
+ if (specialUse) {
207
+ const flagged = folders.find((f) => f.specialUse?.includes(specialUse));
208
+ if (flagged) return flagged.mailboxId;
209
+ }
210
+
211
+ if (role === CanonicalMailboxRole.Inbox) {
212
+ const inbox = folders.find((f) => f.fullPath.toUpperCase() === "INBOX");
213
+ if (inbox) return inbox.mailboxId;
214
+ }
215
+
216
+ const hints = ROLE_NAME_HINTS[role];
217
+ if (hints) {
218
+ const match = folders.find((f) => hints.includes(leafName(f.fullPath)));
219
+ if (match) return match.mailboxId;
220
+ }
221
+
222
+ return null;
223
+ };
224
+
225
+ /**
226
+ * Resolve the full appointment set for one account: the user's persisted
227
+ * choice when set (and still a real mailbox — a deleted mailbox's stale
228
+ * appointment is treated as unfilled and re-proposed), else a server-proposed
229
+ * `findFolderForRole` guess. Always returns one entry per `CANONICAL_ROLES`
230
+ * member (RFC 032 settings tiers: total, never a sparse array), so the map is
231
+ * never empty for a normal provider.
232
+ */
233
+ export const resolveFolderAppointments = (
234
+ persisted: ReadonlyMap<string, string>,
235
+ mailboxes: readonly FolderCandidate[],
236
+ ): FolderAppointment[] =>
237
+ CANONICAL_ROLES.map((role) => {
238
+ const persistedId = persisted.get(role);
239
+ const validPersisted =
240
+ persistedId && mailboxes.some((m) => m.mailboxId === persistedId)
241
+ ? persistedId
242
+ : undefined;
243
+ const mailboxId =
244
+ validPersisted ?? findFolderForRole(role, mailboxes) ?? undefined;
245
+ return mailboxId ? { role, mailboxId } : { role };
246
+ });
247
+
248
+ /**
249
+ * End-to-end resolution for one account: load its persisted appointments and
250
+ * its mailboxes, then resolve. The single entry point every handler that
251
+ * builds an AccountResponse calls (create/update account, GET /config's
252
+ * per-account fan-out mirrors this with a pre-loaded settings batch instead —
253
+ * see `groupFolderAppointmentsByAccount` — to avoid an N+1 settings read).
254
+ */
255
+ export const resolveAccountFolderAppointments = async (
256
+ client: {
257
+ mailbox: Pick<IMailboxRepository, "listAllByAccount">;
258
+ accountSetting: Pick<IAccountSettingRepository, "get">;
259
+ },
260
+ accountConfigId: string,
261
+ accountId: string,
262
+ ): Promise<FolderAppointment[]> => {
263
+ const [persisted, mailboxes] = await Promise.all([
264
+ loadFolderAppointmentsForAccount(
265
+ client.accountSetting,
266
+ accountConfigId,
267
+ accountId,
268
+ ),
269
+ client.mailbox.listAllByAccount(accountId),
270
+ ]);
271
+ return resolveFolderAppointments(persisted, mailboxes);
272
+ };
@@ -0,0 +1,76 @@
1
+ import type {
2
+ AppointFolderRoleInput,
3
+ CanonicalMailboxRole,
4
+ } from "@remit/api-openapi-types";
5
+ import type { APIGatewayProxyEvent } from "aws-lambda";
6
+ import { getAccountConfigIdFromEvent } from "../auth.js";
7
+ import { getClient } from "../service/dynamodb.js";
8
+ import type { FolderRoleOperationIds, OperationHandler } from "../types.js";
9
+ import { toAccountResponse } from "./account-guards.js";
10
+ import { loadAccountOverrides } from "./account-overrides.js";
11
+ import { assertAccountOwnership } from "./account-ownership.js";
12
+ import { loadSignatureForAccount } from "./account-signature.js";
13
+ import {
14
+ resolveAccountFolderAppointments,
15
+ writeFolderRoleAppointment,
16
+ } from "./folder-role-appointments.js";
17
+ import { assertMailboxInAccount } from "./mailbox.js";
18
+
19
+ /**
20
+ * RFC 032 exclusive-folder-appointment (#976): the single write operation for
21
+ * the per-account role map. `appoint(role, mailboxId)` sets `map[role]` —
22
+ * there is no second place a duplicate appointment could live, so writing a
23
+ * role replaces whichever mailbox previously held it. `mailboxId: null` clears
24
+ * the role back to unfilled.
25
+ */
26
+ export const FolderRoleOperations: Record<
27
+ FolderRoleOperationIds,
28
+ OperationHandler<FolderRoleOperationIds>
29
+ > = {
30
+ FolderRoleOperations_appointFolderRole: async (
31
+ context,
32
+ ...args: unknown[]
33
+ ) => {
34
+ const event = args[0] as APIGatewayProxyEvent;
35
+ const accountConfigId = getAccountConfigIdFromEvent(event);
36
+ const { accountId, role } = context.request.params as {
37
+ accountId: string;
38
+ role: CanonicalMailboxRole;
39
+ };
40
+ const body = context.request.requestBody as AppointFolderRoleInput;
41
+
42
+ const { account, accountSetting, mailbox } = await getClient();
43
+ const existing = await account.get(accountId);
44
+ assertAccountOwnership(existing, accountConfigId, "act");
45
+
46
+ if (body.mailboxId) {
47
+ const target = await mailbox.get(accountId, body.mailboxId);
48
+ assertMailboxInAccount(target, accountId, "act");
49
+ }
50
+
51
+ await writeFolderRoleAppointment(
52
+ accountSetting,
53
+ accountConfigId,
54
+ accountId,
55
+ role,
56
+ body.mailboxId ?? null,
57
+ );
58
+
59
+ const [signature, overrides, folderAppointments] = await Promise.all([
60
+ loadSignatureForAccount(accountSetting, accountConfigId, accountId),
61
+ loadAccountOverrides(accountSetting, accountConfigId, accountId),
62
+ resolveAccountFolderAppointments(
63
+ { mailbox, accountSetting },
64
+ accountConfigId,
65
+ accountId,
66
+ ),
67
+ ]);
68
+
69
+ return toAccountResponse(
70
+ existing,
71
+ signature,
72
+ overrides,
73
+ folderAppointments,
74
+ );
75
+ },
76
+ };
@@ -0,0 +1,48 @@
1
+ import type { OperationHandler, OperationIds } from "../types.js";
2
+ import { AccountDetailOperations, AccountOperations } from "./account.js";
3
+ import { MicrosoftOAuthOperations } from "./account-oauth.js";
4
+ import { AddressDetailOperations, AddressOperations } from "./address.js";
5
+ import { ConfigOperations } from "./config.js";
6
+ import { FilterDetailOperations, FilterOperations } from "./filter.js";
7
+ import { FolderRoleOperations } from "./folder-role.js";
8
+ import {
9
+ MailboxDetailOperations,
10
+ MailboxOperations,
11
+ TrashOperations,
12
+ } from "./mailbox.js";
13
+ import { MeOperations } from "./me.js";
14
+ import { MessageBulkOperations, MessageOperations } from "./message.js";
15
+ import { OrganizeJobDetailOperations, OrganizeOperations } from "./organize.js";
16
+ import { OutboxDetailOperations, OutboxOperations } from "./outbox.js";
17
+ import { SemanticSearchOperations } from "./search.js";
18
+ import { SyncOperations } from "./sync.js";
19
+ import { ThreadDetailOperations, ThreadOperations } from "./thread.js";
20
+ import { UnifiedThreadOperations } from "./unified-threads.js";
21
+
22
+ // biome-ignore lint/suspicious/noExplicitAny: Types are narrowed downstream
23
+ export const handlers: Record<OperationIds, OperationHandler<any>> = {
24
+ ...MeOperations,
25
+ ...ConfigOperations,
26
+ ...AccountOperations,
27
+ ...AccountDetailOperations,
28
+ ...MicrosoftOAuthOperations,
29
+ ...MailboxOperations,
30
+ ...MailboxDetailOperations,
31
+ ...FolderRoleOperations,
32
+ ...FilterOperations,
33
+ ...FilterDetailOperations,
34
+ ...OrganizeOperations,
35
+ ...OrganizeJobDetailOperations,
36
+ ...TrashOperations,
37
+ ...SyncOperations,
38
+ ...ThreadDetailOperations,
39
+ ...UnifiedThreadOperations,
40
+ ...ThreadOperations,
41
+ ...MessageOperations,
42
+ ...MessageBulkOperations,
43
+ ...OutboxOperations,
44
+ ...OutboxDetailOperations,
45
+ ...AddressOperations,
46
+ ...AddressDetailOperations,
47
+ ...SemanticSearchOperations,
48
+ };