@remit/backend 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. package/tsconfig.json +7 -0
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import {
4
+ createContentSigner,
5
+ getContentSigner,
6
+ verifyContentSignature,
7
+ } from "./contentSignature.js";
8
+
9
+ const SECRET = "test-master-secret-at-least-32-chars-long";
10
+ const PATH_A = "accounts/cfg-alice/acc-alice/messages/msg-1/parts/1.2";
11
+ const PATH_B = "accounts/cfg-bob/acc-bob/messages/msg-9/parts/1.2";
12
+
13
+ const nowSeconds = () => Math.floor(Date.now() / 1000);
14
+
15
+ describe("content signature: sign + verify round trip", () => {
16
+ it("a freshly signed URL verifies", () => {
17
+ const { exp, sig } = createContentSigner(SECRET)(PATH_A);
18
+ const result = verifyContentSignature(
19
+ PATH_A,
20
+ String(exp),
21
+ sig,
22
+ SECRET,
23
+ nowSeconds(),
24
+ );
25
+ assert.deepEqual(result, { valid: true });
26
+ });
27
+
28
+ it("rejects a signature minted for a different account's path (cross-account IDOR defense)", () => {
29
+ const { exp, sig } = createContentSigner(SECRET)(PATH_A);
30
+ // Present account A's signature against account B's path.
31
+ const result = verifyContentSignature(
32
+ PATH_B,
33
+ String(exp),
34
+ sig,
35
+ SECRET,
36
+ nowSeconds(),
37
+ );
38
+ assert.deepEqual(result, { valid: false, reason: "bad-signature" });
39
+ });
40
+
41
+ it("rejects a tampered signature", () => {
42
+ const { exp, sig } = createContentSigner(SECRET)(PATH_A);
43
+ const tampered = `${sig.slice(0, -1)}${sig.endsWith("A") ? "B" : "A"}`;
44
+ const result = verifyContentSignature(
45
+ PATH_A,
46
+ String(exp),
47
+ tampered,
48
+ SECRET,
49
+ nowSeconds(),
50
+ );
51
+ assert.equal(result.valid, false);
52
+ });
53
+
54
+ it("rejects a valid signature verified with the wrong secret", () => {
55
+ const { exp, sig } = createContentSigner(SECRET)(PATH_A);
56
+ const result = verifyContentSignature(
57
+ PATH_A,
58
+ String(exp),
59
+ sig,
60
+ "a-different-master-secret-also-32-chars",
61
+ nowSeconds(),
62
+ );
63
+ assert.deepEqual(result, { valid: false, reason: "bad-signature" });
64
+ });
65
+
66
+ it("rejects an expired signature", () => {
67
+ // Negative TTL mints a signature whose expiry is already in the past.
68
+ const { exp, sig } = createContentSigner(SECRET, -20)(PATH_A);
69
+ assert.ok(exp < nowSeconds());
70
+ const result = verifyContentSignature(
71
+ PATH_A,
72
+ String(exp),
73
+ sig,
74
+ SECRET,
75
+ nowSeconds(),
76
+ );
77
+ assert.deepEqual(result, { valid: false, reason: "expired" });
78
+ });
79
+
80
+ it("reports missing exp/sig distinctly from a bad signature", () => {
81
+ assert.deepEqual(
82
+ verifyContentSignature(
83
+ PATH_A,
84
+ undefined,
85
+ undefined,
86
+ SECRET,
87
+ nowSeconds(),
88
+ ),
89
+ { valid: false, reason: "missing" },
90
+ );
91
+ });
92
+
93
+ it("rejects a non-integer exp as malformed", () => {
94
+ const { sig } = createContentSigner(SECRET)(PATH_A);
95
+ assert.deepEqual(
96
+ verifyContentSignature(PATH_A, "not-a-number", sig, SECRET, nowSeconds()),
97
+ { valid: false, reason: "malformed" },
98
+ );
99
+ });
100
+ });
101
+
102
+ describe("getContentSigner", () => {
103
+ const ORIGINAL_BACKEND = process.env.DATA_BACKEND;
104
+ const ORIGINAL_SECRET = process.env.BETTER_AUTH_SECRET;
105
+
106
+ afterEach(() => {
107
+ if (ORIGINAL_BACKEND === undefined) delete process.env.DATA_BACKEND;
108
+ else process.env.DATA_BACKEND = ORIGINAL_BACKEND;
109
+ if (ORIGINAL_SECRET === undefined) delete process.env.BETTER_AUTH_SECRET;
110
+ else process.env.BETTER_AUTH_SECRET = ORIGINAL_SECRET;
111
+ });
112
+
113
+ it("returns undefined outside Postgres mode (AWS keeps unsigned URLs)", () => {
114
+ delete process.env.DATA_BACKEND;
115
+ assert.equal(getContentSigner(), undefined);
116
+ });
117
+
118
+ it("returns a working signer in Postgres mode", () => {
119
+ process.env.DATA_BACKEND = "postgres";
120
+ process.env.BETTER_AUTH_SECRET = SECRET;
121
+ const signer = getContentSigner();
122
+ assert.ok(signer);
123
+ const { exp, sig } = signer(PATH_A);
124
+ assert.deepEqual(
125
+ verifyContentSignature(PATH_A, String(exp), sig, SECRET, nowSeconds()),
126
+ { valid: true },
127
+ );
128
+ });
129
+
130
+ it("throws in Postgres mode when the master secret is missing (fail loud)", () => {
131
+ process.env.DATA_BACKEND = "postgres";
132
+ delete process.env.BETTER_AUTH_SECRET;
133
+ assert.throws(() => getContentSigner(), /BETTER_AUTH_SECRET/);
134
+ });
135
+
136
+ it("returns a working signer in SQLite mode", () => {
137
+ process.env.DATA_BACKEND = "sqlite";
138
+ process.env.BETTER_AUTH_SECRET = SECRET;
139
+ const signer = getContentSigner();
140
+ assert.ok(signer);
141
+ const { exp, sig } = signer(PATH_A);
142
+ assert.deepEqual(
143
+ verifyContentSignature(PATH_A, String(exp), sig, SECRET, nowSeconds()),
144
+ { valid: true },
145
+ );
146
+ });
147
+
148
+ it("throws in SQLite mode when the master secret is missing (fail loud)", () => {
149
+ process.env.DATA_BACKEND = "sqlite";
150
+ delete process.env.BETTER_AUTH_SECRET;
151
+ assert.throws(() => getContentSigner(), /BETTER_AUTH_SECRET/);
152
+ });
153
+ });
@@ -0,0 +1,139 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { usesBetterAuthJwt } from "../data-backend.js";
3
+
4
+ /**
5
+ * Signed-URL scheme for `/content/*` on the Postgres stack.
6
+ *
7
+ * On AWS the same bytes are guarded by CloudFront + a Lambda@Edge JWT verifier.
8
+ * The Postgres stack serves `/content/*` straight from the backend container,
9
+ * and a bearer token cannot ride along on an `<img src>` / `<a href>` content
10
+ * load rendered inside email HTML. So the backend signs each content URL when
11
+ * it hands the SPA a `BodyPartResponse.contentUrl`, and the `/content` route
12
+ * verifies the signature instead of a bearer.
13
+ *
14
+ * The signature covers the account-scoped storage path (`accounts/{cfg}/{acc}/
15
+ * messages/{msg}/parts/{part}`) plus an expiry. Because the path — including
16
+ * both account ids — is part of the signed message, a signature minted for
17
+ * account A's content cannot be replayed against account B's path (the
18
+ * recomputed HMAC won't match). The backend only ever mints signatures for the
19
+ * authenticated caller's own accountConfigId, so a caller can never obtain a
20
+ * valid signature for another account. This is the presigned-URL capability
21
+ * model the export flow already uses: possession of the URL grants access until
22
+ * it expires, bounded by a short TTL.
23
+ */
24
+
25
+ const KEY_DERIVATION_LABEL = "remit-content-url-signing-v1";
26
+
27
+ /**
28
+ * Default validity window for a signed content URL. Long enough to cover a
29
+ * viewing session (react-query caches describeMessage for 30 min and inline
30
+ * images may load late), short enough to bound the replay window if a URL
31
+ * leaks.
32
+ */
33
+ export const CONTENT_URL_TTL_SECONDS = 3600;
34
+
35
+ /**
36
+ * Derive a purpose-specific signing subkey from the master secret. Domain
37
+ * separation via a fixed label keeps the content-signing key distinct from the
38
+ * raw better-auth secret, so a compromise of one signing space does not reveal
39
+ * the other. Reusing `BETTER_AUTH_SECRET` as the master means no new secret has
40
+ * to be provisioned on the Postgres stack — it is already required there.
41
+ */
42
+ const deriveSigningKey = (masterSecret: string): Buffer =>
43
+ createHmac("sha256", masterSecret).update(KEY_DERIVATION_LABEL).digest();
44
+
45
+ const canonicalMessage = (relativePath: string, exp: number): string =>
46
+ `${relativePath}\n${exp}`;
47
+
48
+ const computeSignature = (
49
+ key: Buffer,
50
+ relativePath: string,
51
+ exp: number,
52
+ ): string =>
53
+ createHmac("sha256", key)
54
+ .update(canonicalMessage(relativePath, exp))
55
+ .digest("base64url");
56
+
57
+ export interface ContentSignature {
58
+ exp: number;
59
+ sig: string;
60
+ }
61
+
62
+ /**
63
+ * A function that signs a decoded, account-scoped storage path and returns the
64
+ * `exp`/`sig` query params to append to its content URL.
65
+ */
66
+ export type ContentSigner = (relativePath: string) => ContentSignature;
67
+
68
+ export const createContentSigner = (
69
+ masterSecret: string,
70
+ ttlSeconds: number = CONTENT_URL_TTL_SECONDS,
71
+ ): ContentSigner => {
72
+ const key = deriveSigningKey(masterSecret);
73
+ return (relativePath) => {
74
+ const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
75
+ return { exp, sig: computeSignature(key, relativePath, exp) };
76
+ };
77
+ };
78
+
79
+ /**
80
+ * Build the content-URL signer for the current environment, or `undefined` when
81
+ * signing does not apply. Signing is enforced on the self-host SQL backends
82
+ * (postgres and sqlite), which serve `/content/*` straight from the backend
83
+ * container; on AWS the Lambda@Edge JWT verifier guards `/content/*` and the URL
84
+ * stays unsigned so CloudFront/S3 behaviour is unchanged. Throws on those
85
+ * backends when the master secret is missing, so a misconfigured deploy fails
86
+ * loud rather than shipping unsigned (unauthenticated) content URLs.
87
+ */
88
+ export const getContentSigner = (): ContentSigner | undefined => {
89
+ if (!usesBetterAuthJwt()) return undefined;
90
+ const secret = process.env.BETTER_AUTH_SECRET;
91
+ if (!secret || secret.length === 0) {
92
+ throw new Error(
93
+ "a self-host SQL backend (postgres/sqlite) requires BETTER_AUTH_SECRET to sign content URLs",
94
+ );
95
+ }
96
+ return createContentSigner(secret);
97
+ };
98
+
99
+ export type ContentSignatureFailure =
100
+ | "missing"
101
+ | "malformed"
102
+ | "expired"
103
+ | "bad-signature";
104
+
105
+ export type ContentSignatureResult =
106
+ | { valid: true }
107
+ | { valid: false; reason: ContentSignatureFailure };
108
+
109
+ /**
110
+ * Verify a signed content request. Recomputes the HMAC over the requested path
111
+ * and the supplied expiry and constant-time compares it against the presented
112
+ * signature. Pure so the decision can be unit-tested without a live server.
113
+ */
114
+ export const verifyContentSignature = (
115
+ relativePath: string,
116
+ expRaw: string | undefined,
117
+ sig: string | undefined,
118
+ masterSecret: string,
119
+ nowSeconds: number,
120
+ ): ContentSignatureResult => {
121
+ if (!expRaw || !sig) return { valid: false, reason: "missing" };
122
+
123
+ const exp = Number(expRaw);
124
+ if (!Number.isInteger(exp) || exp <= 0) {
125
+ return { valid: false, reason: "malformed" };
126
+ }
127
+ if (exp < nowSeconds) return { valid: false, reason: "expired" };
128
+
129
+ const key = deriveSigningKey(masterSecret);
130
+ const expected = Buffer.from(computeSignature(key, relativePath, exp));
131
+ const presented = Buffer.from(sig);
132
+ if (expected.length !== presented.length) {
133
+ return { valid: false, reason: "bad-signature" };
134
+ }
135
+ if (!timingSafeEqual(expected, presented)) {
136
+ return { valid: false, reason: "bad-signature" };
137
+ }
138
+ return { valid: true };
139
+ };
@@ -0,0 +1,156 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import {
4
+ createContentSigner,
5
+ verifyContentSignature,
6
+ } from "./contentSignature.js";
7
+ import { buildContentUrl, getContentDeliveryDomain } from "./contentUrl.js";
8
+
9
+ describe("buildContentUrl", () => {
10
+ it("produces the /content/accounts/{cfg}/{acc}/messages/{msg}/parts/{part} layout the Lambda@Edge expects", () => {
11
+ const url = buildContentUrl({
12
+ domain: "https://abc123.cloudfront.net",
13
+ accountConfigId: "cfg-alice",
14
+ accountId: "acc-alice",
15
+ messageId: "msg-1",
16
+ partPath: "1.2",
17
+ });
18
+
19
+ assert.equal(
20
+ url,
21
+ "https://abc123.cloudfront.net/content/accounts/cfg-alice/acc-alice/messages/msg-1/parts/1.2",
22
+ );
23
+ });
24
+
25
+ it("prepends https:// when the domain is given as a bare hostname", () => {
26
+ const url = buildContentUrl({
27
+ domain: "abc123.cloudfront.net",
28
+ accountConfigId: "cfg-alice",
29
+ accountId: "acc-alice",
30
+ messageId: "msg-1",
31
+ partPath: "1",
32
+ });
33
+
34
+ assert.equal(url.startsWith("https://abc123.cloudfront.net/"), true);
35
+ });
36
+
37
+ it("strips a trailing slash on the domain so the path doesn't double up", () => {
38
+ const url = buildContentUrl({
39
+ domain: "https://abc123.cloudfront.net/",
40
+ accountConfigId: "cfg-alice",
41
+ accountId: "acc-alice",
42
+ messageId: "msg-1",
43
+ partPath: "1",
44
+ });
45
+
46
+ assert.equal(
47
+ url,
48
+ "https://abc123.cloudfront.net/content/accounts/cfg-alice/acc-alice/messages/msg-1/parts/1",
49
+ );
50
+ });
51
+
52
+ it("encodes each segment of partPath defensively (forward-slash kept as separator)", () => {
53
+ const url = buildContentUrl({
54
+ domain: "https://cdn.test",
55
+ accountConfigId: "cfg",
56
+ accountId: "acc",
57
+ messageId: "msg",
58
+ partPath: "1/strange path/3",
59
+ });
60
+
61
+ assert.equal(
62
+ url,
63
+ "https://cdn.test/content/accounts/cfg/acc/messages/msg/parts/1/strange%20path/3",
64
+ );
65
+ });
66
+
67
+ it("leaves the URL unsigned when no signer is provided (AWS/Lambda@Edge path)", () => {
68
+ const url = buildContentUrl({
69
+ domain: "https://abc123.cloudfront.net",
70
+ accountConfigId: "cfg-alice",
71
+ accountId: "acc-alice",
72
+ messageId: "msg-1",
73
+ partPath: "1",
74
+ });
75
+ assert.equal(url.includes("?"), false);
76
+ });
77
+
78
+ it("appends an exp/sig signature over the decoded storage path when a signer is provided", () => {
79
+ const secret = "test-master-secret-at-least-32-chars-long";
80
+ const url = buildContentUrl({
81
+ domain: "https://api.example.test",
82
+ accountConfigId: "cfg-alice",
83
+ accountId: "acc-alice",
84
+ messageId: "msg-1",
85
+ partPath: "1.2",
86
+ sign: createContentSigner(secret),
87
+ });
88
+
89
+ const parsed = new URL(url);
90
+ const exp = parsed.searchParams.get("exp");
91
+ const sig = parsed.searchParams.get("sig");
92
+ assert.ok(exp);
93
+ assert.ok(sig);
94
+ // The signature must verify against the same decoded storage path the
95
+ // `/content` route recomputes from `req.path`.
96
+ const relativePath = parsed.pathname.replace(/^\/content\//, "");
97
+ const result = verifyContentSignature(
98
+ relativePath,
99
+ exp,
100
+ sig,
101
+ secret,
102
+ Math.floor(Date.now() / 1000),
103
+ );
104
+ assert.deepEqual(result, { valid: true });
105
+ });
106
+
107
+ it("nests accountConfigId before accountId so the Lambda@Edge prefix check matches", () => {
108
+ const url = buildContentUrl({
109
+ domain: "https://cdn.test",
110
+ accountConfigId: "CFG",
111
+ accountId: "ACC",
112
+ messageId: "MSG",
113
+ partPath: "1",
114
+ });
115
+
116
+ const segments = new URL(url).pathname.split("/");
117
+ // ["", "content", "accounts", "CFG", "ACC", "messages", "MSG", "parts", "1"]
118
+ assert.equal(segments[1], "content");
119
+ assert.equal(segments[2], "accounts");
120
+ assert.equal(segments[3], "CFG");
121
+ assert.equal(segments[4], "ACC");
122
+ });
123
+ });
124
+
125
+ describe("getContentDeliveryDomain", () => {
126
+ const ORIGINAL = process.env.CONTENT_DELIVERY_DOMAIN;
127
+
128
+ beforeEach(() => {
129
+ delete process.env.CONTENT_DELIVERY_DOMAIN;
130
+ });
131
+
132
+ afterEach(() => {
133
+ if (ORIGINAL === undefined) delete process.env.CONTENT_DELIVERY_DOMAIN;
134
+ else process.env.CONTENT_DELIVERY_DOMAIN = ORIGINAL;
135
+ });
136
+
137
+ it("throws when CONTENT_DELIVERY_DOMAIN is unset (fail loud over silent placeholder, #299)", () => {
138
+ assert.throws(
139
+ () => getContentDeliveryDomain(),
140
+ /CONTENT_DELIVERY_DOMAIN is not set/,
141
+ );
142
+ });
143
+
144
+ it("throws when CONTENT_DELIVERY_DOMAIN is empty", () => {
145
+ process.env.CONTENT_DELIVERY_DOMAIN = "";
146
+ assert.throws(
147
+ () => getContentDeliveryDomain(),
148
+ /CONTENT_DELIVERY_DOMAIN is not set/,
149
+ );
150
+ });
151
+
152
+ it("returns the env var value when set", () => {
153
+ process.env.CONTENT_DELIVERY_DOMAIN = "https://abc.cloudfront.net";
154
+ assert.equal(getContentDeliveryDomain(), "https://abc.cloudfront.net");
155
+ });
156
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Build the public CloudFront URL that fetches a single body part for a
3
+ * message. The path layout is what the Lambda@Edge JWT verifier expects
4
+ * (PR 1 of #224): `/content/accounts/{accountConfigId}/{accountId}/messages/
5
+ * {messageId}/parts/{partPath}`.
6
+ *
7
+ * The function is intentionally pure — domain comes from configuration so
8
+ * tests can pin the output without env vars. `partPath` is passed through
9
+ * `encodeURIComponent` because IMAP section paths are dot-separated digits
10
+ * (e.g. `1`, `1.2`, `2.1.3`) but a defensive encode keeps the builder safe
11
+ * if the path ever carries unexpected characters.
12
+ */
13
+ import type { ContentSigner } from "./contentSignature.js";
14
+
15
+ export interface BuildContentUrlInput {
16
+ domain: string;
17
+ accountConfigId: string;
18
+ accountId: string;
19
+ messageId: string;
20
+ partPath: string;
21
+ /**
22
+ * When present (Postgres stack), append an `exp`/`sig` signature scoped to
23
+ * this account's storage path. Absent on AWS, where Lambda@Edge guards the
24
+ * URL and the path stays unsigned. See `contentSignature.ts`.
25
+ */
26
+ sign?: ContentSigner;
27
+ }
28
+
29
+ export const buildContentUrl = (input: BuildContentUrlInput): string => {
30
+ const { domain, accountConfigId, accountId, messageId, partPath, sign } =
31
+ input;
32
+ const normalizedDomain = domain.replace(/\/+$/, "");
33
+ const base = normalizedDomain.startsWith("http")
34
+ ? normalizedDomain
35
+ : `https://${normalizedDomain}`;
36
+ // The signed message is the decoded, account-scoped storage path — the same
37
+ // value the `/content` route recomputes from `req.path`. Keep it in lockstep
38
+ // with the verifier's `req.path.replace(/^\/content\//, "")`.
39
+ const relativePath = `accounts/${accountConfigId}/${accountId}/messages/${messageId}/parts/${partPath}`;
40
+ const safePart = partPath
41
+ .split("/")
42
+ .map((segment) => encodeURIComponent(segment))
43
+ .join("/");
44
+ const url = `${base}/content/accounts/${accountConfigId}/${accountId}/messages/${messageId}/parts/${safePart}`;
45
+ if (!sign) return url;
46
+ const { exp, sig } = sign(relativePath);
47
+ return `${url}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
48
+ };
49
+
50
+ /**
51
+ * Read the CloudFront distribution domain from the environment. The CDK
52
+ * wires `CONTENT_DELIVERY_DOMAIN` onto the API Lambda from the
53
+ * ContentDelivery stack's published SSM parameter (`/{stage}/Remit/frontendUrl`).
54
+ *
55
+ * Throws when the env var is missing or empty. `BodyPartResponse.contentUrl`
56
+ * is required + URL-formatted in the API contract, so silently emitting a
57
+ * placeholder string would ship a contract lie to clients. Failing loud here
58
+ * surfaces the missing wire-up as a 500 the deploy team can act on (#299).
59
+ * Production synth hard-codes a syntactically-valid placeholder URL via
60
+ * `infra/bin/infra.ts` so this only fires when the env is actually unset.
61
+ */
62
+ export const getContentDeliveryDomain = (): string => {
63
+ const value = process.env.CONTENT_DELIVERY_DOMAIN;
64
+ if (!value || value.length === 0) {
65
+ throw new Error(
66
+ "CONTENT_DELIVERY_DOMAIN is not set; BodyPartResponse.contentUrl cannot be built. Wire the CloudFront distribution domain via the API Lambda env (see infra/bin/infra.ts).",
67
+ );
68
+ }
69
+ return value;
70
+ };
@@ -0,0 +1,155 @@
1
+ import type { ThreadMessageResponse } from "@remit/api-openapi-types";
2
+ import type {
3
+ AddressItem,
4
+ MessageItem,
5
+ ThreadMessageItem,
6
+ } from "@remit/data-ports";
7
+ import { deriveAddressId } from "@remit/data-ports/id";
8
+ import { MessageCategory, SenderTrust, StarColor } from "@remit/domain-enums";
9
+ import { deriveAutoMoved } from "./autoMoved.js";
10
+ import { deriveSenderTrust } from "./senderTrust.js";
11
+
12
+ /**
13
+ * Subset of the ElectroDB client surface used for batch enrichment. Declared
14
+ * structurally so unit tests can pass an in-memory fake without standing up
15
+ * DynamoDB.
16
+ */
17
+ export interface EnrichClient {
18
+ message: {
19
+ get(messageIds: string[]): Promise<MessageItem[]>;
20
+ };
21
+ address: {
22
+ getAddress(
23
+ accountConfigId: string,
24
+ addressIds: string[],
25
+ ): Promise<AddressItem[]>;
26
+ };
27
+ }
28
+
29
+ const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
30
+ threadMessageId: item.threadMessageId,
31
+ threadId: item.threadId,
32
+ messageId: item.messageId,
33
+ accountConfigId: item.accountConfigId,
34
+ mailboxId: item.mailboxId,
35
+ fromEmail: item.fromEmail,
36
+ fromName: item.fromName,
37
+ subject: item.subject,
38
+ sentDate: item.sentDate,
39
+ isRead: item.isRead,
40
+ hasAttachment: item.hasAttachment,
41
+ star: item.star ?? StarColor.None,
42
+ hasStars: item.hasStars,
43
+ isDeleted: item.isDeleted,
44
+ snippet: item.snippet,
45
+ createdAt: item.createdAt,
46
+ updatedAt: item.updatedAt,
47
+ senderTrust: SenderTrust.Unknown,
48
+ });
49
+
50
+ /**
51
+ * Plan the unique batch-fetch keys for a page of ThreadMessage rows.
52
+ *
53
+ * Splitting this out keeps the dedup logic testable without DynamoDB and
54
+ * documents the contract: a page of N rows produces at most one BatchGet
55
+ * for messages and one for addresses, no matter how many rows share the
56
+ * same messageId / sender.
57
+ *
58
+ * `addressId` is derived deterministically from `(accountConfigId, fromEmail)`
59
+ * via `deriveAddressId`, mirroring the write path in
60
+ * `body-sync.ts`. Rows without a `fromEmail` (rare; sentinel/system rows)
61
+ * contribute no addressId and fall back to `senderTrust: "unknown"`.
62
+ */
63
+ export interface BatchPlan {
64
+ messageIds: string[];
65
+ addressIds: string[];
66
+ addressIdByRow: Map<string, string>;
67
+ }
68
+
69
+ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
70
+ const messageIds = new Set<string>();
71
+ const addressIds = new Set<string>();
72
+ const addressIdByRow = new Map<string, string>();
73
+
74
+ for (const row of rows) {
75
+ messageIds.add(row.messageId);
76
+
77
+ if (!row.fromEmail) continue;
78
+
79
+ const addressId = deriveAddressId(row.accountConfigId, row.fromEmail);
80
+ addressIds.add(addressId);
81
+ addressIdByRow.set(row.threadMessageId, addressId);
82
+ }
83
+
84
+ return {
85
+ messageIds: [...messageIds],
86
+ addressIds: [...addressIds],
87
+ addressIdByRow,
88
+ };
89
+ };
90
+
91
+ /**
92
+ * Enrich a page of ThreadMessage rows with `category` (from the underlying
93
+ * Message), `senderTrust` (derived from the From Address's flags map) and
94
+ * `autoMoved` (projected from the Message's internal placement verdict, see
95
+ * `deriveAutoMoved`).
96
+ *
97
+ * Two BatchGetItem calls per page, regardless of page size — see
98
+ * `planBatchFetch` for the dedup contract.
99
+ *
100
+ * Missing rows fall back gracefully: `category` is omitted only when the
101
+ * underlying Message row is absent (clients treat as `personal`); a present
102
+ * Message with no stored category coalesces to `uncategorized` (RFC 032 Tier 2).
103
+ * `senderTrust` defaults to `"unknown"`. `autoMoved` is omitted whenever the
104
+ * move isn't a real, in-effect auto-move (or the Message row is absent).
105
+ */
106
+ export const enrichThreadRows = async (
107
+ rows: ThreadMessageItem[],
108
+ client: EnrichClient,
109
+ accountConfigId: string,
110
+ ): Promise<ThreadMessageResponse[]> => {
111
+ if (rows.length === 0) return [];
112
+
113
+ const plan = planBatchFetch(rows);
114
+
115
+ const [messages, addresses] = await Promise.all([
116
+ plan.messageIds.length ? client.message.get(plan.messageIds) : [],
117
+ plan.addressIds.length
118
+ ? client.address.getAddress(accountConfigId, plan.addressIds)
119
+ : [],
120
+ ]);
121
+
122
+ const categoryByMessageId = new Map(
123
+ messages.map((m) => [
124
+ m.messageId,
125
+ m.category ?? MessageCategory.uncategorized,
126
+ ]),
127
+ );
128
+ const authenticityByMessageId = new Map(
129
+ messages.map((m) => [m.messageId, m.authenticity]),
130
+ );
131
+ const autoMovedByMessageId = new Map(
132
+ messages.map((m) => [m.messageId, deriveAutoMoved(m)]),
133
+ );
134
+ const trustByAddressId = new Map(
135
+ addresses.map((a) => [a.addressId, deriveSenderTrust(a.flags)]),
136
+ );
137
+
138
+ return rows.map((row) => {
139
+ const base = toResponse(row);
140
+ const category = categoryByMessageId.get(row.messageId);
141
+ const authenticity = authenticityByMessageId.get(row.messageId);
142
+ const autoMoved = autoMovedByMessageId.get(row.messageId);
143
+ const addressId = plan.addressIdByRow.get(row.threadMessageId);
144
+ const senderTrust = addressId
145
+ ? (trustByAddressId.get(addressId) ?? SenderTrust.Unknown)
146
+ : SenderTrust.Unknown;
147
+ return {
148
+ ...base,
149
+ ...(category !== undefined ? { category } : {}),
150
+ ...(authenticity !== undefined ? { authenticity } : {}),
151
+ ...(autoMoved !== undefined ? { autoMoved } : {}),
152
+ senderTrust,
153
+ };
154
+ });
155
+ };