@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,159 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ConflictError, HTTPError } from "@remit/data-ports/errors";
|
|
5
|
+
import { defaultErrorCode } from "../error.js";
|
|
6
|
+
class TargetVersionMismatchError extends HTTPError {
|
|
7
|
+
name = "TargetVersionMismatchError";
|
|
8
|
+
statusCode = 422;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The running version is a fact the updater owns: it reads the tag from `.env`
|
|
12
|
+
* and writes it into `state.json`, which is the only honest source across an
|
|
13
|
+
* update that rewrites that tag underneath a long-lived backend process. When no
|
|
14
|
+
* state file exists yet the backend has nothing authoritative to report, so it
|
|
15
|
+
* reports the version as unknown rather than fabricating one from its own
|
|
16
|
+
* process environment — a value captured at container start that drifts from the
|
|
17
|
+
* real running tag the moment an update lands. The first updater check writes a
|
|
18
|
+
* real `currentVersion` within seconds of the stack coming up.
|
|
19
|
+
*/
|
|
20
|
+
const UNKNOWN_VERSION = "unknown";
|
|
21
|
+
/**
|
|
22
|
+
* A refusal in the shape `formatResponse` unwraps: the status, and the body as
|
|
23
|
+
* an object it serializes. Handing it an already-serialized `body` string
|
|
24
|
+
* instead publishes the whole envelope — `{"statusCode":…,"headers":…}` with
|
|
25
|
+
* the real body escaped inside it — which is not an `ApiError` at all.
|
|
26
|
+
*/
|
|
27
|
+
const refuse = (statusCode, message) => ({
|
|
28
|
+
statusCode,
|
|
29
|
+
body: { code: defaultErrorCode(statusCode), message },
|
|
30
|
+
});
|
|
31
|
+
const notFound = () => refuse(404, "Not found");
|
|
32
|
+
/**
|
|
33
|
+
* The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
|
|
34
|
+
* which the hosted deployment leaves unset. Both endpoints answer 404 in that
|
|
35
|
+
* case. Where it is on, any authenticated caller may use it — the account list
|
|
36
|
+
* is the trust boundary of a self-hosted instance.
|
|
37
|
+
*/
|
|
38
|
+
const guardManifestConfigured = (config) => config.selfUpdate === "Enabled" ? null : notFound();
|
|
39
|
+
/**
|
|
40
|
+
* Read `state.json` off the control volume. A missing file is an instance that
|
|
41
|
+
* has never checked or updated (RFC 037 D8), not an error, so it reads as
|
|
42
|
+
* `null`; the caller renders the disabled/empty resource. Any other read or
|
|
43
|
+
* parse failure propagates.
|
|
44
|
+
*/
|
|
45
|
+
const readState = (controlDir) => {
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(join(controlDir, "state.json"), "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === "ENOENT")
|
|
52
|
+
return null;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
};
|
|
57
|
+
const emptyResource = () => ({
|
|
58
|
+
currentVersion: UNKNOWN_VERSION,
|
|
59
|
+
check: { status: "disabled" },
|
|
60
|
+
run: null,
|
|
61
|
+
});
|
|
62
|
+
const isRunInFlight = (run) => run != null && run.outcome === null;
|
|
63
|
+
/**
|
|
64
|
+
* The version the caller may install is the one the last check reported. It
|
|
65
|
+
* exists only on a successful check; a disabled or failed check offers nothing
|
|
66
|
+
* to match, so any target is a mismatch.
|
|
67
|
+
*/
|
|
68
|
+
const checkedVersion = (state) => state?.check.status === "ok" ? state.check.latestVersion : undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Write `request.json` atomically with exactly the four fields the seam accepts
|
|
71
|
+
* (#133). The updater resolves the registry and every image reference from the
|
|
72
|
+
* manifest it fetches itself; a request naming any of those is rejected whole,
|
|
73
|
+
* so the backend never writes one. The temp file sits in the same directory as
|
|
74
|
+
* the target for the rename to be atomic on one filesystem.
|
|
75
|
+
*/
|
|
76
|
+
const writeRequest = (dir, request) => {
|
|
77
|
+
const tmp = join(dir, `.request.json.${request.runId}.tmp`);
|
|
78
|
+
writeFileSync(tmp, JSON.stringify(request), { mode: 0o644 });
|
|
79
|
+
renameSync(tmp, join(dir, "request.json"));
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Record a check request on the control seam (#599). The updater consumes it on
|
|
83
|
+
* its watch loop and runs a manifest check immediately, so a press of check in
|
|
84
|
+
* the panel — not just the updater's own cadence — moves `lastCheckedAt`. Like
|
|
85
|
+
* request.json it is written atomically and carries no authority: its presence
|
|
86
|
+
* is the whole message, so it is empty.
|
|
87
|
+
*/
|
|
88
|
+
const writeCheckRequest = (dir) => {
|
|
89
|
+
const tmp = join(dir, `.check-request.json.tmp`);
|
|
90
|
+
writeFileSync(tmp, JSON.stringify({}), { mode: 0o644 });
|
|
91
|
+
renameSync(tmp, join(dir, "check-request.json"));
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The resource returned by the POST. The updater has not yet written the
|
|
95
|
+
* authoritative run — it polls the seam — so this bootstraps the run block with
|
|
96
|
+
* the id just requested and the first phase, giving the client a `runId` to poll
|
|
97
|
+
* for (RFC 037 D9). The updater's own `state.json` supersedes it on the next
|
|
98
|
+
* read.
|
|
99
|
+
*/
|
|
100
|
+
const requestedResource = (state, runId, targetVersion, requestedAt) => {
|
|
101
|
+
const from = state?.currentVersion ?? UNKNOWN_VERSION;
|
|
102
|
+
return {
|
|
103
|
+
currentVersion: from,
|
|
104
|
+
check: state?.check ?? { status: "disabled" },
|
|
105
|
+
run: {
|
|
106
|
+
runId,
|
|
107
|
+
fromVersion: from,
|
|
108
|
+
targetVersion,
|
|
109
|
+
phase: "checking",
|
|
110
|
+
outcome: null,
|
|
111
|
+
startedAt: requestedAt,
|
|
112
|
+
updatedAt: requestedAt,
|
|
113
|
+
message: "The update has been requested.",
|
|
114
|
+
logCommand: "remit logs updater",
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
export const SystemOperations = {
|
|
119
|
+
SystemOperations_getSystemUpdate: async (context, ctx) => {
|
|
120
|
+
const offSurface = guardManifestConfigured(ctx.config);
|
|
121
|
+
if (offSurface)
|
|
122
|
+
return offSurface;
|
|
123
|
+
// A refresh asks for a fresh answer, and only the updater can fetch one, so
|
|
124
|
+
// the request goes on the control seam for its watch loop to pick up (#599).
|
|
125
|
+
// The answer is the stored state as it stands, `lastCheckedAt` included: the
|
|
126
|
+
// caller holds its own press and watches that timestamp move, so the
|
|
127
|
+
// response never has to claim a verdict the updater has not reached.
|
|
128
|
+
const { refresh } = context.request.query;
|
|
129
|
+
if (refresh === true)
|
|
130
|
+
writeCheckRequest(ctx.config.updateControlDir);
|
|
131
|
+
return readState(ctx.config.updateControlDir) ?? emptyResource();
|
|
132
|
+
},
|
|
133
|
+
SystemOperations_applySystemUpdate: async (context, ctx) => {
|
|
134
|
+
const offSurface = guardManifestConfigured(ctx.config);
|
|
135
|
+
if (offSurface)
|
|
136
|
+
return offSurface;
|
|
137
|
+
const sub = ctx.identity.sub;
|
|
138
|
+
const { targetVersion } = context.request.requestBody;
|
|
139
|
+
const state = readState(ctx.config.updateControlDir);
|
|
140
|
+
if (isRunInFlight(state?.run)) {
|
|
141
|
+
throw new ConflictError("An update is already in progress.");
|
|
142
|
+
}
|
|
143
|
+
if (targetVersion !== checkedVersion(state)) {
|
|
144
|
+
throw new TargetVersionMismatchError("targetVersion does not match the latest checked release.");
|
|
145
|
+
}
|
|
146
|
+
const runId = randomUUID();
|
|
147
|
+
const requestedAt = new Date().toISOString();
|
|
148
|
+
writeRequest(ctx.config.updateControlDir, {
|
|
149
|
+
runId,
|
|
150
|
+
targetVersion,
|
|
151
|
+
requestedAt,
|
|
152
|
+
requestedBy: sub,
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
statusCode: 202,
|
|
156
|
+
body: requestedResource(state, runId, targetVersion, requestedAt),
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { MessageCategory, SenderTrust, ThreadSearchResponse } from "@remit/api-openapi-types";
|
|
2
|
+
import type { ResultList, ThreadMessageItem } from "@remit/data-ports";
|
|
3
|
+
import { type EnrichClient } from "../derive/enrichThreadRows.js";
|
|
4
|
+
import type { OperationHandler, ThreadDetailOperationIds, ThreadOperationIds } from "../types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Build the `listByMailbox` options for the thread list handler.
|
|
7
|
+
*
|
|
8
|
+
* Exposed as a pure helper so the `excludeDeleted: true` default — which is
|
|
9
|
+
* the entire point of the #212 fix on the handler side — is testable without
|
|
10
|
+
* standing up DynamoDB. The defaults must stay opt-out from the user (a
|
|
11
|
+
* future Trash / All-Mail view can flip the flag) and opt-in at the
|
|
12
|
+
* service layer.
|
|
13
|
+
*/
|
|
14
|
+
export declare const buildListThreadsOptions: (query: {
|
|
15
|
+
continuationToken?: string;
|
|
16
|
+
order?: "asc" | "desc";
|
|
17
|
+
}) => {
|
|
18
|
+
order: "asc" | "desc";
|
|
19
|
+
continuationToken: string | undefined;
|
|
20
|
+
limit: number;
|
|
21
|
+
attributes: ("mailboxId" | "messageId" | "referenceOrder" | "createdAt" | "updatedAt" | "accountConfigId" | "uid" | "category" | "subject" | "internalDate" | "inReplyTo" | "fromName" | "threadMessageId" | "star" | "threadId" | "sentDate" | "isRead" | "hasAttachment" | "hasStars" | "isDeleted" | "messageIdHeader" | "fromEmail" | "listId" | "snippet")[];
|
|
22
|
+
excludeDeleted: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Build the `searchByMailboxWindow` options for the thread search handler.
|
|
26
|
+
* Same #212 `excludeDeleted` default as `buildListThreadsOptions`. The caller's
|
|
27
|
+
* `limit` is forwarded raw and clamped server-side (THREAD_SEARCH_MAX_LIMIT).
|
|
28
|
+
*/
|
|
29
|
+
export declare const buildSearchThreadsOptions: (query: {
|
|
30
|
+
continuationToken?: string;
|
|
31
|
+
order?: "asc" | "desc";
|
|
32
|
+
limit?: number;
|
|
33
|
+
}) => {
|
|
34
|
+
order: "asc" | "desc";
|
|
35
|
+
continuationToken: string | undefined;
|
|
36
|
+
limit: number | undefined;
|
|
37
|
+
attributes: ("mailboxId" | "messageId" | "referenceOrder" | "createdAt" | "updatedAt" | "accountConfigId" | "uid" | "category" | "subject" | "internalDate" | "inReplyTo" | "fromName" | "threadMessageId" | "star" | "threadId" | "sentDate" | "isRead" | "hasAttachment" | "hasStars" | "isDeleted" | "messageIdHeader" | "fromEmail" | "listId" | "snippet")[];
|
|
38
|
+
excludeDeleted: boolean;
|
|
39
|
+
};
|
|
40
|
+
type WindowOptions = {
|
|
41
|
+
order?: "asc" | "desc";
|
|
42
|
+
limit?: number;
|
|
43
|
+
continuationToken?: string;
|
|
44
|
+
attributes?: Array<keyof ThreadMessageItem>;
|
|
45
|
+
excludeDeleted?: boolean;
|
|
46
|
+
};
|
|
47
|
+
type ThreadSearch = {
|
|
48
|
+
query?: string;
|
|
49
|
+
subject?: string;
|
|
50
|
+
from?: string;
|
|
51
|
+
unread?: boolean;
|
|
52
|
+
starred?: boolean;
|
|
53
|
+
attachments?: boolean;
|
|
54
|
+
category?: MessageCategory[];
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Minimal client surface `executeThreadSearch` needs. Declared structurally
|
|
58
|
+
* (like `EnrichClient`) so the orchestration — index/window read, count, and
|
|
59
|
+
* the off-row enrich/filter — is testable with an in-memory fake.
|
|
60
|
+
*/
|
|
61
|
+
export interface ThreadSearchClient extends EnrichClient {
|
|
62
|
+
threadMessage: {
|
|
63
|
+
searchByMailboxWindow(accountConfigId: string, mailboxId: string, search: ThreadSearch, options?: WindowOptions): Promise<ResultList<ThreadMessageItem>>;
|
|
64
|
+
countByMailbox(accountConfigId: string, mailboxId: string, search: ThreadSearch, options?: {
|
|
65
|
+
excludeDeleted?: boolean;
|
|
66
|
+
order?: "asc" | "desc";
|
|
67
|
+
}): Promise<number>;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export type ThreadSearchParams = {
|
|
71
|
+
continuationToken?: string;
|
|
72
|
+
order?: "asc" | "desc";
|
|
73
|
+
query?: string;
|
|
74
|
+
subject?: string;
|
|
75
|
+
from?: string;
|
|
76
|
+
unread?: boolean;
|
|
77
|
+
starred?: boolean;
|
|
78
|
+
attachments?: boolean;
|
|
79
|
+
senderTrust?: SenderTrust[];
|
|
80
|
+
category?: MessageCategory[];
|
|
81
|
+
dkimMismatch?: boolean;
|
|
82
|
+
count?: boolean;
|
|
83
|
+
results?: boolean;
|
|
84
|
+
limit?: number;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Run a per-mailbox thread search: select the index/window read, optionally
|
|
88
|
+
* count, and resolve off-row criteria by enriching+filtering the window. The
|
|
89
|
+
* `results` toggle omits `items` (count-only) and `count` adds the match count.
|
|
90
|
+
*
|
|
91
|
+
* `count` names the whole match, never the page: a page size bounds the rows a
|
|
92
|
+
* response carries and has no bearing on how many messages match (#509).
|
|
93
|
+
*
|
|
94
|
+
* An off-row criterion is the one shape that has no such number. `senderTrust`
|
|
95
|
+
* and `dkimMismatch` are resolved by enriching a window, so counting them means
|
|
96
|
+
* enriching every candidate row in the mailbox. `count` is therefore absent
|
|
97
|
+
* (#305) — a page's post-filter length labelled `count` is a page length
|
|
98
|
+
* presented as a total, which is the defect the field exists to avoid. Absence
|
|
99
|
+
* is the same answer the response already gives for a count nobody asked for,
|
|
100
|
+
* so no caller needs a second shape to read it. A count-only request under an
|
|
101
|
+
* off-row criterion therefore reads nothing at all.
|
|
102
|
+
*/
|
|
103
|
+
export declare const executeThreadSearch: (client: ThreadSearchClient, accountConfigId: string, mailboxId: string, params: ThreadSearchParams) => Promise<ThreadSearchResponse>;
|
|
104
|
+
/**
|
|
105
|
+
* Build the `listByThread` options for the thread-messages handler. Same
|
|
106
|
+
* #212 default as the listing handlers — a soft-deleted message inside a
|
|
107
|
+
* conversation should not surface in the conversation pane either.
|
|
108
|
+
*
|
|
109
|
+
* A conversation is never scoped to one mailbox: the user's own replies live
|
|
110
|
+
* in Sent, filed messages live in their folder, and all of them belong to the
|
|
111
|
+
* same thread (#46).
|
|
112
|
+
*
|
|
113
|
+
* The default order is `asc`: a conversation is read in the order it happened,
|
|
114
|
+
* so a reply follows the message it answers (#81). The ordering is the query's,
|
|
115
|
+
* not the handler's — `listByThread` sorts on sentDate with `threadMessageId`
|
|
116
|
+
* breaking ties, so it holds across pages rather than within one response.
|
|
117
|
+
*/
|
|
118
|
+
export declare const buildListThreadMessagesOptions: (query: {
|
|
119
|
+
order?: "asc" | "desc";
|
|
120
|
+
}) => {
|
|
121
|
+
order: "asc" | "desc";
|
|
122
|
+
excludeDeleted: boolean;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Collapse rows that are the same piece of mail to one entry.
|
|
126
|
+
*
|
|
127
|
+
* A ThreadMessage row is keyed by (threadId, messageId), and a messageId is
|
|
128
|
+
* derived from the account plus the RFC 5322 Message-ID, so the same mail
|
|
129
|
+
* synced from two folders is one row — the mailbox a thread spans is not what
|
|
130
|
+
* duplicates it. `MessageMoveService.copyMessage` is the exception: a copy is a
|
|
131
|
+
* per-folder placement with its own derived id (`deriveCopyMessageId`), so a
|
|
132
|
+
* copied message is a second row in the same thread carrying the same
|
|
133
|
+
* `messageIdHeader`. Reading the whole thread rather than one mailbox of it
|
|
134
|
+
* (#46) is what makes both rows visible at once, and two entries for one message
|
|
135
|
+
* is never what a conversation should show.
|
|
136
|
+
*
|
|
137
|
+
* The oldest row wins, so the original outlives the copy, with `threadMessageId`
|
|
138
|
+
* breaking ties. Both are independent of sort order, so `asc` and `desc` keep
|
|
139
|
+
* the same message rather than each keeping a different copy of it.
|
|
140
|
+
*
|
|
141
|
+
* A row without a usable `messageIdHeader` is never merged: the fallback
|
|
142
|
+
* identity for headerless mail is per-row by construction, so two such rows are
|
|
143
|
+
* two distinct messages.
|
|
144
|
+
*/
|
|
145
|
+
export declare const dedupeThreadMessages: <T extends Pick<ThreadMessageItem, "threadMessageId" | "messageIdHeader" | "createdAt">>(rows: T[]) => T[];
|
|
146
|
+
export declare const ThreadOperations: Record<ThreadOperationIds, OperationHandler<ThreadOperationIds>>;
|
|
147
|
+
export declare const ThreadDetailOperations: Record<ThreadDetailOperationIds, OperationHandler<ThreadDetailOperationIds>>;
|
|
148
|
+
export {};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { isValidMessageId } from "@remit/data-ports/id";
|
|
3
|
+
import { enrichThreadRows, } from "../derive/enrichThreadRows.js";
|
|
4
|
+
import { filterByOffRowCriteria, hasOffRowCriteria, } from "../derive/filterThreadCriteria.js";
|
|
5
|
+
const DEFAULT_THREADS_PAGE_SIZE = 50;
|
|
6
|
+
// Project to fields used by ListThreadsResponse — keep in sync with handler mapping.
|
|
7
|
+
// Includes ThreadMessage table key fields (accountConfigId, threadMessageId) and
|
|
8
|
+
// the lsi2 index components (mailboxId, sentDate) so ElectroDB can materialize
|
|
9
|
+
// entities and pagination cursors from projected reads. accountConfigId +
|
|
10
|
+
// fromEmail are required at read time to derive the From Address row for
|
|
11
|
+
// senderTrust enrichment (see enrichThreadRows).
|
|
12
|
+
const THREAD_LIST_ATTRIBUTES = [
|
|
13
|
+
"threadMessageId",
|
|
14
|
+
"threadId",
|
|
15
|
+
"messageId",
|
|
16
|
+
"accountConfigId",
|
|
17
|
+
"mailboxId",
|
|
18
|
+
"fromEmail",
|
|
19
|
+
"fromName",
|
|
20
|
+
"subject",
|
|
21
|
+
"sentDate",
|
|
22
|
+
"isRead",
|
|
23
|
+
"hasAttachment",
|
|
24
|
+
"star",
|
|
25
|
+
"hasStars",
|
|
26
|
+
"isDeleted",
|
|
27
|
+
"snippet",
|
|
28
|
+
"category",
|
|
29
|
+
"createdAt",
|
|
30
|
+
"updatedAt",
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Build the `listByMailbox` options for the thread list handler.
|
|
34
|
+
*
|
|
35
|
+
* Exposed as a pure helper so the `excludeDeleted: true` default — which is
|
|
36
|
+
* the entire point of the #212 fix on the handler side — is testable without
|
|
37
|
+
* standing up DynamoDB. The defaults must stay opt-out from the user (a
|
|
38
|
+
* future Trash / All-Mail view can flip the flag) and opt-in at the
|
|
39
|
+
* service layer.
|
|
40
|
+
*/
|
|
41
|
+
export const buildListThreadsOptions = (query) => ({
|
|
42
|
+
order: query.order ?? "desc",
|
|
43
|
+
continuationToken: query.continuationToken,
|
|
44
|
+
limit: DEFAULT_THREADS_PAGE_SIZE,
|
|
45
|
+
attributes: [...THREAD_LIST_ATTRIBUTES],
|
|
46
|
+
excludeDeleted: true,
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Build the `searchByMailboxWindow` options for the thread search handler.
|
|
50
|
+
* Same #212 `excludeDeleted` default as `buildListThreadsOptions`. The caller's
|
|
51
|
+
* `limit` is forwarded raw and clamped server-side (THREAD_SEARCH_MAX_LIMIT).
|
|
52
|
+
*/
|
|
53
|
+
export const buildSearchThreadsOptions = (query) => ({
|
|
54
|
+
order: query.order ?? "desc",
|
|
55
|
+
continuationToken: query.continuationToken,
|
|
56
|
+
limit: query.limit,
|
|
57
|
+
attributes: [...THREAD_LIST_ATTRIBUTES],
|
|
58
|
+
excludeDeleted: true,
|
|
59
|
+
});
|
|
60
|
+
const toArray = (value) => {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
return Array.isArray(value) ? value : [value];
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Run a per-mailbox thread search: select the index/window read, optionally
|
|
67
|
+
* count, and resolve off-row criteria by enriching+filtering the window. The
|
|
68
|
+
* `results` toggle omits `items` (count-only) and `count` adds the match count.
|
|
69
|
+
*
|
|
70
|
+
* `count` names the whole match, never the page: a page size bounds the rows a
|
|
71
|
+
* response carries and has no bearing on how many messages match (#509).
|
|
72
|
+
*
|
|
73
|
+
* An off-row criterion is the one shape that has no such number. `senderTrust`
|
|
74
|
+
* and `dkimMismatch` are resolved by enriching a window, so counting them means
|
|
75
|
+
* enriching every candidate row in the mailbox. `count` is therefore absent
|
|
76
|
+
* (#305) — a page's post-filter length labelled `count` is a page length
|
|
77
|
+
* presented as a total, which is the defect the field exists to avoid. Absence
|
|
78
|
+
* is the same answer the response already gives for a count nobody asked for,
|
|
79
|
+
* so no caller needs a second shape to read it. A count-only request under an
|
|
80
|
+
* off-row criterion therefore reads nothing at all.
|
|
81
|
+
*/
|
|
82
|
+
export const executeThreadSearch = async (client, accountConfigId, mailboxId, params) => {
|
|
83
|
+
const search = {
|
|
84
|
+
query: params.query,
|
|
85
|
+
subject: params.subject,
|
|
86
|
+
from: params.from,
|
|
87
|
+
unread: params.unread,
|
|
88
|
+
starred: params.starred,
|
|
89
|
+
attachments: params.attachments,
|
|
90
|
+
category: params.category,
|
|
91
|
+
};
|
|
92
|
+
const offRow = {
|
|
93
|
+
senderTrust: params.senderTrust,
|
|
94
|
+
dkimMismatch: params.dkimMismatch,
|
|
95
|
+
};
|
|
96
|
+
const wantCount = params.count === true;
|
|
97
|
+
const wantResults = params.results !== false;
|
|
98
|
+
const options = buildSearchThreadsOptions({
|
|
99
|
+
continuationToken: params.continuationToken,
|
|
100
|
+
order: params.order,
|
|
101
|
+
limit: params.limit,
|
|
102
|
+
});
|
|
103
|
+
if (hasOffRowCriteria(offRow)) {
|
|
104
|
+
if (!wantResults)
|
|
105
|
+
return {};
|
|
106
|
+
const window = await client.threadMessage.searchByMailboxWindow(accountConfigId, mailboxId, search, options);
|
|
107
|
+
const enriched = await enrichThreadRows(window.items, client, accountConfigId);
|
|
108
|
+
return {
|
|
109
|
+
items: filterByOffRowCriteria(enriched, offRow),
|
|
110
|
+
continuationToken: window.continuationToken,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const response = {};
|
|
114
|
+
if (wantResults) {
|
|
115
|
+
const window = await client.threadMessage.searchByMailboxWindow(accountConfigId, mailboxId, search, options);
|
|
116
|
+
response.items = await enrichThreadRows(window.items, client, accountConfigId);
|
|
117
|
+
response.continuationToken = window.continuationToken;
|
|
118
|
+
}
|
|
119
|
+
if (wantCount) {
|
|
120
|
+
response.count = await client.threadMessage.countByMailbox(accountConfigId, mailboxId, search, { excludeDeleted: true, order: params.order });
|
|
121
|
+
}
|
|
122
|
+
return response;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Build the `listByThread` options for the thread-messages handler. Same
|
|
126
|
+
* #212 default as the listing handlers — a soft-deleted message inside a
|
|
127
|
+
* conversation should not surface in the conversation pane either.
|
|
128
|
+
*
|
|
129
|
+
* A conversation is never scoped to one mailbox: the user's own replies live
|
|
130
|
+
* in Sent, filed messages live in their folder, and all of them belong to the
|
|
131
|
+
* same thread (#46).
|
|
132
|
+
*
|
|
133
|
+
* The default order is `asc`: a conversation is read in the order it happened,
|
|
134
|
+
* so a reply follows the message it answers (#81). The ordering is the query's,
|
|
135
|
+
* not the handler's — `listByThread` sorts on sentDate with `threadMessageId`
|
|
136
|
+
* breaking ties, so it holds across pages rather than within one response.
|
|
137
|
+
*/
|
|
138
|
+
export const buildListThreadMessagesOptions = (query) => ({
|
|
139
|
+
order: query.order ?? "asc",
|
|
140
|
+
excludeDeleted: true,
|
|
141
|
+
});
|
|
142
|
+
/**
|
|
143
|
+
* Collapse rows that are the same piece of mail to one entry.
|
|
144
|
+
*
|
|
145
|
+
* A ThreadMessage row is keyed by (threadId, messageId), and a messageId is
|
|
146
|
+
* derived from the account plus the RFC 5322 Message-ID, so the same mail
|
|
147
|
+
* synced from two folders is one row — the mailbox a thread spans is not what
|
|
148
|
+
* duplicates it. `MessageMoveService.copyMessage` is the exception: a copy is a
|
|
149
|
+
* per-folder placement with its own derived id (`deriveCopyMessageId`), so a
|
|
150
|
+
* copied message is a second row in the same thread carrying the same
|
|
151
|
+
* `messageIdHeader`. Reading the whole thread rather than one mailbox of it
|
|
152
|
+
* (#46) is what makes both rows visible at once, and two entries for one message
|
|
153
|
+
* is never what a conversation should show.
|
|
154
|
+
*
|
|
155
|
+
* The oldest row wins, so the original outlives the copy, with `threadMessageId`
|
|
156
|
+
* breaking ties. Both are independent of sort order, so `asc` and `desc` keep
|
|
157
|
+
* the same message rather than each keeping a different copy of it.
|
|
158
|
+
*
|
|
159
|
+
* A row without a usable `messageIdHeader` is never merged: the fallback
|
|
160
|
+
* identity for headerless mail is per-row by construction, so two such rows are
|
|
161
|
+
* two distinct messages.
|
|
162
|
+
*/
|
|
163
|
+
export const dedupeThreadMessages = (rows) => {
|
|
164
|
+
const winners = new Map();
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
if (!isValidMessageId(row.messageIdHeader))
|
|
167
|
+
continue;
|
|
168
|
+
const header = row.messageIdHeader.trim();
|
|
169
|
+
const held = winners.get(header);
|
|
170
|
+
if (!held ||
|
|
171
|
+
row.createdAt < held.createdAt ||
|
|
172
|
+
(row.createdAt === held.createdAt &&
|
|
173
|
+
row.threadMessageId < held.threadMessageId)) {
|
|
174
|
+
winners.set(header, row);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return rows.filter((row) => {
|
|
178
|
+
if (!isValidMessageId(row.messageIdHeader))
|
|
179
|
+
return true;
|
|
180
|
+
return winners.get(row.messageIdHeader.trim()) === row;
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
export const ThreadOperations = {
|
|
184
|
+
ThreadOperations_listThreads: async (context, ctx) => {
|
|
185
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
186
|
+
const { mailboxId } = context.request.params;
|
|
187
|
+
const { continuationToken, order } = context.request.query;
|
|
188
|
+
const client = ctx.client;
|
|
189
|
+
const result = await client.threadMessage.listByMailbox(accountConfigId, mailboxId, buildListThreadsOptions({ continuationToken, order }));
|
|
190
|
+
const items = await enrichThreadRows(result.items, client, accountConfigId);
|
|
191
|
+
return {
|
|
192
|
+
items,
|
|
193
|
+
continuationToken: result.continuationToken,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
ThreadOperations_searchThreads: async (context, ctx) => {
|
|
197
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
198
|
+
const { mailboxId } = context.request.params;
|
|
199
|
+
const raw = context.request.query;
|
|
200
|
+
return executeThreadSearch(ctx.client, accountConfigId, mailboxId, {
|
|
201
|
+
...raw,
|
|
202
|
+
senderTrust: toArray(raw.senderTrust),
|
|
203
|
+
category: toArray(raw.category),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
export const ThreadDetailOperations = {
|
|
208
|
+
ThreadDetailOperations_listThreadMessages: async (context, ctx) => {
|
|
209
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
210
|
+
const { threadId } = context.request.params;
|
|
211
|
+
const { order } = context.request.query;
|
|
212
|
+
const client = ctx.client;
|
|
213
|
+
const result = await client.threadMessage.listByThread(threadId, accountConfigId, buildListThreadMessagesOptions({ order }));
|
|
214
|
+
// Defense-in-depth: the query is already scoped to accountConfigId; assert
|
|
215
|
+
// no foreign row slips into the response. `read`-class → 404, no existence
|
|
216
|
+
// leak, consistent with the mailbox/message guards.
|
|
217
|
+
for (const row of result.items) {
|
|
218
|
+
if (row.accountConfigId !== accountConfigId) {
|
|
219
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const items = await enrichThreadRows(dedupeThreadMessages(result.items), client, accountConfigId);
|
|
223
|
+
return {
|
|
224
|
+
items,
|
|
225
|
+
continuationToken: result.continuationToken,
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|