@remit/backend 0.0.143 → 0.0.145
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/dist/dev-server/content-handler.d.ts +38 -0
- package/dist/dev-server/content-handler.js +46 -0
- package/dist/src/api.d.ts +5 -0
- package/dist/src/api.js +73 -0
- package/dist/src/auth.d.ts +24 -0
- package/dist/src/auth.js +81 -0
- package/dist/src/config/api-config.d.ts +20 -0
- package/dist/src/config/api-config.js +1 -0
- package/dist/src/config/from-env.d.ts +2 -0
- package/dist/src/config/from-env.js +65 -0
- package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
- package/dist/src/config/msoauth-secrets-manager.js +28 -0
- package/dist/src/config/msoauth.d.ts +19 -0
- package/dist/src/config/msoauth.js +6 -0
- package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
- package/dist/src/derive/autoMoved.js +49 -0
- package/dist/src/derive/contentSignature.d.ts +40 -0
- package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
- package/dist/src/derive/contentUrl.d.ts +33 -0
- package/dist/src/derive/contentUrl.js +27 -0
- package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
- package/dist/src/derive/deriveMuted.js +7 -0
- package/dist/src/derive/enrichThreadRows.d.ts +118 -0
- package/dist/src/derive/enrichThreadRows.js +151 -0
- package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
- package/dist/src/derive/filterThreadCriteria.js +24 -0
- package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
- package/dist/src/derive/pendingMoveCounts.js +53 -0
- package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
- package/dist/src/derive/senderTrust.js +19 -0
- package/dist/src/error.d.ts +21 -0
- package/dist/src/error.js +85 -0
- package/dist/src/handlers/account-guards.d.ts +36 -0
- package/dist/src/handlers/account-guards.js +107 -0
- package/dist/src/handlers/account-oauth.d.ts +15 -0
- package/dist/src/handlers/account-oauth.js +334 -0
- package/dist/src/handlers/account-overrides.d.ts +53 -0
- package/dist/src/handlers/account-overrides.js +233 -0
- package/dist/src/handlers/account-signature.d.ts +28 -0
- package/dist/src/handlers/account-signature.js +85 -0
- package/dist/src/handlers/account.d.ts +7 -0
- package/dist/src/handlers/account.js +337 -0
- package/dist/src/handlers/address.d.ts +35 -0
- package/dist/src/handlers/address.js +133 -0
- package/dist/src/handlers/calendar-event.d.ts +70 -0
- package/dist/src/handlers/calendar-event.js +417 -0
- package/dist/src/handlers/calendar-feed.d.ts +47 -0
- package/dist/src/handlers/calendar-feed.js +184 -0
- package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
- package/dist/src/handlers/calendar-suggestion.js +204 -0
- package/dist/src/handlers/calendar.d.ts +116 -0
- package/dist/src/handlers/calendar.js +323 -0
- package/dist/src/handlers/config.d.ts +43 -0
- package/dist/src/handlers/config.js +164 -0
- package/dist/src/handlers/ensure-account-config.d.ts +9 -0
- package/dist/src/handlers/ensure-account-config.js +27 -0
- package/dist/src/handlers/filter.d.ts +3 -0
- package/dist/src/handlers/filter.js +88 -0
- package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
- package/dist/src/handlers/folder-role-appointments.js +168 -0
- package/dist/src/handlers/folder-role-labels.d.ts +26 -0
- package/dist/src/handlers/folder-role-labels.js +40 -0
- package/dist/src/handlers/folder-role.d.ts +9 -0
- package/dist/src/handlers/folder-role.js +37 -0
- package/dist/src/handlers/index.d.ts +88 -0
- package/dist/src/handlers/index.js +62 -0
- package/dist/src/handlers/label.d.ts +43 -0
- package/dist/src/handlers/label.js +107 -0
- package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
- package/dist/src/handlers/mailbox-bindings.js +61 -0
- package/dist/src/handlers/mailbox.d.ts +23 -0
- package/dist/src/handlers/mailbox.js +211 -0
- package/dist/src/handlers/me.d.ts +2 -0
- package/dist/src/handlers/me.js +117 -0
- package/dist/src/handlers/message.d.ts +82 -0
- package/dist/src/handlers/message.js +595 -0
- package/dist/src/handlers/organize.d.ts +10 -0
- package/dist/src/handlers/organize.js +103 -0
- package/dist/src/handlers/outbox-attachment.d.ts +25 -0
- package/dist/src/handlers/outbox-attachment.js +77 -0
- package/dist/src/handlers/outbox.d.ts +4 -0
- package/dist/src/handlers/outbox.js +136 -0
- package/dist/src/handlers/sync-progress.d.ts +46 -0
- package/dist/src/handlers/sync-progress.js +63 -0
- package/dist/src/handlers/sync.d.ts +28 -0
- package/dist/src/handlers/sync.js +73 -0
- package/dist/src/handlers/system-update.d.ts +2 -0
- package/dist/src/handlers/system-update.js +159 -0
- package/dist/src/handlers/thread.d.ts +148 -0
- package/dist/src/handlers/thread.js +228 -0
- package/dist/src/handlers/unified-threads.d.ts +247 -0
- package/dist/src/handlers/unified-threads.js +406 -0
- package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
- package/dist/src/handlers/vip-suggestions.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +53 -0
- package/{src/json.ts → dist/src/json.d.ts} +1 -4
- package/dist/src/json.js +10 -0
- package/dist/src/jwt-auth.d.ts +15 -0
- package/dist/src/jwt-auth.js +45 -0
- package/dist/src/lambda.d.ts +13 -0
- package/dist/src/lambda.js +33 -0
- package/dist/src/request.d.ts +9 -0
- package/dist/src/request.js +17 -0
- package/dist/src/response.d.ts +44 -0
- package/dist/src/response.js +80 -0
- package/dist/src/serve.d.ts +31 -0
- package/dist/src/serve.js +117 -0
- package/dist/src/service/account-deletion.d.ts +20 -0
- package/dist/src/service/account-deletion.js +33 -0
- package/dist/src/service/compose-sqlite.d.ts +3 -0
- package/dist/src/service/compose-sqlite.js +13 -0
- package/dist/src/service/create-remit-client.d.ts +109 -0
- package/dist/src/service/create-remit-client.js +188 -0
- package/dist/src/service/data-client.d.ts +12 -0
- package/dist/src/service/data-client.js +42 -0
- package/dist/src/service/export-identity.d.ts +4 -0
- package/dist/src/service/export-identity.js +33 -0
- package/dist/src/service/fire-and-forget.d.ts +54 -0
- package/dist/src/service/fire-and-forget.js +37 -0
- package/dist/src/service/organize.d.ts +181 -0
- package/dist/src/service/organize.js +367 -0
- package/dist/src/service/queues.d.ts +12 -0
- package/dist/src/service/queues.js +1 -0
- package/dist/src/service/refusal.d.ts +4 -0
- package/dist/src/service/refusal.js +20 -0
- package/dist/src/service/sender-category.d.ts +51 -0
- package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
- package/dist/src/service/shared-deps.d.ts +6 -0
- package/dist/src/service/shared-deps.js +24 -0
- package/dist/src/service/trigger-sync.d.ts +57 -0
- package/dist/src/service/trigger-sync.js +31 -0
- package/dist/src/types.d.ts +72 -0
- package/dist/src/types.js +1 -0
- package/package.json +125 -98
- package/.env.test +0 -4
- package/dev-server/content-auth.test.ts +0 -113
- package/dev-server/content-auth.ts +0 -54
- package/dev-server/content-handler.test.ts +0 -105
- package/dev-server/content-handler.ts +0 -79
- package/dev-server/content-path.test.ts +0 -37
- package/dev-server/content-path.ts +0 -27
- package/dev-server/cors.test.ts +0 -48
- package/dev-server/cors.ts +0 -25
- package/dev-server/keep-alive.test.ts +0 -24
- package/dev-server/lambda-helpers.ts +0 -69
- package/dev-server/log-output.test.ts +0 -173
- package/dev-server/oauth-callback-route.test.ts +0 -66
- package/dev-server/relational-health.test.ts +0 -83
- package/dev-server/relational-health.ts +0 -39
- package/dev-server/server.ts +0 -384
- package/dev-server/sync-age.test.ts +0 -91
- package/dev-server/sync-age.ts +0 -55
- package/dev-server/upload-handler.ts +0 -132
- package/dev-server/upload-route.test.ts +0 -196
- package/scripts/backfill-classification.ts +0 -91
- package/scripts/backfill-list-id.ts +0 -83
- package/scripts/config-save.ts +0 -128
- package/src/auth.test.ts +0 -12
- package/src/auth.ts +0 -92
- package/src/config/msoauth.test.ts +0 -48
- package/src/config/msoauth.ts +0 -48
- package/src/data-backend.test.ts +0 -23
- package/src/data-backend.ts +0 -14
- package/src/derive/autoMoved.test.ts +0 -104
- package/src/derive/contentSignature.test.ts +0 -161
- package/src/derive/contentUrl.test.ts +0 -172
- package/src/derive/contentUrl.ts +0 -86
- package/src/derive/deriveMuted.test.ts +0 -34
- package/src/derive/enrichThreadRows.test.ts +0 -360
- package/src/derive/enrichThreadRows.ts +0 -234
- package/src/derive/filterThreadCriteria.test.ts +0 -126
- package/src/derive/filterThreadCriteria.ts +0 -51
- package/src/derive/senderTrust.test.ts +0 -67
- package/src/error.test.ts +0 -201
- package/src/error.ts +0 -123
- package/src/handlers/account-guards.test.ts +0 -67
- package/src/handlers/account-guards.ts +0 -169
- package/src/handlers/account-oauth-callback.test.ts +0 -155
- package/src/handlers/account-oauth-consent.test.ts +0 -336
- package/src/handlers/account-oauth-start.test.ts +0 -191
- package/src/handlers/account-oauth.test.ts +0 -439
- package/src/handlers/account-oauth.ts +0 -635
- package/src/handlers/account-overrides.test.ts +0 -88
- package/src/handlers/account-overrides.ts +0 -323
- package/src/handlers/account-ownership.ts +0 -25
- package/src/handlers/account-signature.ts +0 -117
- package/src/handlers/account-synced-services.test.ts +0 -245
- package/src/handlers/account.ts +0 -552
- package/src/handlers/address.test.ts +0 -381
- package/src/handlers/address.ts +0 -200
- package/src/handlers/calendar-event.test.ts +0 -685
- package/src/handlers/calendar-event.ts +0 -738
- package/src/handlers/calendar-feed-validators.test.ts +0 -228
- package/src/handlers/calendar-feed.test.ts +0 -439
- package/src/handlers/calendar-feed.ts +0 -291
- package/src/handlers/calendar-sqlite-fixture.ts +0 -36
- package/src/handlers/calendar-suggestion.test.ts +0 -1269
- package/src/handlers/calendar-suggestion.ts +0 -425
- package/src/handlers/calendar.test.ts +0 -1080
- package/src/handlers/calendar.ts +0 -517
- package/src/handlers/config.test.ts +0 -245
- package/src/handlers/config.ts +0 -332
- package/src/handlers/ensure-account-config.ts +0 -30
- package/src/handlers/filter.test.ts +0 -644
- package/src/handlers/filter.ts +0 -518
- package/src/handlers/folder-role-appointments.test.ts +0 -438
- package/src/handlers/folder-role-appointments.ts +0 -249
- package/src/handlers/folder-role-labels.ts +0 -63
- package/src/handlers/folder-role.test.ts +0 -209
- package/src/handlers/folder-role.ts +0 -80
- package/src/handlers/index.ts +0 -83
- package/src/handlers/label.test.ts +0 -112
- package/src/handlers/label.ts +0 -210
- package/src/handlers/mailbox-bindings.ts +0 -111
- package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
- package/src/handlers/mailbox-settled-target.test.ts +0 -251
- package/src/handlers/mailbox.test.ts +0 -280
- package/src/handlers/mailbox.ts +0 -581
- package/src/handlers/me.test.ts +0 -81
- package/src/handlers/me.ts +0 -203
- package/src/handlers/message-read-backfill.test.ts +0 -103
- package/src/handlers/message.test.ts +0 -131
- package/src/handlers/message.ts +0 -1131
- package/src/handlers/organize.test.ts +0 -277
- package/src/handlers/organize.ts +0 -197
- package/src/handlers/outbox-attachment.test.ts +0 -765
- package/src/handlers/outbox-attachment.ts +0 -126
- package/src/handlers/outbox-list.test.ts +0 -179
- package/src/handlers/outbox.test.ts +0 -772
- package/src/handlers/outbox.ts +0 -258
- package/src/handlers/search.test.ts +0 -161
- package/src/handlers/search.ts +0 -105
- package/src/handlers/sync-progress.test.ts +0 -205
- package/src/handlers/sync-progress.ts +0 -99
- package/src/handlers/sync.test.ts +0 -168
- package/src/handlers/sync.ts +0 -118
- package/src/handlers/system-update.test.ts +0 -497
- package/src/handlers/system-update.ts +0 -230
- package/src/handlers/thread.test.ts +0 -396
- package/src/handlers/thread.ts +0 -427
- package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
- package/src/handlers/unified-threads.test.ts +0 -785
- package/src/handlers/unified-threads.ts +0 -669
- package/src/index.test.ts +0 -83
- package/src/index.ts +0 -228
- package/src/jwt-auth.test.ts +0 -152
- package/src/jwt-auth.ts +0 -132
- package/src/log-redaction.test.ts +0 -144
- package/src/openapi-spec.test.ts +0 -118
- package/src/request-context.test.ts +0 -61
- package/src/request-context.ts +0 -33
- package/src/request-pipeline.test.ts +0 -88
- package/src/request.ts +0 -10
- package/src/response.test.ts +0 -130
- package/src/response.ts +0 -144
- package/src/service/compose-sqlite.ts +0 -98
- package/src/service/config-import.ts +0 -39
- package/src/service/create-remit-client.ts +0 -522
- package/src/service/data-client.test.ts +0 -97
- package/src/service/data-client.ts +0 -58
- package/src/service/export-identity.ts +0 -39
- package/src/service/filter.ts +0 -40
- package/src/service/fire-and-forget.test.ts +0 -126
- package/src/service/fire-and-forget.ts +0 -79
- package/src/service/localhost-env-config.test.ts +0 -36
- package/src/service/organize-queue.ts +0 -98
- package/src/service/organize.test.ts +0 -1451
- package/src/service/organize.ts +0 -1025
- package/src/service/read-path-filter-config.test.ts +0 -243
- package/src/service/semantic-capability.test.ts +0 -78
- package/src/service/semantic-capability.ts +0 -95
- package/src/service/sender-category.test.ts +0 -235
- package/src/service/sqs.ts +0 -4
- package/src/service/trigger-sync.test.ts +0 -205
- package/src/service/trigger-sync.ts +0 -100
- package/src/stdout-capture-fixture.ts +0 -40
- package/src/types.ts +0 -271
- package/tsconfig.json +0 -7
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ownedAccount } from "@remit/mailbox-service";
|
|
2
|
+
import { unwrap } from "../service/refusal.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every filter in the account whose action applies this label (issue #26). The
|
|
5
|
+
* count backs `LabelResponse.filterCount`; the list backs the cascade delete.
|
|
6
|
+
*/
|
|
7
|
+
export const findFiltersForLabel = async (deps, accountConfigId, labelId) => {
|
|
8
|
+
const filters = await deps.filter.listByAccountConfig(accountConfigId);
|
|
9
|
+
return filters.filter((filter) => filter.actionLabelId === labelId);
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* `filterCount` is computed here, not stored (issue #26) — every other field
|
|
13
|
+
* comes straight off the `LabelItem` row.
|
|
14
|
+
*/
|
|
15
|
+
const toLabelResponse = (item, filterCount) => ({
|
|
16
|
+
labelId: item.labelId,
|
|
17
|
+
accountConfigId: item.accountConfigId,
|
|
18
|
+
name: item.name,
|
|
19
|
+
color: item.color,
|
|
20
|
+
filterCount,
|
|
21
|
+
createdAt: item.createdAt,
|
|
22
|
+
updatedAt: item.updatedAt,
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Delete a label and cascade in both directions (issue #26): every filter
|
|
26
|
+
* whose `actionLabelId` is this label is deleted outright — never left
|
|
27
|
+
* dangling, never blocking the delete — and every `MessageLabel` row for it is
|
|
28
|
+
* removed. The label row itself is deleted last so a failure mid-cascade never
|
|
29
|
+
* leaves the label gone while its filters or applied-to-messages still exist.
|
|
30
|
+
*/
|
|
31
|
+
export const deleteLabelWithCascade = async (deps, accountConfigId, labelId) => {
|
|
32
|
+
const filters = await findFiltersForLabel(deps, accountConfigId, labelId);
|
|
33
|
+
for (const filter of filters) {
|
|
34
|
+
await deps.filter.delete(accountConfigId, filter.filterId);
|
|
35
|
+
}
|
|
36
|
+
await deps.messageLabel.removeAllByLabelId(accountConfigId, labelId);
|
|
37
|
+
await deps.label.delete(accountConfigId, labelId);
|
|
38
|
+
};
|
|
39
|
+
export const LabelOperations = {
|
|
40
|
+
LabelOperations_listLabels: async (context, ctx) => {
|
|
41
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
42
|
+
const { accountId } = context.request.params;
|
|
43
|
+
const { continuationToken } = context.request.query;
|
|
44
|
+
const client = ctx.client;
|
|
45
|
+
const account = await client.account.get(accountId);
|
|
46
|
+
unwrap(ownedAccount(account, accountConfigId, "read"));
|
|
47
|
+
const page = await client.label.listPageByAccountConfig(accountConfigId, {
|
|
48
|
+
continuationToken,
|
|
49
|
+
});
|
|
50
|
+
const filters = await client.filter.listByAccountConfig(accountConfigId);
|
|
51
|
+
const filterCountByLabelId = new Map();
|
|
52
|
+
for (const filter of filters) {
|
|
53
|
+
const labelId = filter.actionLabelId;
|
|
54
|
+
filterCountByLabelId.set(labelId, (filterCountByLabelId.get(labelId) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
items: page.items.map((item) => toLabelResponse(item, filterCountByLabelId.get(item.labelId) ?? 0)),
|
|
58
|
+
continuationToken: page.continuationToken,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
LabelOperations_createLabel: async (context, ctx) => {
|
|
62
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
63
|
+
const { accountId } = context.request.params;
|
|
64
|
+
const input = context.request.requestBody;
|
|
65
|
+
const client = ctx.client;
|
|
66
|
+
const account = await client.account.get(accountId);
|
|
67
|
+
unwrap(ownedAccount(account, accountConfigId, "act"));
|
|
68
|
+
const label = await client.label.create({
|
|
69
|
+
accountConfigId,
|
|
70
|
+
name: input.name,
|
|
71
|
+
color: input.color,
|
|
72
|
+
});
|
|
73
|
+
return toLabelResponse(label, 0);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export const LabelDetailOperations = {
|
|
77
|
+
LabelDetailOperations_getLabel: async (context, ctx) => {
|
|
78
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
79
|
+
const { accountId, labelId } = context.request.params;
|
|
80
|
+
const client = ctx.client;
|
|
81
|
+
const account = await client.account.get(accountId);
|
|
82
|
+
unwrap(ownedAccount(account, accountConfigId, "read"));
|
|
83
|
+
const label = await client.label.get(accountConfigId, labelId);
|
|
84
|
+
const filters = await findFiltersForLabel(client, accountConfigId, labelId);
|
|
85
|
+
return toLabelResponse(label, filters.length);
|
|
86
|
+
},
|
|
87
|
+
LabelDetailOperations_updateLabel: async (context, ctx) => {
|
|
88
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
89
|
+
const { accountId, labelId } = context.request.params;
|
|
90
|
+
const body = context.request.requestBody;
|
|
91
|
+
const client = ctx.client;
|
|
92
|
+
const account = await client.account.get(accountId);
|
|
93
|
+
unwrap(ownedAccount(account, accountConfigId, "act"));
|
|
94
|
+
const updated = await client.label.update(accountConfigId, labelId, body);
|
|
95
|
+
const filters = await findFiltersForLabel(client, accountConfigId, labelId);
|
|
96
|
+
return toLabelResponse(updated, filters.length);
|
|
97
|
+
},
|
|
98
|
+
LabelDetailOperations_deleteLabel: async (context, ctx) => {
|
|
99
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
100
|
+
const { accountId, labelId } = context.request.params;
|
|
101
|
+
const client = ctx.client;
|
|
102
|
+
const account = await client.account.get(accountId);
|
|
103
|
+
unwrap(ownedAccount(account, accountConfigId, "act"));
|
|
104
|
+
await deleteLabelWithCascade(client, accountConfigId, labelId);
|
|
105
|
+
return { statusCode: 204 };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IAccountSettingRepository, IFilterRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
|
|
3
|
+
/**
|
|
4
|
+
* What a folder is bound to: the filters that move mail into it, and the
|
|
5
|
+
* canonical roles it has been appointed to.
|
|
6
|
+
*/
|
|
7
|
+
export interface MailboxBindings {
|
|
8
|
+
filterNames: string[];
|
|
9
|
+
roles: CanonicalMailboxRoleValue[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `filter.actionMailboxId` has no reverse index, so the filters are listed for
|
|
13
|
+
* the account config and matched — they are few and per-config, so this is not
|
|
14
|
+
* a mailbox scan. Role appointments are `account_setting` rows whose *value* is
|
|
15
|
+
* a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
|
|
16
|
+
* per-account appointment map answers for them.
|
|
17
|
+
*/
|
|
18
|
+
export declare const loadMailboxBindings: (client: {
|
|
19
|
+
filter: Pick<IFilterRepository, "listByAccountConfig">;
|
|
20
|
+
accountSetting: Pick<IAccountSettingRepository, "get">;
|
|
21
|
+
}, accountConfigId: string, accountId: string, mailboxId: string) => Promise<MailboxBindings>;
|
|
22
|
+
/**
|
|
23
|
+
* Refuse a delete while any durable reference is bound to the folder (D16).
|
|
24
|
+
*
|
|
25
|
+
* This is what makes the settled-target rule total: a binding is only created
|
|
26
|
+
* against a settled folder and only removed by the user, so a `mailboxId` that
|
|
27
|
+
* names nothing never exists and no reader of a filter's `actionMailboxId` or a
|
|
28
|
+
* role appointment needs a missing-target branch. It removes code rather than
|
|
29
|
+
* adding a check.
|
|
30
|
+
*
|
|
31
|
+
* The alternatives both cost more than they look. `Filter` has no enabled flag
|
|
32
|
+
* and its `actionMailboxId` sentinel means "no move action", so clearing the
|
|
33
|
+
* target silently turns a move-filter into one that does something else;
|
|
34
|
+
* deleting the user's filters outright is not a decision a folder delete gets
|
|
35
|
+
* to make.
|
|
36
|
+
*
|
|
37
|
+
* The message **names** each binding rather than counting them, because a count
|
|
38
|
+
* is not actionable — and this fires on ordinary configurations, since pointing
|
|
39
|
+
* a filter at a folder is what filters are for.
|
|
40
|
+
*/
|
|
41
|
+
export declare const assertNoBindings: (mailbox: Pick<MailboxItem, "fullPath">, bindings: MailboxBindings) => void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FILTER_NO_ACTION } from "@remit/data-ports";
|
|
2
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
3
|
+
import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
|
|
4
|
+
/**
|
|
5
|
+
* `filter.actionMailboxId` has no reverse index, so the filters are listed for
|
|
6
|
+
* the account config and matched — they are few and per-config, so this is not
|
|
7
|
+
* a mailbox scan. Role appointments are `account_setting` rows whose *value* is
|
|
8
|
+
* a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
|
|
9
|
+
* per-account appointment map answers for them.
|
|
10
|
+
*/
|
|
11
|
+
export const loadMailboxBindings = async (client, accountConfigId, accountId, mailboxId) => {
|
|
12
|
+
const [filters, appointments] = await Promise.all([
|
|
13
|
+
client.filter.listByAccountConfig(accountConfigId),
|
|
14
|
+
loadFolderAppointmentsForAccount(client.accountSetting, accountConfigId, accountId),
|
|
15
|
+
]);
|
|
16
|
+
const filterNames = filters
|
|
17
|
+
.filter((filter) => filter.actionMailboxId !== FILTER_NO_ACTION &&
|
|
18
|
+
filter.actionMailboxId === mailboxId)
|
|
19
|
+
.map((filter) => filter.name);
|
|
20
|
+
const roles = [];
|
|
21
|
+
for (const [role, appointment] of appointments) {
|
|
22
|
+
if (appointment.mailboxId === mailboxId) {
|
|
23
|
+
roles.push(role);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { filterNames, roles };
|
|
27
|
+
};
|
|
28
|
+
const asList = (items) => items.length === 1
|
|
29
|
+
? items[0]
|
|
30
|
+
: `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
31
|
+
/**
|
|
32
|
+
* Refuse a delete while any durable reference is bound to the folder (D16).
|
|
33
|
+
*
|
|
34
|
+
* This is what makes the settled-target rule total: a binding is only created
|
|
35
|
+
* against a settled folder and only removed by the user, so a `mailboxId` that
|
|
36
|
+
* names nothing never exists and no reader of a filter's `actionMailboxId` or a
|
|
37
|
+
* role appointment needs a missing-target branch. It removes code rather than
|
|
38
|
+
* adding a check.
|
|
39
|
+
*
|
|
40
|
+
* The alternatives both cost more than they look. `Filter` has no enabled flag
|
|
41
|
+
* and its `actionMailboxId` sentinel means "no move action", so clearing the
|
|
42
|
+
* target silently turns a move-filter into one that does something else;
|
|
43
|
+
* deleting the user's filters outright is not a decision a folder delete gets
|
|
44
|
+
* to make.
|
|
45
|
+
*
|
|
46
|
+
* The message **names** each binding rather than counting them, because a count
|
|
47
|
+
* is not actionable — and this fires on ordinary configurations, since pointing
|
|
48
|
+
* a filter at a folder is what filters are for.
|
|
49
|
+
*/
|
|
50
|
+
export const assertNoBindings = (mailbox, bindings) => {
|
|
51
|
+
const clauses = [];
|
|
52
|
+
if (bindings.filterNames.length > 0) {
|
|
53
|
+
clauses.push(`the ${bindings.filterNames.length === 1 ? "filter" : "filters"} ${asList(bindings.filterNames.map((name) => `“${name}”`))}`);
|
|
54
|
+
}
|
|
55
|
+
if (bindings.roles.length > 0) {
|
|
56
|
+
clauses.push(`the ${asList(bindings.roles)} ${bindings.roles.length === 1 ? "role" : "roles"}`);
|
|
57
|
+
}
|
|
58
|
+
if (clauses.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
throw new BadRequestError(`“${mailbox.fullPath}” is used by ${asList(clauses)}.`);
|
|
61
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RenameMailboxInput } from "@remit/api-openapi-types";
|
|
2
|
+
import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
3
|
+
import { type FolderPathDeps } from "@remit/mailbox-service";
|
|
4
|
+
import type { MailboxDetailOperationIds, MailboxOperationIds, OperationHandler, TrashOperationIds } from "../types.js";
|
|
5
|
+
/**
|
|
6
|
+
* The mute flag and the display-name override are user preferences that live
|
|
7
|
+
* in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
|
|
8
|
+
* Pick only those keys from a PATCH body: each follows the same `null` → remove,
|
|
9
|
+
* value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
|
|
10
|
+
* The canonical role a folder fills is appointed separately — see
|
|
11
|
+
* FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
|
|
12
|
+
*/
|
|
13
|
+
export declare const pickMailboxOverrideChanges: (body: RenameMailboxInput) => {
|
|
14
|
+
displayNameOverride?: string | null;
|
|
15
|
+
muted?: RenameMailboxInput["muted"];
|
|
16
|
+
};
|
|
17
|
+
export type MailboxPatchClient = FolderPathDeps & {
|
|
18
|
+
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
|
|
19
|
+
};
|
|
20
|
+
export declare const applyMailboxPatch: (client: MailboxPatchClient, accountConfigId: string, mailboxId: string, accountId: string, body: RenameMailboxInput) => Promise<MailboxItem>;
|
|
21
|
+
export declare const MailboxOperations: Record<MailboxOperationIds, OperationHandler<MailboxOperationIds, "sync">>;
|
|
22
|
+
export declare const MailboxDetailOperations: Record<MailboxDetailOperationIds, OperationHandler<MailboxDetailOperationIds, "sync">>;
|
|
23
|
+
export declare const TrashOperations: Record<TrashOperationIds, OperationHandler<TrashOperationIds, "sync">>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
|
|
2
|
+
import { MessageSystemFlag } from "@remit/domain-enums";
|
|
3
|
+
import { changeFolderPath, createFolder, folderInAccount, folderInFlight, ownedAccount, ownedFolder, } from "@remit/mailbox-service";
|
|
4
|
+
import { applyPendingMoveCountPrediction, } from "../derive/pendingMoveCounts.js";
|
|
5
|
+
import { unwrap } from "../service/refusal.js";
|
|
6
|
+
import { applyMailboxOverrideChanges, loadMailboxOverrides, loadMailboxOverridesForConfig, } from "./account-overrides.js";
|
|
7
|
+
import { assertNoBindings, loadMailboxBindings } from "./mailbox-bindings.js";
|
|
8
|
+
/**
|
|
9
|
+
* The mute flag and the display-name override are user preferences that live
|
|
10
|
+
* in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
|
|
11
|
+
* Pick only those keys from a PATCH body: each follows the same `null` → remove,
|
|
12
|
+
* value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
|
|
13
|
+
* The canonical role a folder fills is appointed separately — see
|
|
14
|
+
* FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
|
|
15
|
+
*/
|
|
16
|
+
export const pickMailboxOverrideChanges = (body) => {
|
|
17
|
+
const changes = {};
|
|
18
|
+
if (Object.hasOwn(body, "displayNameOverride")) {
|
|
19
|
+
changes.displayNameOverride = body.displayNameOverride;
|
|
20
|
+
}
|
|
21
|
+
if (Object.hasOwn(body, "muted")) {
|
|
22
|
+
changes.muted = body.muted;
|
|
23
|
+
}
|
|
24
|
+
return changes;
|
|
25
|
+
};
|
|
26
|
+
// Overrides land first and independently, so an override-only PATCH never
|
|
27
|
+
// touches the folder's mutation state.
|
|
28
|
+
export const applyMailboxPatch = async (client, accountConfigId, mailboxId, accountId, body) => {
|
|
29
|
+
const { fullPath } = body;
|
|
30
|
+
const applyOverrides = async () => {
|
|
31
|
+
const changes = pickMailboxOverrideChanges(body);
|
|
32
|
+
if (Object.keys(changes).length === 0)
|
|
33
|
+
return;
|
|
34
|
+
await applyMailboxOverrideChanges(client.accountSetting, accountConfigId, mailboxId, changes);
|
|
35
|
+
};
|
|
36
|
+
if (!fullPath) {
|
|
37
|
+
const folder = unwrap(await ownedFolder(client, accountConfigId, accountId, mailboxId));
|
|
38
|
+
await applyOverrides();
|
|
39
|
+
return folder;
|
|
40
|
+
}
|
|
41
|
+
const { outcome, mailbox } = unwrap(await changeFolderPath(client, accountConfigId, {
|
|
42
|
+
accountId,
|
|
43
|
+
mailboxId,
|
|
44
|
+
fullPath,
|
|
45
|
+
}));
|
|
46
|
+
await applyOverrides();
|
|
47
|
+
if (outcome === "AlreadyRecorded") {
|
|
48
|
+
throw new ConflictError(folderInFlight(mailbox));
|
|
49
|
+
}
|
|
50
|
+
return mailbox;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Every pending placement move (issue #1271) for an account, on whichever
|
|
54
|
+
* backend is active (`RemitClient.placementMove` is present on both — see
|
|
55
|
+
* `create-remit-client.ts`). Read-only; feeds
|
|
56
|
+
* `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
|
|
57
|
+
* stored counts (epic #1281 invariant 4). Markers are written by the
|
|
58
|
+
* imap-worker bulk sync path through `RemitClient.placementMove` on every
|
|
59
|
+
* backend, so this is a real signal on the self-host stack too.
|
|
60
|
+
*/
|
|
61
|
+
const loadPendingMoves = (client, accountId) => client.placementMove.listByAccountId(accountId);
|
|
62
|
+
/**
|
|
63
|
+
* Every pending `\Seen` flag-push marker (issue #1273) for an account —
|
|
64
|
+
* `\Flagged` (star) markers are excluded, since only read/unread state feeds
|
|
65
|
+
* `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
|
|
66
|
+
* on both backends, so this is a real signal on the self-host stack too.
|
|
67
|
+
*/
|
|
68
|
+
const loadPendingUnseenFlagPushes = async (client, accountId) => {
|
|
69
|
+
const pushes = await client.flagPush.listByAccountId(accountId);
|
|
70
|
+
return pushes
|
|
71
|
+
.filter((push) => push.flagName === MessageSystemFlag.Seen)
|
|
72
|
+
.map((push) => ({ mailboxId: push.mailboxId, operation: push.operation }));
|
|
73
|
+
};
|
|
74
|
+
const toMailboxResponse = (mailbox, overrides = {}) => ({
|
|
75
|
+
mailboxId: mailbox.mailboxId,
|
|
76
|
+
accountId: mailbox.accountId,
|
|
77
|
+
namespaceType: mailbox.namespaceType,
|
|
78
|
+
namespacePrefix: mailbox.namespacePrefix,
|
|
79
|
+
hierarchyDelimiter: mailbox.hierarchyDelimiter,
|
|
80
|
+
fullPath: mailbox.fullPath,
|
|
81
|
+
messageCount: mailbox.messageCount,
|
|
82
|
+
unseenCount: mailbox.unseenCount,
|
|
83
|
+
deletedCount: mailbox.deletedCount,
|
|
84
|
+
specialUse: mailbox.specialUse ? Array.from(mailbox.specialUse) : undefined,
|
|
85
|
+
lastSyncUid: mailbox.lastSyncUid,
|
|
86
|
+
highWaterMarkUid: mailbox.highWaterMarkUid,
|
|
87
|
+
lastMessageSyncAt: mailbox.lastMessageSyncAt,
|
|
88
|
+
syncStatus: mailbox.syncStatus,
|
|
89
|
+
// The row is the only thing that knows what a rename is aiming at, and
|
|
90
|
+
// `fullPath` stays the confirmed one throughout (D2), so without this a
|
|
91
|
+
// client cannot tell a create in flight from a rename in flight, name the
|
|
92
|
+
// target of a failed rename, or offer the right retry.
|
|
93
|
+
pendingPath: mailbox.pendingPath,
|
|
94
|
+
syncFailureReason: mailbox.syncFailureReason,
|
|
95
|
+
muted: overrides.muted,
|
|
96
|
+
displayNameOverride: overrides.displayNameOverride,
|
|
97
|
+
createdAt: mailbox.createdAt,
|
|
98
|
+
updatedAt: mailbox.updatedAt,
|
|
99
|
+
});
|
|
100
|
+
export const MailboxOperations = {
|
|
101
|
+
MailboxOperations_listMailboxes: async (context, ctx) => {
|
|
102
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
103
|
+
const { accountId } = context.request.params;
|
|
104
|
+
const { continuationToken } = context.request.query;
|
|
105
|
+
const client = ctx.client;
|
|
106
|
+
const account = await client.account.get(accountId);
|
|
107
|
+
unwrap(ownedAccount(account, accountConfigId, "read"));
|
|
108
|
+
const result = await client.mailbox.listByAccount(accountId, {
|
|
109
|
+
continuationToken,
|
|
110
|
+
});
|
|
111
|
+
// Overrides (mute / display-name / role) live in per-mailbox AccountSetting
|
|
112
|
+
// rows (RFC 032). Load the whole config's set in one query and key it by
|
|
113
|
+
// mailboxId so each mailbox surfaces its overrides without an N+1.
|
|
114
|
+
const overridesByMailbox = await loadMailboxOverridesForConfig(client.accountSetting, accountConfigId);
|
|
115
|
+
// Read-time prediction for any pending placement move (issue #1271) and
|
|
116
|
+
// pending \Seen flag push (issue #1273) — adjusts messageCount /
|
|
117
|
+
// unseenCount only, never the stored row (epic #1281 invariant 4).
|
|
118
|
+
const pendingMoves = await loadPendingMoves(client, accountId);
|
|
119
|
+
const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(client, accountId);
|
|
120
|
+
// A folder mid-mutation is listed, labelled (D11). Hiding a `deleting` one
|
|
121
|
+
// made a delete that does not settle look like a folder that silently
|
|
122
|
+
// vanished, while the server still held it.
|
|
123
|
+
const items = applyPendingMoveCountPrediction(result.items, pendingMoves, pendingUnseenFlagPushes);
|
|
124
|
+
return {
|
|
125
|
+
items: items.map((mailbox) => toMailboxResponse(mailbox, overridesByMailbox.get(mailbox.mailboxId) ?? {})),
|
|
126
|
+
continuationToken: result.continuationToken,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
MailboxOperations_createMailbox: async (context, ctx) => {
|
|
130
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
131
|
+
const { accountId } = context.request.params;
|
|
132
|
+
const { namespaceType, fullPath } = context.request
|
|
133
|
+
.requestBody;
|
|
134
|
+
const created = await createFolder(ctx.client, accountConfigId, {
|
|
135
|
+
accountId,
|
|
136
|
+
namespaceType,
|
|
137
|
+
fullPath,
|
|
138
|
+
});
|
|
139
|
+
const { outcome, mailbox } = unwrap(created);
|
|
140
|
+
if (outcome === "Existing") {
|
|
141
|
+
throw new BadRequestError(`A folder named “${fullPath}” is already there.`);
|
|
142
|
+
}
|
|
143
|
+
return toMailboxResponse(mailbox);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
export const MailboxDetailOperations = {
|
|
147
|
+
MailboxDetailOperations_getMailbox: async (context, ctx) => {
|
|
148
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
149
|
+
const { accountId, mailboxId } = context.request.params;
|
|
150
|
+
const client = ctx.client;
|
|
151
|
+
const account = await client.account.get(accountId);
|
|
152
|
+
unwrap(ownedAccount(account, accountConfigId, "read"));
|
|
153
|
+
const mailbox = await client.mailbox.get(accountId, mailboxId);
|
|
154
|
+
unwrap(folderInAccount(mailbox, accountId, "read"));
|
|
155
|
+
const overrides = await loadMailboxOverrides(client.accountSetting, accountConfigId, mailboxId);
|
|
156
|
+
// Read-time prediction for any pending placement move (issue #1271) and
|
|
157
|
+
// pending \Seen flag push (issue #1273) — adjusts messageCount /
|
|
158
|
+
// unseenCount only, never the stored row (epic #1281 invariant 4).
|
|
159
|
+
const pendingMoves = await loadPendingMoves(client, accountId);
|
|
160
|
+
const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(client, accountId);
|
|
161
|
+
const adjusted = applyPendingMoveCountPrediction([mailbox], pendingMoves, pendingUnseenFlagPushes)[0] ?? mailbox;
|
|
162
|
+
return toMailboxResponse(adjusted, overrides);
|
|
163
|
+
},
|
|
164
|
+
MailboxDetailOperations_renameMailbox: async (context, ctx) => {
|
|
165
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
166
|
+
const { accountId, mailboxId } = context.request.params;
|
|
167
|
+
const body = context.request.requestBody;
|
|
168
|
+
const client = ctx.client;
|
|
169
|
+
const mailbox = await applyMailboxPatch(client, accountConfigId, mailboxId, accountId, body);
|
|
170
|
+
const overrides = await loadMailboxOverrides(client.accountSetting, accountConfigId, mailboxId);
|
|
171
|
+
return toMailboxResponse(mailbox, overrides);
|
|
172
|
+
},
|
|
173
|
+
MailboxDetailOperations_deleteMailbox: async (context, ctx) => {
|
|
174
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
175
|
+
const { accountId, mailboxId } = context.request.params;
|
|
176
|
+
const client = ctx.client;
|
|
177
|
+
const account = await client.account.get(accountId);
|
|
178
|
+
unwrap(ownedAccount(account, accountConfigId, "act"));
|
|
179
|
+
const mailbox = await client.mailbox.get(accountId, mailboxId);
|
|
180
|
+
unwrap(folderInAccount(mailbox, accountId, "act"));
|
|
181
|
+
// Cheapest first (D4, D13). None of the three becomes possible by
|
|
182
|
+
// retrying the same request, so all three are 400 and all three run
|
|
183
|
+
// before anything is recorded.
|
|
184
|
+
if (mailbox.fullPath.toUpperCase() === "INBOX") {
|
|
185
|
+
throw new BadRequestError("The inbox can't be deleted.");
|
|
186
|
+
}
|
|
187
|
+
const children = await client.mailbox.findByPathPrefix(accountId, mailbox.fullPath, mailbox.hierarchyDelimiter);
|
|
188
|
+
if (children.length > 0) {
|
|
189
|
+
// The server would keep the parent's name as a placeholder and leave the
|
|
190
|
+
// children untouched — an outcome no local row describes (D9).
|
|
191
|
+
throw new BadRequestError(`“${mailbox.fullPath}” has folders inside it. Delete those first.`);
|
|
192
|
+
}
|
|
193
|
+
assertNoBindings(mailbox, await loadMailboxBindings(client, accountConfigId, accountId, mailboxId));
|
|
194
|
+
await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
|
|
195
|
+
return { statusCode: 204 };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
export const TrashOperations = {
|
|
199
|
+
TrashOperations_emptyTrash: async (context, ctx) => {
|
|
200
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
201
|
+
const { accountId } = context.request.params;
|
|
202
|
+
const client = ctx.client;
|
|
203
|
+
const account = await client.account.get(accountId);
|
|
204
|
+
unwrap(ownedAccount(account, accountConfigId, "act"));
|
|
205
|
+
// The service resolves Trash, marks the rows and counts them, all off one
|
|
206
|
+
// read, and refuses with a coded 409 when the role is unresolved. Counting
|
|
207
|
+
// here as well would be a second answer free to disagree with the one that
|
|
208
|
+
// acts, and "0 deleted" reads as success to a user whose Trash is untouched.
|
|
209
|
+
return client.messageMove.emptyTrash(accountConfigId, accountId);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { SYNC_WARNING_SURFACE_ATTEMPTS } from "@remit/data-ports/sync-warning-state";
|
|
3
|
+
import { logger } from "@remit/logger";
|
|
4
|
+
import { toVipSuggestionEntry } from "./vip-suggestions.js";
|
|
5
|
+
const DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
|
|
6
|
+
const SURFACED_WARNING_CAP = 200;
|
|
7
|
+
export const MeOperations = {
|
|
8
|
+
MeOperations_listVipSuggestions: async (_context, ctx) => {
|
|
9
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
10
|
+
const { address } = ctx.client;
|
|
11
|
+
const items = await address.listSuggestedVips({ accountConfigId });
|
|
12
|
+
return { suggestions: items.map(toVipSuggestionEntry) };
|
|
13
|
+
},
|
|
14
|
+
MeOperations_listQuarantine: async (_context, ctx) => {
|
|
15
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
16
|
+
const { quarantine, syncWarning } = ctx.client;
|
|
17
|
+
const [entries, warnings] = await Promise.all([
|
|
18
|
+
quarantine.listByAccountConfigId(accountConfigId),
|
|
19
|
+
syncWarning.listSurfacedByAccountConfigId(accountConfigId, SYNC_WARNING_SURFACE_ATTEMPTS, SURFACED_WARNING_CAP),
|
|
20
|
+
]);
|
|
21
|
+
return {
|
|
22
|
+
entries,
|
|
23
|
+
warnings: warnings.items,
|
|
24
|
+
warningsTotal: warnings.total,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
MeOperations_deleteMe: async (context, ctx) => {
|
|
28
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
29
|
+
const { confirmEmail } = context.request
|
|
30
|
+
.requestBody;
|
|
31
|
+
const callerEmail = ctx.identity.email;
|
|
32
|
+
if (typeof confirmEmail !== "string" || confirmEmail.trim() === "") {
|
|
33
|
+
throw new BadRequestError("confirmEmail is required");
|
|
34
|
+
}
|
|
35
|
+
if (confirmEmail.toLowerCase() !== callerEmail.toLowerCase()) {
|
|
36
|
+
throw new BadRequestError("confirmEmail does not match your account email");
|
|
37
|
+
}
|
|
38
|
+
const { accountConfig } = ctx.client;
|
|
39
|
+
try {
|
|
40
|
+
await accountConfig.update(accountConfigId, {
|
|
41
|
+
state: "deleting",
|
|
42
|
+
deletedAt: Date.now(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// If already in "deleting" state, treat as idempotent
|
|
47
|
+
if (err instanceof Error &&
|
|
48
|
+
(err.name === "ConditionalCheckFailedException" ||
|
|
49
|
+
err.message.includes("ConditionalCheckFailed"))) {
|
|
50
|
+
return {
|
|
51
|
+
statusCode: 202,
|
|
52
|
+
message: "Account deletion already in progress",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
await ctx.client.queues.accountFanout.send({
|
|
58
|
+
body: { type: "AccountDelete", accountConfigId },
|
|
59
|
+
});
|
|
60
|
+
// biome-ignore lint/plugin/no-logger-info: account deletion is an audit-grade signal
|
|
61
|
+
logger.info({ accountConfigId }, "Account deletion initiated");
|
|
62
|
+
return {
|
|
63
|
+
statusCode: 202,
|
|
64
|
+
message: "Account deletion initiated",
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
MeOperations_createExport: async (_context, ctx) => {
|
|
68
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
69
|
+
const userId = ctx.identity.sub;
|
|
70
|
+
const { accountExportRequest } = ctx.client;
|
|
71
|
+
const exportRequest = await accountExportRequest.create({
|
|
72
|
+
accountConfigId,
|
|
73
|
+
userId,
|
|
74
|
+
state: "Pending",
|
|
75
|
+
});
|
|
76
|
+
await ctx.client.queues.accountFanout.send({
|
|
77
|
+
body: {
|
|
78
|
+
type: "AccountExport",
|
|
79
|
+
accountConfigId,
|
|
80
|
+
accountExportRequestId: exportRequest.accountExportRequestId,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
// biome-ignore lint/plugin/no-logger-info: account export is an audit-grade signal
|
|
84
|
+
logger.info({
|
|
85
|
+
accountConfigId,
|
|
86
|
+
accountExportRequestId: exportRequest.accountExportRequestId,
|
|
87
|
+
}, "Account export initiated");
|
|
88
|
+
return {
|
|
89
|
+
statusCode: 202,
|
|
90
|
+
exportId: exportRequest.accountExportRequestId,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
MeOperations_getExport: async (context, ctx) => {
|
|
94
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
95
|
+
const exportId = context.request.params.exportId;
|
|
96
|
+
const { accountExportRequest, storage } = ctx.client;
|
|
97
|
+
const exportRequest = await accountExportRequest.get(exportId);
|
|
98
|
+
if (exportRequest.accountConfigId !== accountConfigId) {
|
|
99
|
+
throw new NotFoundError(`Export not found: ${exportId}`);
|
|
100
|
+
}
|
|
101
|
+
const downloadUrl = exportRequest.state === "Ready" && exportRequest.objectKey
|
|
102
|
+
? await storage.getPresignedDownloadUrl(exportRequest.objectKey, DOWNLOAD_URL_TTL_SECONDS)
|
|
103
|
+
: undefined;
|
|
104
|
+
return {
|
|
105
|
+
accountExportRequestId: exportRequest.accountExportRequestId,
|
|
106
|
+
accountConfigId: exportRequest.accountConfigId,
|
|
107
|
+
userId: exportRequest.userId,
|
|
108
|
+
state: exportRequest.state,
|
|
109
|
+
createdAt: exportRequest.createdAt,
|
|
110
|
+
updatedAt: exportRequest.updatedAt,
|
|
111
|
+
objectKey: exportRequest.objectKey,
|
|
112
|
+
downloadUrl,
|
|
113
|
+
expiresAt: exportRequest.expiresAt,
|
|
114
|
+
errorMessage: exportRequest.errorMessage,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { BodyPartResponse } from "@remit/api-openapi-types";
|
|
2
|
+
import { type ContentSigner } from "../derive/contentSignature.js";
|
|
3
|
+
import type { MessageBulkOperationIds, MessageOperationIds, OperationHandler } from "../types.js";
|
|
4
|
+
export declare const isStorageNotFoundError: (error: unknown) => boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Decode the raw `.eml` bytes into a string for inspection. RFC822/MIME
|
|
7
|
+
* sources are 7-bit/8-bit ASCII-compatible (non-ASCII bytes are encoded via
|
|
8
|
+
* quoted-printable / base64 inside the body), so `latin1` round-trips every
|
|
9
|
+
* byte 1:1 without loss or replacement characters — unlike `utf8`, which would
|
|
10
|
+
* mangle raw 8-bit bytes. Headers and structure stay byte-accurate.
|
|
11
|
+
*/
|
|
12
|
+
export declare const decodeRawEml: (body: Buffer) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Extract the accountConfigId + accountId segments from a bodyStorageKey URI
|
|
15
|
+
* such as
|
|
16
|
+
* `s3://bucket/accounts/{accountConfigId}/{accountId}/messages/{messageId}/body.eml`.
|
|
17
|
+
* Returns null when the URI shape doesn't match — caller falls back to the
|
|
18
|
+
* slow path without writing a parsed-body cache.
|
|
19
|
+
*/
|
|
20
|
+
export declare const extractAccountIdsFromBodyKey: (uri: string) => {
|
|
21
|
+
accountConfigId: string;
|
|
22
|
+
accountId: string;
|
|
23
|
+
} | null;
|
|
24
|
+
/**
|
|
25
|
+
* Map a list of stored `BodyPart` rows to API `BodyPartResponse` objects,
|
|
26
|
+
* populating `contentUrl` from the CloudFront distribution domain. Pure
|
|
27
|
+
* function so the URL-construction contract can be pinned in tests without
|
|
28
|
+
* standing up the full handler. The contract requires every `contentUrl`
|
|
29
|
+
* to be a real URL (#299), so the API refuses to start without the domain.
|
|
30
|
+
*/
|
|
31
|
+
export interface BodyPartLike {
|
|
32
|
+
bodyPartId: string;
|
|
33
|
+
mediaType: string;
|
|
34
|
+
mediaSubtype: string;
|
|
35
|
+
sizeOctets: number;
|
|
36
|
+
disposition?: string;
|
|
37
|
+
dispositionFilename?: string;
|
|
38
|
+
isMultipart: boolean;
|
|
39
|
+
contentId?: string;
|
|
40
|
+
partPath: string;
|
|
41
|
+
}
|
|
42
|
+
export declare const isMediaType: (value: unknown) => value is BodyPartResponse["mediaType"];
|
|
43
|
+
export declare const isContentDisposition: (value: unknown) => value is NonNullable<BodyPartResponse["disposition"]>;
|
|
44
|
+
export declare const FORWARDED_MESSAGE_FILENAME = "Forwarded message.eml";
|
|
45
|
+
export declare const buildBodyPartResponses: (parts: readonly BodyPartLike[], context: {
|
|
46
|
+
contentDeliveryDomain: string;
|
|
47
|
+
accountConfigId: string;
|
|
48
|
+
accountId: string;
|
|
49
|
+
messageId: string;
|
|
50
|
+
sign?: ContentSigner;
|
|
51
|
+
}) => BodyPartResponse[];
|
|
52
|
+
export interface SpamReportBulkFailure {
|
|
53
|
+
messageId: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
export interface SpamReportBulkOutcome {
|
|
57
|
+
successCount: number;
|
|
58
|
+
failureCount: number;
|
|
59
|
+
failures?: SpamReportBulkFailure[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The fallback for a failure nobody designed copy for — an AWS SDK message
|
|
63
|
+
* naming a queue URL, an `ECONNREFUSED host:port`, a programmer error.
|
|
64
|
+
* Everything reaching it is flattened, same policy as `error.ts`'s "Internal
|
|
65
|
+
* server error" flattening, because `SpamReportBulkResult.reason` is shown to
|
|
66
|
+
* the user as-is. It says "try again" because retrying is the only thing left
|
|
67
|
+
* to say about a cause we cannot name; an outcome the service DOES understand
|
|
68
|
+
* carries its own text through `DESIGNED_FAILURES` instead of landing here.
|
|
69
|
+
*/
|
|
70
|
+
export declare const GENERIC_FAILURE_REASON = "This message could not be processed. Please try again. If it keeps failing, report it.";
|
|
71
|
+
/**
|
|
72
|
+
* Drive one report-spam/not-spam operation per message, concurrently. Each
|
|
73
|
+
* message's own wait for its move to settle (SpamReportService's R2 wait) is
|
|
74
|
+
* bounded on its own, but running the batch sequentially would let those
|
|
75
|
+
* waits accumulate across the whole request — 50 messages could each wait
|
|
76
|
+
* seconds, serializing into minutes and dying at the proxy. Running them
|
|
77
|
+
* concurrently instead bounds the whole request by a single message's wait,
|
|
78
|
+
* regardless of batch size.
|
|
79
|
+
*/
|
|
80
|
+
export declare const settleSpamReportBulk: (messageIds: string[], run: (messageId: string) => Promise<void>) => Promise<SpamReportBulkOutcome>;
|
|
81
|
+
export declare const MessageOperations: Record<MessageOperationIds, OperationHandler<MessageOperationIds, "sync" | "messageManagement" | "body">>;
|
|
82
|
+
export declare const MessageBulkOperations: Record<MessageBulkOperationIds, OperationHandler<MessageBulkOperationIds, "sync" | "messageManagement" | "body">>;
|