@remit/backend 0.0.44 → 0.0.46
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/dev-server/log-output.test.ts +173 -0
- package/dev-server/server.ts +22 -11
- package/package.json +1 -1
- package/scripts/backfill-list-id.ts +3 -2
- package/src/derive/deriveMuted.test.ts +34 -0
- package/src/derive/deriveMuted.ts +10 -0
- package/src/derive/enrichThreadRows.test.ts +61 -1
- package/src/derive/enrichThreadRows.ts +20 -9
- package/src/derive/filterThreadCriteria.test.ts +1 -0
- package/src/index.ts +2 -2
- package/tsconfig.json +12 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// This file is the backend image's entrypoint, so what it writes IS the
|
|
9
|
+
// container's log stream. deploy/vps/README.md ("Logs") promises one JSON object
|
|
10
|
+
// per line, and a log-shipping pipeline written against that contract breaks on
|
|
11
|
+
// the first line it cannot parse — which is why this runs the real server rather
|
|
12
|
+
// than asserting against a mocked writer.
|
|
13
|
+
//
|
|
14
|
+
// Both streams are held to it. The container log driver merges stdout and
|
|
15
|
+
// stderr into one log and `remit logs` shows both, so a raw line on stderr
|
|
16
|
+
// breaks a Vector parser exactly like a raw line on stdout.
|
|
17
|
+
|
|
18
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
|
|
20
|
+
const freePort = (): Promise<number> =>
|
|
21
|
+
new Promise((resolveWith, reject) => {
|
|
22
|
+
const probe = createServer();
|
|
23
|
+
probe.on("error", reject);
|
|
24
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
25
|
+
const address = probe.address();
|
|
26
|
+
if (address === null || typeof address === "string") {
|
|
27
|
+
probe.close();
|
|
28
|
+
reject(new Error("could not reserve a port"));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
probe.close(() => resolveWith(address.port));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
type Line = Record<string, unknown>;
|
|
36
|
+
type Stream = "stdout" | "stderr";
|
|
37
|
+
|
|
38
|
+
const captured: Record<Stream, string> = { stdout: "", stderr: "" };
|
|
39
|
+
const listeners = new Set<() => void>();
|
|
40
|
+
let port = 0;
|
|
41
|
+
|
|
42
|
+
// The child's output reaches this process asynchronously, so a request that has
|
|
43
|
+
// already been answered is not yet a line here. Wait for the line rather than
|
|
44
|
+
// for a duration, and only once the stream ends on a newline, so no assertion
|
|
45
|
+
// ever runs against half of one.
|
|
46
|
+
const waitForStdout = (contains: string): Promise<void> =>
|
|
47
|
+
new Promise((resolveWith, reject) => {
|
|
48
|
+
const settled = () =>
|
|
49
|
+
captured.stdout.includes(contains) && captured.stdout.endsWith("\n");
|
|
50
|
+
if (settled()) {
|
|
51
|
+
resolveWith();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
stopWaiting();
|
|
56
|
+
reject(new Error(`stdout never carried ${contains}: ${captured.stdout}`));
|
|
57
|
+
}, 10_000);
|
|
58
|
+
const onData = () => {
|
|
59
|
+
if (!settled()) return;
|
|
60
|
+
stopWaiting();
|
|
61
|
+
resolveWith();
|
|
62
|
+
};
|
|
63
|
+
const stopWaiting = () => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
listeners.delete(onData);
|
|
66
|
+
};
|
|
67
|
+
listeners.add(onData);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const parsedLines = (stream: Stream): Line[] =>
|
|
71
|
+
captured[stream]
|
|
72
|
+
.split("\n")
|
|
73
|
+
.filter((line) => line.trim().length > 0)
|
|
74
|
+
.map((line, index) => {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(line) as Line;
|
|
77
|
+
} catch {
|
|
78
|
+
throw new assert.AssertionError({
|
|
79
|
+
message: `${stream} line ${index + 1} is not JSON, so a log pipeline drops it: ${line}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("the backend entrypoint's log output", () => {
|
|
85
|
+
let server: ReturnType<typeof spawn>;
|
|
86
|
+
|
|
87
|
+
before(async () => {
|
|
88
|
+
port = await freePort();
|
|
89
|
+
|
|
90
|
+
server = spawn(
|
|
91
|
+
process.execPath,
|
|
92
|
+
["--import", "tsx", "dev-server/server.ts"],
|
|
93
|
+
{
|
|
94
|
+
cwd: packageRoot,
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
96
|
+
env: {
|
|
97
|
+
...process.env,
|
|
98
|
+
SERVER_PORT: String(port),
|
|
99
|
+
LOG_LEVEL: "debug",
|
|
100
|
+
REMIT_SERVICE_NAME: "backend",
|
|
101
|
+
// tsx's own loader warnings are an artifact of running the
|
|
102
|
+
// TypeScript source; the image runs a bundle and never emits them.
|
|
103
|
+
NODE_NO_WARNINGS: "1",
|
|
104
|
+
NODE_OPTIONS: "",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const stream of ["stdout", "stderr"] as const) {
|
|
110
|
+
server[stream]?.setEncoding("utf8");
|
|
111
|
+
server[stream]?.on("data", (chunk: string) => {
|
|
112
|
+
captured[stream] += chunk;
|
|
113
|
+
for (const listener of [...listeners]) listener();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await new Promise<void>((resolveWith, reject) => {
|
|
118
|
+
server.on("message", (message) => {
|
|
119
|
+
if (message === "ready") resolveWith();
|
|
120
|
+
});
|
|
121
|
+
// Carry the child's stderr into the failure: a server that cannot boot
|
|
122
|
+
// otherwise reports only an exit code, and the reason is in that buffer.
|
|
123
|
+
server.on("exit", (code) =>
|
|
124
|
+
reject(
|
|
125
|
+
new Error(
|
|
126
|
+
`server exited before listening (code ${code})\n${captured.stderr}`,
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A request the router answers without touching a data backend, so the
|
|
133
|
+
// invocation's own lines — the ones withTelemetry writes around the
|
|
134
|
+
// handler — are captured by the time this suite reads them.
|
|
135
|
+
await fetch(`http://127.0.0.1:${port}/no-such-route`);
|
|
136
|
+
await waitForStdout("Request received");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
after(() => {
|
|
140
|
+
server.kill("SIGKILL");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("is JSON on every line of both streams", () => {
|
|
144
|
+
const lines = [...parsedLines("stdout"), ...parsedLines("stderr")];
|
|
145
|
+
assert.ok(lines.length > 0, "expected the server to have written a line");
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
assert.equal(typeof line.level, "string");
|
|
148
|
+
assert.equal(typeof line.time, "string");
|
|
149
|
+
assert.equal(line.service, "backend");
|
|
150
|
+
assert.equal(typeof line.msg, "string");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("reports what it is listening on as fields, not a banner", () => {
|
|
155
|
+
const listening = parsedLines("stdout").find(
|
|
156
|
+
(line) => line.msg === "Backend listening",
|
|
157
|
+
);
|
|
158
|
+
assert.ok(listening, "expected a startup line");
|
|
159
|
+
assert.equal(listening.port, port);
|
|
160
|
+
assert.equal(listening.url, `http://localhost:${port}`);
|
|
161
|
+
assert.equal(listening.level, "info");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("correlates the whole invocation under one requestId", () => {
|
|
165
|
+
const lines = parsedLines("stdout").filter(
|
|
166
|
+
(line) => line.msg === "Lambda invocation started" || line.path,
|
|
167
|
+
);
|
|
168
|
+
assert.ok(lines.length >= 2, "expected the invocation to have logged");
|
|
169
|
+
const requestIds = new Set(lines.map((line) => line.requestId));
|
|
170
|
+
assert.equal(requestIds.size, 1);
|
|
171
|
+
assert.equal([...requestIds][0] === undefined, false);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/dev-server/server.ts
CHANGED
|
@@ -288,7 +288,7 @@ app.all(/(.*)/, async (req: Request, res: Response) => {
|
|
|
288
288
|
) {
|
|
289
289
|
const parsed = await safeJsonParse<unknown>(body).catch(() => undefined);
|
|
290
290
|
if (parsed === undefined) {
|
|
291
|
-
|
|
291
|
+
logger.error("Failed to parse JSON body");
|
|
292
292
|
} else if (
|
|
293
293
|
parsed &&
|
|
294
294
|
typeof parsed === "object" &&
|
|
@@ -313,19 +313,30 @@ app.all(/(.*)/, async (req: Request, res: Response) => {
|
|
|
313
313
|
|
|
314
314
|
const port = env.SERVER_PORT;
|
|
315
315
|
|
|
316
|
+
// This file is the backend image's entrypoint, so its startup output is the
|
|
317
|
+
// first thing a log collector reads from the container. It goes through the
|
|
318
|
+
// logger for the same reason every other line does: one JSON object per line is
|
|
319
|
+
// the contract in deploy/vps/README.md, and a banner printed alongside it is a
|
|
320
|
+
// line the pipeline cannot parse.
|
|
321
|
+
//
|
|
322
|
+
// This is also the everyday `npm run dev` server, so the addresses stay whole
|
|
323
|
+
// and clickable — `url`, not a port a developer has to assemble one themselves.
|
|
316
324
|
app.listen(Number(port), "0.0.0.0", () => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
325
|
+
// biome-ignore lint/plugin/no-logger-info: the configuration a container came up on is an audit-grade signal
|
|
326
|
+
logger.info(
|
|
327
|
+
{
|
|
328
|
+
port: Number(port),
|
|
329
|
+
url: `http://localhost:${port}`,
|
|
330
|
+
dynamodbPort: env.DYNAMODB_PORT,
|
|
331
|
+
dynamodbTable: env.DYNAMODB_TABLE_NAME,
|
|
332
|
+
nodeEnv: env.NODE_ENV,
|
|
333
|
+
...(isSelfHostBackend
|
|
334
|
+
? {}
|
|
335
|
+
: { apiDocsUrl: `http://localhost:${port}/api-docs` }),
|
|
336
|
+
},
|
|
337
|
+
"Backend listening",
|
|
320
338
|
);
|
|
321
339
|
|
|
322
|
-
console.table({
|
|
323
|
-
SERVER_PORT: port,
|
|
324
|
-
DYNAMODB_PORT: env.DYNAMODB_PORT,
|
|
325
|
-
DYNAMODB_TABLE: env.DYNAMODB_TABLE_NAME,
|
|
326
|
-
NODE_ENV: env.NODE_ENV,
|
|
327
|
-
});
|
|
328
|
-
|
|
329
340
|
process.send?.("ready");
|
|
330
341
|
});
|
|
331
342
|
|
package/package.json
CHANGED
|
@@ -57,12 +57,13 @@ const run = async (): Promise<void> => {
|
|
|
57
57
|
{ checkpointStore: fileCheckpointStore, logger },
|
|
58
58
|
);
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// biome-ignore lint/plugin/no-logger-info: a completed full-corpus backfill is an audit-grade signal
|
|
61
|
+
logger.info({ result }, "backfill done");
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
run()
|
|
64
65
|
.then(() => process.exit(0))
|
|
65
66
|
.catch((error: unknown) => {
|
|
66
|
-
|
|
67
|
+
logger.error({ error }, "backfill failed");
|
|
67
68
|
process.exit(1);
|
|
68
69
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { AddressFlags } from "@remit/api-openapi-types";
|
|
4
|
+
import { deriveMuted } from "./deriveMuted.js";
|
|
5
|
+
|
|
6
|
+
const SET_AT = 1_700_000_000_000;
|
|
7
|
+
|
|
8
|
+
describe("deriveMuted", () => {
|
|
9
|
+
it("returns false when flags is undefined", () => {
|
|
10
|
+
assert.equal(deriveMuted(undefined), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns false for an empty flags object", () => {
|
|
14
|
+
assert.equal(deriveMuted({}), false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns false when muted.value is false", () => {
|
|
18
|
+
const flags: AddressFlags = { muted: { value: false, setAt: SET_AT } };
|
|
19
|
+
assert.equal(deriveMuted(flags), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns true when muted.value is true", () => {
|
|
23
|
+
const flags: AddressFlags = { muted: { value: true, setAt: SET_AT } };
|
|
24
|
+
assert.equal(deriveMuted(flags), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("ignores other flags (orthogonal axis)", () => {
|
|
28
|
+
const flags: AddressFlags = {
|
|
29
|
+
vip: { value: true, setAt: SET_AT },
|
|
30
|
+
wellknown: { value: true, setAt: SET_AT },
|
|
31
|
+
};
|
|
32
|
+
assert.equal(deriveMuted(flags), false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AddressFlags } from "@remit/api-openapi-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Derive whether the From address is muted from an Address's flags map.
|
|
5
|
+
*
|
|
6
|
+
* Pure function, no I/O. Frontend never derives this — single source of
|
|
7
|
+
* truth for filtering the daily brief.
|
|
8
|
+
*/
|
|
9
|
+
export const deriveMuted = (flags: AddressFlags | undefined): boolean =>
|
|
10
|
+
flags?.muted?.value === true;
|
|
@@ -7,11 +7,13 @@ import type {
|
|
|
7
7
|
MessageLabelItem,
|
|
8
8
|
ThreadMessageItem,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
|
+
import { deriveAddressId } from "@remit/data-ports/id";
|
|
10
11
|
import { type EnrichClient, enrichThreadRows } from "./enrichThreadRows.js";
|
|
11
12
|
|
|
12
13
|
const threadRow = (
|
|
13
14
|
threadMessageId: string,
|
|
14
15
|
messageId: string,
|
|
16
|
+
fromEmail?: string,
|
|
15
17
|
): ThreadMessageItem =>
|
|
16
18
|
({
|
|
17
19
|
threadMessageId,
|
|
@@ -19,6 +21,7 @@ const threadRow = (
|
|
|
19
21
|
messageId,
|
|
20
22
|
accountConfigId: "acc-1",
|
|
21
23
|
mailboxId: "mbx-1",
|
|
24
|
+
fromEmail,
|
|
22
25
|
sentDate: 1,
|
|
23
26
|
isRead: true,
|
|
24
27
|
hasAttachment: false,
|
|
@@ -31,9 +34,10 @@ const threadRow = (
|
|
|
31
34
|
const buildClient = (
|
|
32
35
|
messageLabels: MessageLabelItem[],
|
|
33
36
|
labels: LabelItem[],
|
|
37
|
+
addresses: AddressItem[] = [],
|
|
34
38
|
): EnrichClient => ({
|
|
35
39
|
message: { get: async () => [] as MessageItem[] },
|
|
36
|
-
address: { getAddress: async () =>
|
|
40
|
+
address: { getAddress: async () => addresses },
|
|
37
41
|
messageLabel: {
|
|
38
42
|
listByMessageIds: async (messageIds: string[]) =>
|
|
39
43
|
messageLabels.filter((row) => messageIds.includes(row.messageId)),
|
|
@@ -161,3 +165,59 @@ describe("enrichThreadRows — labels", () => {
|
|
|
161
165
|
assert.equal(second?.labels, undefined);
|
|
162
166
|
});
|
|
163
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("enrichThreadRows — muted", () => {
|
|
170
|
+
const SET_AT = 1_700_000_000_000;
|
|
171
|
+
|
|
172
|
+
test("sets muted true from the batch-fetched Address's flags, no extra query", async () => {
|
|
173
|
+
const fromEmail = "muted@example.com";
|
|
174
|
+
const addressId = deriveAddressId("acc-1", fromEmail);
|
|
175
|
+
const rows = [threadRow("tm-1", "msg-1", fromEmail)];
|
|
176
|
+
const addresses = [
|
|
177
|
+
{
|
|
178
|
+
addressId,
|
|
179
|
+
accountConfigId: "acc-1",
|
|
180
|
+
flags: { muted: { value: true, setAt: SET_AT } },
|
|
181
|
+
},
|
|
182
|
+
] as unknown as AddressItem[];
|
|
183
|
+
|
|
184
|
+
let addressCalls = 0;
|
|
185
|
+
const client: EnrichClient = {
|
|
186
|
+
message: { get: async () => [] as MessageItem[] },
|
|
187
|
+
address: {
|
|
188
|
+
getAddress: async () => {
|
|
189
|
+
addressCalls += 1;
|
|
190
|
+
return addresses;
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
messageLabel: { listByMessageIds: async () => [] },
|
|
194
|
+
label: { listByAccountConfig: async () => [] },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const [result] = await enrichThreadRows(rows, client, "acc-1");
|
|
198
|
+
assert.equal(result?.muted, true);
|
|
199
|
+
assert.equal(addressCalls, 1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("defaults muted to false when the Address has no muted flag", async () => {
|
|
203
|
+
const fromEmail = "not-muted@example.com";
|
|
204
|
+
const addressId = deriveAddressId("acc-1", fromEmail);
|
|
205
|
+
const rows = [threadRow("tm-1", "msg-1", fromEmail)];
|
|
206
|
+
const addresses = [
|
|
207
|
+
{ addressId, accountConfigId: "acc-1", flags: {} },
|
|
208
|
+
] as unknown as AddressItem[];
|
|
209
|
+
|
|
210
|
+
const [result] = await enrichThreadRows(
|
|
211
|
+
rows,
|
|
212
|
+
buildClient([], [], addresses),
|
|
213
|
+
"acc-1",
|
|
214
|
+
);
|
|
215
|
+
assert.equal(result?.muted, false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("defaults muted to false when no Address row resolves", async () => {
|
|
219
|
+
const rows = [threadRow("tm-1", "msg-1")];
|
|
220
|
+
const [result] = await enrichThreadRows(rows, buildClient([], []), "acc-1");
|
|
221
|
+
assert.equal(result?.muted, false);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
10
10
|
import { SenderTrust, StarColor } from "@remit/domain-enums";
|
|
11
11
|
import { deriveAutoMoved } from "./autoMoved.js";
|
|
12
|
+
import { deriveMuted } from "./deriveMuted.js";
|
|
12
13
|
import { deriveSenderTrust } from "./senderTrust.js";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -54,6 +55,7 @@ const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
|
|
|
54
55
|
createdAt: item.createdAt,
|
|
55
56
|
updatedAt: item.updatedAt,
|
|
56
57
|
senderTrust: SenderTrust.Unknown,
|
|
58
|
+
muted: false,
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
/**
|
|
@@ -98,20 +100,22 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
|
|
|
98
100
|
};
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
|
-
* Enrich a page of ThreadMessage rows with `senderTrust`
|
|
102
|
-
* Address's flags map), `authenticity` and `autoMoved`
|
|
103
|
-
* Message row, see `deriveAutoMoved`).
|
|
103
|
+
* Enrich a page of ThreadMessage rows with `senderTrust` and `muted` (both
|
|
104
|
+
* derived from the From Address's flags map), `authenticity` and `autoMoved`
|
|
105
|
+
* (both projected from the Message row, see `deriveAutoMoved`).
|
|
104
106
|
*
|
|
105
|
-
* `category` is not enriched: it is denormalized onto the ThreadMessage row
|
|
106
|
-
*
|
|
107
|
-
*
|
|
107
|
+
* `category` is not enriched: it is denormalized onto the ThreadMessage row
|
|
108
|
+
* (shared with `Message.category`'s write-once value, see body-sync.ts) and
|
|
109
|
+
* carried straight through by `toResponse`, so the value a client renders is
|
|
110
|
+
* the value the category filter matched.
|
|
108
111
|
*
|
|
109
112
|
* Two BatchGetItem calls per page, regardless of page size — see
|
|
110
113
|
* `planBatchFetch` for the dedup contract.
|
|
111
114
|
*
|
|
112
|
-
* Missing rows fall back gracefully: `senderTrust` defaults to `"unknown"`,
|
|
113
|
-
* `authenticity` / `autoMoved` are omitted
|
|
114
|
-
* the move isn't a real, in-effect
|
|
115
|
+
* Missing rows fall back gracefully: `senderTrust` defaults to `"unknown"`,
|
|
116
|
+
* `muted` defaults to `false`, and `authenticity` / `autoMoved` are omitted
|
|
117
|
+
* whenever the Message row is absent or the move isn't a real, in-effect
|
|
118
|
+
* auto-move.
|
|
115
119
|
*
|
|
116
120
|
* Not annotated `Promise<ThreadMessageResponse[]>`: `labels` is a new field on
|
|
117
121
|
* it in this same PR, and that package publishes separately from this repo —
|
|
@@ -169,6 +173,9 @@ export const enrichThreadRows = async (
|
|
|
169
173
|
const trustByAddressId = new Map(
|
|
170
174
|
addresses.map((a) => [a.addressId, deriveSenderTrust(a.flags)]),
|
|
171
175
|
);
|
|
176
|
+
const mutedByAddressId = new Map(
|
|
177
|
+
addresses.map((a) => [a.addressId, deriveMuted(a.flags)]),
|
|
178
|
+
);
|
|
172
179
|
|
|
173
180
|
return rows.map((row) => {
|
|
174
181
|
const base = toResponse(row);
|
|
@@ -178,6 +185,9 @@ export const enrichThreadRows = async (
|
|
|
178
185
|
const senderTrust = addressId
|
|
179
186
|
? (trustByAddressId.get(addressId) ?? SenderTrust.Unknown)
|
|
180
187
|
: SenderTrust.Unknown;
|
|
188
|
+
const muted = addressId
|
|
189
|
+
? (mutedByAddressId.get(addressId) ?? false)
|
|
190
|
+
: false;
|
|
181
191
|
const labels = labelsByMessageId.get(row.messageId);
|
|
182
192
|
return {
|
|
183
193
|
...base,
|
|
@@ -185,6 +195,7 @@ export const enrichThreadRows = async (
|
|
|
185
195
|
...(autoMoved !== undefined ? { autoMoved } : {}),
|
|
186
196
|
...(labels !== undefined ? { labels } : {}),
|
|
187
197
|
senderTrust,
|
|
198
|
+
muted,
|
|
188
199
|
};
|
|
189
200
|
});
|
|
190
201
|
};
|
package/src/index.ts
CHANGED
|
@@ -144,11 +144,11 @@ const readOriginHeader = (
|
|
|
144
144
|
// A scope, not `logger.setBindings`: this process serves requests concurrently,
|
|
145
145
|
// and bindings on the shared logger belong to whichever request wrote them last,
|
|
146
146
|
// so a line gets attributed to the wrong request. The scope follows the request
|
|
147
|
-
// through its own async continuations and nothing else.
|
|
147
|
+
// through its own async continuations and nothing else. It nests inside the one
|
|
148
|
+
// `withTelemetry` opens, which is where `requestId` comes from.
|
|
148
149
|
const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
|
|
149
150
|
withLogContext(
|
|
150
151
|
{
|
|
151
|
-
requestId: context.awsRequestId,
|
|
152
152
|
path: event.path,
|
|
153
153
|
method: event.httpMethod,
|
|
154
154
|
},
|
package/tsconfig.json
CHANGED
|
@@ -3,5 +3,16 @@
|
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"outDir": "dist"
|
|
5
5
|
},
|
|
6
|
-
"include": [
|
|
6
|
+
"include": [
|
|
7
|
+
"src/**/*.ts",
|
|
8
|
+
"dev-server/**/*.ts",
|
|
9
|
+
"scripts/**/*.ts",
|
|
10
|
+
// The backend image's third entrypoint. `migrate.mjs` and
|
|
11
|
+
// `backfill-list-id.mjs` ship inside this image alongside `server.mjs`
|
|
12
|
+
// (npm-scripts/docker-bundle.mjs), so they belong to the same typecheck —
|
|
13
|
+
// living under deploy/ is where it sits in the tree, not a different
|
|
14
|
+
// deliverable. Unchecked, it shipped a container entrypoint with two hard
|
|
15
|
+
// type errors.
|
|
16
|
+
"../../deploy/vps/migrate/**/*.ts"
|
|
17
|
+
]
|
|
7
18
|
}
|