@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,411 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MailboxResponse,
|
|
3
|
+
RenameMailboxInput,
|
|
4
|
+
} from "@remit/api-openapi-types";
|
|
5
|
+
import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
6
|
+
import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
|
+
import { MessageSystemFlag } from "@remit/domain-enums";
|
|
8
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
9
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
10
|
+
import {
|
|
11
|
+
applyPendingMoveCountPrediction,
|
|
12
|
+
type PendingUnseenFlagPush,
|
|
13
|
+
} from "../derive/pendingMoveCounts.js";
|
|
14
|
+
import { getClient } from "../service/dynamodb.js";
|
|
15
|
+
import type {
|
|
16
|
+
MailboxDetailOperationIds,
|
|
17
|
+
MailboxOperationIds,
|
|
18
|
+
OperationHandler,
|
|
19
|
+
TrashOperationIds,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import {
|
|
22
|
+
applyMailboxOverrideChanges,
|
|
23
|
+
loadMailboxOverrides,
|
|
24
|
+
loadMailboxOverridesForConfig,
|
|
25
|
+
type MailboxOverrides,
|
|
26
|
+
} from "./account-overrides.js";
|
|
27
|
+
import { assertAccountOwnership } from "./account-ownership.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The mute flag and the display-name override are user preferences that live
|
|
31
|
+
* in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
|
|
32
|
+
* Pick only those keys from a PATCH body: each follows the same `null` → remove,
|
|
33
|
+
* value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
|
|
34
|
+
* The canonical role a folder fills is appointed separately — see
|
|
35
|
+
* FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
|
|
36
|
+
*/
|
|
37
|
+
export const pickMailboxOverrideChanges = (
|
|
38
|
+
body: RenameMailboxInput,
|
|
39
|
+
): {
|
|
40
|
+
displayNameOverride?: string | null;
|
|
41
|
+
muted?: RenameMailboxInput["muted"];
|
|
42
|
+
} => {
|
|
43
|
+
const changes: {
|
|
44
|
+
displayNameOverride?: string | null;
|
|
45
|
+
muted?: RenameMailboxInput["muted"];
|
|
46
|
+
} = {};
|
|
47
|
+
if (Object.hasOwn(body, "displayNameOverride")) {
|
|
48
|
+
changes.displayNameOverride = body.displayNameOverride;
|
|
49
|
+
}
|
|
50
|
+
if (Object.hasOwn(body, "muted")) {
|
|
51
|
+
changes.muted = body.muted;
|
|
52
|
+
}
|
|
53
|
+
return changes;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Minimal client surface needed to apply a mailbox PATCH. Structurally
|
|
58
|
+
* satisfied by RemitClient; narrowed so tests can stub it.
|
|
59
|
+
*/
|
|
60
|
+
export interface MailboxPatchClient {
|
|
61
|
+
mailbox: {
|
|
62
|
+
get(accountId: string, mailboxId: string): Promise<MailboxItem>;
|
|
63
|
+
};
|
|
64
|
+
mailboxQueue: {
|
|
65
|
+
renameMailbox(
|
|
66
|
+
mailboxId: string,
|
|
67
|
+
newPath: string,
|
|
68
|
+
accountId: string,
|
|
69
|
+
): Promise<MailboxItem>;
|
|
70
|
+
};
|
|
71
|
+
accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Apply a mailbox PATCH body: override changes first (mute flag + display-name/
|
|
76
|
+
* role overrides — written to per-mailbox AccountSetting rows, no IMAP
|
|
77
|
+
* machinery), then rename — which triggers the IMAP rename machinery
|
|
78
|
+
* (syncStatus/oldPath + MAILBOX_RENAME event) — only when `fullPath` is
|
|
79
|
+
* present. An override-only PATCH therefore never calls
|
|
80
|
+
* `mailboxQueue.renameMailbox`.
|
|
81
|
+
*/
|
|
82
|
+
export const applyMailboxPatch = async (
|
|
83
|
+
client: MailboxPatchClient,
|
|
84
|
+
accountConfigId: string,
|
|
85
|
+
mailboxId: string,
|
|
86
|
+
accountId: string,
|
|
87
|
+
body: RenameMailboxInput,
|
|
88
|
+
): Promise<MailboxItem> => {
|
|
89
|
+
const { fullPath } = body;
|
|
90
|
+
|
|
91
|
+
// --- Override settings (mute flag + display-name/role overrides) ---
|
|
92
|
+
// Written to per-mailbox AccountSetting rows (RFC 032) with the same
|
|
93
|
+
// null→remove semantics as UpdateAddressFlagsInput. Applied before (and
|
|
94
|
+
// independent of) any rename so an override-only PATCH never touches
|
|
95
|
+
// syncStatus/oldPath.
|
|
96
|
+
const changes = pickMailboxOverrideChanges(body);
|
|
97
|
+
if (Object.keys(changes).length > 0) {
|
|
98
|
+
await applyMailboxOverrideChanges(
|
|
99
|
+
client.accountSetting,
|
|
100
|
+
accountConfigId,
|
|
101
|
+
mailboxId,
|
|
102
|
+
changes,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Rename (IMAP machinery) ---
|
|
107
|
+
// Only triggered when fullPath is present.
|
|
108
|
+
if (!fullPath) {
|
|
109
|
+
return client.mailbox.get(accountId, mailboxId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Cross-account guard for a mailbox reached by id under an `accountId` path.
|
|
117
|
+
* `assertAccountOwnership` only proves the caller owns the account in the path;
|
|
118
|
+
* the `mailboxId` in that same path can belong to a different account, so without
|
|
119
|
+
* this check a caller could read or mutate another account's mailbox. Mirrors the
|
|
120
|
+
* read/act split of `assertAccountOwnership`: 404 on a read (no existence leak),
|
|
121
|
+
* 403 on an action.
|
|
122
|
+
*/
|
|
123
|
+
export const assertMailboxInAccount = (
|
|
124
|
+
mailbox: Pick<MailboxItem, "mailboxId" | "accountId">,
|
|
125
|
+
accountId: string,
|
|
126
|
+
mode: "read" | "act",
|
|
127
|
+
): void => {
|
|
128
|
+
if (mailbox.accountId === accountId) return;
|
|
129
|
+
if (mode === "read") {
|
|
130
|
+
throw new NotFoundError(`Mailbox not found: ${mailbox.mailboxId}`);
|
|
131
|
+
}
|
|
132
|
+
throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every pending placement move (issue #1271) for an account, on whichever
|
|
137
|
+
* backend is active (`RemitClient.placementMove` is present on both — see
|
|
138
|
+
* `create-remit-client.ts`). Read-only; feeds
|
|
139
|
+
* `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
|
|
140
|
+
* stored counts (epic #1281 invariant 4). Markers are written by the
|
|
141
|
+
* imap-worker bulk sync path through `RemitClient.placementMove` on every
|
|
142
|
+
* backend, so this is a real signal on Postgres too.
|
|
143
|
+
*/
|
|
144
|
+
const loadPendingMoves = (
|
|
145
|
+
client: Awaited<ReturnType<typeof getClient>>,
|
|
146
|
+
accountId: string,
|
|
147
|
+
) => client.placementMove.listByAccountId(accountId);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Every pending `\Seen` flag-push marker (issue #1273) for an account —
|
|
151
|
+
* `\Flagged` (star) markers are excluded, since only read/unread state feeds
|
|
152
|
+
* `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
|
|
153
|
+
* on both backends, so this is a real signal on Postgres too.
|
|
154
|
+
*/
|
|
155
|
+
const loadPendingUnseenFlagPushes = async (
|
|
156
|
+
client: Awaited<ReturnType<typeof getClient>>,
|
|
157
|
+
accountId: string,
|
|
158
|
+
): Promise<PendingUnseenFlagPush[]> => {
|
|
159
|
+
const pushes = await client.flagPush.listByAccountId(accountId);
|
|
160
|
+
return pushes
|
|
161
|
+
.filter((push) => push.flagName === MessageSystemFlag.Seen)
|
|
162
|
+
.map((push) => ({ mailboxId: push.mailboxId, operation: push.operation }));
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const toMailboxResponse = (
|
|
166
|
+
mailbox: MailboxItem,
|
|
167
|
+
overrides: MailboxOverrides = {},
|
|
168
|
+
): MailboxResponse => ({
|
|
169
|
+
mailboxId: mailbox.mailboxId,
|
|
170
|
+
accountId: mailbox.accountId,
|
|
171
|
+
namespaceType: mailbox.namespaceType,
|
|
172
|
+
namespacePrefix: mailbox.namespacePrefix,
|
|
173
|
+
hierarchyDelimiter: mailbox.hierarchyDelimiter,
|
|
174
|
+
fullPath: mailbox.fullPath,
|
|
175
|
+
messageCount: mailbox.messageCount,
|
|
176
|
+
unseenCount: mailbox.unseenCount,
|
|
177
|
+
deletedCount: mailbox.deletedCount,
|
|
178
|
+
specialUse: mailbox.specialUse ? Array.from(mailbox.specialUse) : undefined,
|
|
179
|
+
lastSyncUid: mailbox.lastSyncUid,
|
|
180
|
+
highWaterMarkUid: mailbox.highWaterMarkUid,
|
|
181
|
+
lastMessageSyncAt: mailbox.lastMessageSyncAt,
|
|
182
|
+
muted: overrides.muted,
|
|
183
|
+
displayNameOverride: overrides.displayNameOverride,
|
|
184
|
+
createdAt: mailbox.createdAt,
|
|
185
|
+
updatedAt: mailbox.updatedAt,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
export const MailboxOperations: Record<
|
|
189
|
+
MailboxOperationIds,
|
|
190
|
+
OperationHandler<MailboxOperationIds>
|
|
191
|
+
> = {
|
|
192
|
+
MailboxOperations_listMailboxes: async (context, ...args: unknown[]) => {
|
|
193
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
194
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
195
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
196
|
+
const { continuationToken } = context.request.query as {
|
|
197
|
+
continuationToken?: string;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const client = await getClient();
|
|
201
|
+
const account = await client.account.get(accountId);
|
|
202
|
+
assertAccountOwnership(account, accountConfigId, "read");
|
|
203
|
+
|
|
204
|
+
const result = await client.mailbox.listByAccount(accountId, {
|
|
205
|
+
continuationToken,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Overrides (mute / display-name / role) live in per-mailbox AccountSetting
|
|
209
|
+
// rows (RFC 032). Load the whole config's set in one query and key it by
|
|
210
|
+
// mailboxId so each mailbox surfaces its overrides without an N+1.
|
|
211
|
+
const overridesByMailbox = await loadMailboxOverridesForConfig(
|
|
212
|
+
client.accountSetting,
|
|
213
|
+
accountConfigId,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Read-time prediction for any pending placement move (issue #1271) and
|
|
217
|
+
// pending \Seen flag push (issue #1273) — adjusts messageCount /
|
|
218
|
+
// unseenCount only, never the stored row (epic #1281 invariant 4).
|
|
219
|
+
const pendingMoves = await loadPendingMoves(client, accountId);
|
|
220
|
+
const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(
|
|
221
|
+
client,
|
|
222
|
+
accountId,
|
|
223
|
+
);
|
|
224
|
+
const items = applyPendingMoveCountPrediction(
|
|
225
|
+
result.items,
|
|
226
|
+
pendingMoves,
|
|
227
|
+
pendingUnseenFlagPushes,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
items: items.map((mailbox) =>
|
|
232
|
+
toMailboxResponse(
|
|
233
|
+
mailbox,
|
|
234
|
+
overridesByMailbox.get(mailbox.mailboxId) ?? {},
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
continuationToken: result.continuationToken,
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
MailboxOperations_createMailbox: async (context, ...args: unknown[]) => {
|
|
242
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
243
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
244
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
245
|
+
const { namespaceType, fullPath } = context.request.requestBody as {
|
|
246
|
+
namespaceType: string;
|
|
247
|
+
fullPath: string;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const client = await getClient();
|
|
251
|
+
const account = await client.account.get(accountId);
|
|
252
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
253
|
+
|
|
254
|
+
const mailbox = await client.mailboxQueue.createMailbox(
|
|
255
|
+
{
|
|
256
|
+
accountId,
|
|
257
|
+
namespaceType: namespaceType as "personal" | "other_users" | "shared",
|
|
258
|
+
namespacePrefix: "",
|
|
259
|
+
hierarchyDelimiter: "/",
|
|
260
|
+
fullPath,
|
|
261
|
+
uidValidity: 0,
|
|
262
|
+
uidNext: 1,
|
|
263
|
+
highestModseq: 0,
|
|
264
|
+
messageCount: 0,
|
|
265
|
+
unseenCount: 0,
|
|
266
|
+
deletedCount: 0,
|
|
267
|
+
totalSize: 0,
|
|
268
|
+
lastSyncUid: 0,
|
|
269
|
+
highWaterMarkUid: 0,
|
|
270
|
+
lastMessageSyncAt: 0,
|
|
271
|
+
},
|
|
272
|
+
accountId,
|
|
273
|
+
true,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
return toMailboxResponse(mailbox);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const MailboxDetailOperations: Record<
|
|
281
|
+
MailboxDetailOperationIds,
|
|
282
|
+
OperationHandler<MailboxDetailOperationIds>
|
|
283
|
+
> = {
|
|
284
|
+
MailboxDetailOperations_getMailbox: async (context, ...args: unknown[]) => {
|
|
285
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
286
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
287
|
+
const { accountId, mailboxId } = context.request.params as {
|
|
288
|
+
accountId: string;
|
|
289
|
+
mailboxId: string;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const client = await getClient();
|
|
293
|
+
const account = await client.account.get(accountId);
|
|
294
|
+
assertAccountOwnership(account, accountConfigId, "read");
|
|
295
|
+
|
|
296
|
+
const mailbox = await client.mailbox.get(accountId, mailboxId);
|
|
297
|
+
assertMailboxInAccount(mailbox, accountId, "read");
|
|
298
|
+
const overrides = await loadMailboxOverrides(
|
|
299
|
+
client.accountSetting,
|
|
300
|
+
accountConfigId,
|
|
301
|
+
mailboxId,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// Read-time prediction for any pending placement move (issue #1271) and
|
|
305
|
+
// pending \Seen flag push (issue #1273) — adjusts messageCount /
|
|
306
|
+
// unseenCount only, never the stored row (epic #1281 invariant 4).
|
|
307
|
+
const pendingMoves = await loadPendingMoves(client, accountId);
|
|
308
|
+
const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(
|
|
309
|
+
client,
|
|
310
|
+
accountId,
|
|
311
|
+
);
|
|
312
|
+
const adjusted =
|
|
313
|
+
applyPendingMoveCountPrediction(
|
|
314
|
+
[mailbox],
|
|
315
|
+
pendingMoves,
|
|
316
|
+
pendingUnseenFlagPushes,
|
|
317
|
+
)[0] ?? mailbox;
|
|
318
|
+
|
|
319
|
+
return toMailboxResponse(adjusted, overrides);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
MailboxDetailOperations_renameMailbox: async (
|
|
323
|
+
context,
|
|
324
|
+
...args: unknown[]
|
|
325
|
+
) => {
|
|
326
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
327
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
328
|
+
const { accountId, mailboxId } = context.request.params as {
|
|
329
|
+
accountId: string;
|
|
330
|
+
mailboxId: string;
|
|
331
|
+
};
|
|
332
|
+
const body = context.request.requestBody as RenameMailboxInput;
|
|
333
|
+
|
|
334
|
+
const client = await getClient();
|
|
335
|
+
const account = await client.account.get(accountId);
|
|
336
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
337
|
+
|
|
338
|
+
const existing = await client.mailbox.get(accountId, mailboxId);
|
|
339
|
+
assertMailboxInAccount(existing, accountId, "act");
|
|
340
|
+
|
|
341
|
+
const mailbox = await applyMailboxPatch(
|
|
342
|
+
client,
|
|
343
|
+
accountConfigId,
|
|
344
|
+
mailboxId,
|
|
345
|
+
accountId,
|
|
346
|
+
body,
|
|
347
|
+
);
|
|
348
|
+
const overrides = await loadMailboxOverrides(
|
|
349
|
+
client.accountSetting,
|
|
350
|
+
accountConfigId,
|
|
351
|
+
mailboxId,
|
|
352
|
+
);
|
|
353
|
+
return toMailboxResponse(mailbox, overrides);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
MailboxDetailOperations_deleteMailbox: async (
|
|
357
|
+
context,
|
|
358
|
+
...args: unknown[]
|
|
359
|
+
) => {
|
|
360
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
361
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
362
|
+
const { accountId, mailboxId } = context.request.params as {
|
|
363
|
+
accountId: string;
|
|
364
|
+
mailboxId: string;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const client = await getClient();
|
|
368
|
+
const account = await client.account.get(accountId);
|
|
369
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
370
|
+
|
|
371
|
+
const mailbox = await client.mailbox.get(accountId, mailboxId);
|
|
372
|
+
assertMailboxInAccount(mailbox, accountId, "act");
|
|
373
|
+
|
|
374
|
+
await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
|
|
375
|
+
return { statusCode: 204 };
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
export const TrashOperations: Record<
|
|
380
|
+
TrashOperationIds,
|
|
381
|
+
OperationHandler<TrashOperationIds>
|
|
382
|
+
> = {
|
|
383
|
+
TrashOperations_emptyTrash: async (context, ...args: unknown[]) => {
|
|
384
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
385
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
386
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
387
|
+
|
|
388
|
+
const client = await getClient();
|
|
389
|
+
|
|
390
|
+
const account = await client.account.get(accountId);
|
|
391
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
392
|
+
|
|
393
|
+
const trashMailbox =
|
|
394
|
+
await client.mailboxSpecialUse.findTrashMailbox(accountId);
|
|
395
|
+
|
|
396
|
+
if (!trashMailbox) {
|
|
397
|
+
return { deletedCount: 0 };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Get count of messages in trash before emptying
|
|
401
|
+
const messages = await client.message.listAllByMailbox(
|
|
402
|
+
trashMailbox.mailboxId,
|
|
403
|
+
);
|
|
404
|
+
const deletedCount = messages.length;
|
|
405
|
+
|
|
406
|
+
// MessageMoveService handles: Message status updates + SQS event
|
|
407
|
+
await client.messageMove.emptyTrash(accountConfigId, accountId);
|
|
408
|
+
|
|
409
|
+
return { deletedCount };
|
|
410
|
+
},
|
|
411
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
2
|
+
import type {
|
|
3
|
+
AccountExportRequestResponse,
|
|
4
|
+
CreateExportResponse,
|
|
5
|
+
DeleteAccountConfigResponse,
|
|
6
|
+
DeleteMeInput,
|
|
7
|
+
VipSuggestionsResponse,
|
|
8
|
+
} from "@remit/api-openapi-types";
|
|
9
|
+
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
10
|
+
import { logger } from "@remit/logger-lambda";
|
|
11
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
12
|
+
import { env } from "expect-env";
|
|
13
|
+
import type { Context } from "openapi-backend";
|
|
14
|
+
import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
|
|
15
|
+
import { getClient } from "../service/dynamodb.js";
|
|
16
|
+
import { sqsClient } from "../service/sqs.js";
|
|
17
|
+
import type { MeOperationIds, OperationHandler } from "../types.js";
|
|
18
|
+
import { toVipSuggestionEntry } from "./vip-suggestions.js";
|
|
19
|
+
|
|
20
|
+
const DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
|
|
21
|
+
|
|
22
|
+
export const MeOperations: Record<
|
|
23
|
+
MeOperationIds,
|
|
24
|
+
OperationHandler<MeOperationIds>
|
|
25
|
+
> = {
|
|
26
|
+
MeOperations_listVipSuggestions: async (
|
|
27
|
+
_context: Context,
|
|
28
|
+
...args: unknown[]
|
|
29
|
+
): Promise<VipSuggestionsResponse> => {
|
|
30
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
31
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
32
|
+
|
|
33
|
+
const { address } = await getClient();
|
|
34
|
+
const items = await address.listSuggestedVips({ accountConfigId });
|
|
35
|
+
|
|
36
|
+
return { suggestions: items.map(toVipSuggestionEntry) };
|
|
37
|
+
},
|
|
38
|
+
MeOperations_deleteMe: async (
|
|
39
|
+
_context: Context,
|
|
40
|
+
...args: unknown[]
|
|
41
|
+
): Promise<DeleteAccountConfigResponse> => {
|
|
42
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
43
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
44
|
+
|
|
45
|
+
const { confirmEmail } = JSON.parse(
|
|
46
|
+
event.body ?? "{}",
|
|
47
|
+
) as Partial<DeleteMeInput>;
|
|
48
|
+
const callerEmail = event.requestContext?.authorizer?.claims?.email as
|
|
49
|
+
| string
|
|
50
|
+
| undefined;
|
|
51
|
+
|
|
52
|
+
if (typeof confirmEmail !== "string" || confirmEmail.trim() === "") {
|
|
53
|
+
throw new BadRequestError("confirmEmail is required");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!callerEmail) {
|
|
57
|
+
throw new BadRequestError("Account email claim missing from token");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (confirmEmail.toLowerCase() !== callerEmail.toLowerCase()) {
|
|
61
|
+
throw new BadRequestError(
|
|
62
|
+
"confirmEmail does not match your account email",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { accountConfig } = await getClient();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await accountConfig.update(accountConfigId, {
|
|
70
|
+
state: "deleting",
|
|
71
|
+
deletedAt: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
// If already in "deleting" state, treat as idempotent
|
|
75
|
+
if (
|
|
76
|
+
err instanceof Error &&
|
|
77
|
+
(err.name === "ConditionalCheckFailedException" ||
|
|
78
|
+
err.message.includes("ConditionalCheckFailed"))
|
|
79
|
+
) {
|
|
80
|
+
return {
|
|
81
|
+
statusCode: 202,
|
|
82
|
+
message: "Account deletion already in progress",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const queueUrl = env.SQS_QUEUE_URL_ACCOUNT_FANOUT;
|
|
89
|
+
|
|
90
|
+
await sqsClient.send(
|
|
91
|
+
new SendMessageCommand({
|
|
92
|
+
QueueUrl: queueUrl,
|
|
93
|
+
MessageBody: JSON.stringify({
|
|
94
|
+
type: "AccountDelete",
|
|
95
|
+
accountConfigId,
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// biome-ignore lint/plugin/no-logger-info: account deletion is an audit-grade signal
|
|
101
|
+
logger.info({ accountConfigId }, "Account deletion initiated");
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
statusCode: 202,
|
|
105
|
+
message: "Account deletion initiated",
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
MeOperations_createExport: async (
|
|
109
|
+
_context: Context,
|
|
110
|
+
...args: unknown[]
|
|
111
|
+
): Promise<CreateExportResponse> => {
|
|
112
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
113
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
114
|
+
const userId = getSubFromEvent(event);
|
|
115
|
+
|
|
116
|
+
if (!userId) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"Missing Cognito `sub`: cannot attribute an export request to a user",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { accountExportRequest } = await getClient();
|
|
123
|
+
const exportRequest = await accountExportRequest.create({
|
|
124
|
+
accountConfigId,
|
|
125
|
+
userId,
|
|
126
|
+
state: "Pending",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await sqsClient.send(
|
|
130
|
+
new SendMessageCommand({
|
|
131
|
+
QueueUrl: env.SQS_QUEUE_URL_ACCOUNT_FANOUT,
|
|
132
|
+
MessageBody: JSON.stringify({
|
|
133
|
+
type: "AccountExport",
|
|
134
|
+
accountConfigId,
|
|
135
|
+
accountExportRequestId: exportRequest.accountExportRequestId,
|
|
136
|
+
}),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// biome-ignore lint/plugin/no-logger-info: account export is an audit-grade signal
|
|
141
|
+
logger.info(
|
|
142
|
+
{
|
|
143
|
+
accountConfigId,
|
|
144
|
+
accountExportRequestId: exportRequest.accountExportRequestId,
|
|
145
|
+
},
|
|
146
|
+
"Account export initiated",
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
statusCode: 202,
|
|
151
|
+
exportId: exportRequest.accountExportRequestId,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
MeOperations_getExport: async (
|
|
155
|
+
context: Context,
|
|
156
|
+
...args: unknown[]
|
|
157
|
+
): Promise<AccountExportRequestResponse> => {
|
|
158
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
159
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
160
|
+
const exportId = context.request.params.exportId as string;
|
|
161
|
+
|
|
162
|
+
const { accountExportRequest, storage } = await getClient();
|
|
163
|
+
const exportRequest = await accountExportRequest.get(exportId);
|
|
164
|
+
|
|
165
|
+
if (exportRequest.accountConfigId !== accountConfigId) {
|
|
166
|
+
throw new NotFoundError(`Export not found: ${exportId}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const downloadUrl =
|
|
170
|
+
exportRequest.state === "Ready" && exportRequest.objectKey
|
|
171
|
+
? await storage.getPresignedDownloadUrl(
|
|
172
|
+
exportRequest.objectKey,
|
|
173
|
+
DOWNLOAD_URL_TTL_SECONDS,
|
|
174
|
+
)
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
accountExportRequestId: exportRequest.accountExportRequestId,
|
|
179
|
+
accountConfigId: exportRequest.accountConfigId,
|
|
180
|
+
userId: exportRequest.userId,
|
|
181
|
+
state: exportRequest.state,
|
|
182
|
+
createdAt: exportRequest.createdAt,
|
|
183
|
+
updatedAt: exportRequest.updatedAt,
|
|
184
|
+
objectKey: exportRequest.objectKey,
|
|
185
|
+
downloadUrl,
|
|
186
|
+
expiresAt: exportRequest.expiresAt,
|
|
187
|
+
errorMessage: exportRequest.errorMessage,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
};
|