@remit/backend 0.0.36 → 0.0.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,163 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ AddressItem,
5
+ LabelItem,
6
+ MessageItem,
7
+ MessageLabelItem,
8
+ ThreadMessageItem,
9
+ } from "@remit/data-ports";
10
+ import { type EnrichClient, enrichThreadRows } from "./enrichThreadRows.js";
11
+
12
+ const threadRow = (
13
+ threadMessageId: string,
14
+ messageId: string,
15
+ ): ThreadMessageItem =>
16
+ ({
17
+ threadMessageId,
18
+ threadId: "th-1",
19
+ messageId,
20
+ accountConfigId: "acc-1",
21
+ mailboxId: "mbx-1",
22
+ sentDate: 1,
23
+ isRead: true,
24
+ hasAttachment: false,
25
+ hasStars: false,
26
+ isDeleted: false,
27
+ createdAt: 1,
28
+ updatedAt: 1,
29
+ }) as unknown as ThreadMessageItem;
30
+
31
+ const buildClient = (
32
+ messageLabels: MessageLabelItem[],
33
+ labels: LabelItem[],
34
+ ): EnrichClient => ({
35
+ message: { get: async () => [] as MessageItem[] },
36
+ address: { getAddress: async () => [] as AddressItem[] },
37
+ messageLabel: {
38
+ listByMessageIds: async (messageIds: string[]) =>
39
+ messageLabels.filter((row) => messageIds.includes(row.messageId)),
40
+ },
41
+ label: {
42
+ listByAccountConfig: async () => labels,
43
+ },
44
+ });
45
+
46
+ describe("enrichThreadRows — labels", () => {
47
+ test("attaches every label applied to a message", async () => {
48
+ const rows = [threadRow("tm-1", "msg-1")];
49
+ const messageLabels = [
50
+ {
51
+ messageLabelId: "ml-1",
52
+ messageId: "msg-1",
53
+ labelId: "lbl-1",
54
+ accountConfigId: "acc-1",
55
+ createdAt: 1,
56
+ updatedAt: 1,
57
+ },
58
+ {
59
+ messageLabelId: "ml-2",
60
+ messageId: "msg-1",
61
+ labelId: "lbl-2",
62
+ accountConfigId: "acc-1",
63
+ createdAt: 1,
64
+ updatedAt: 1,
65
+ },
66
+ ] as unknown as MessageLabelItem[];
67
+ const labels = [
68
+ {
69
+ labelId: "lbl-1",
70
+ accountConfigId: "acc-1",
71
+ name: "Receipts",
72
+ normalizedName: "receipts",
73
+ color: "Blue",
74
+ createdAt: 1,
75
+ updatedAt: 1,
76
+ },
77
+ {
78
+ labelId: "lbl-2",
79
+ accountConfigId: "acc-1",
80
+ name: "Travel",
81
+ normalizedName: "travel",
82
+ color: "Green",
83
+ createdAt: 1,
84
+ updatedAt: 1,
85
+ },
86
+ ] as unknown as LabelItem[];
87
+
88
+ const [result] = await enrichThreadRows(
89
+ rows,
90
+ buildClient(messageLabels, labels),
91
+ "acc-1",
92
+ );
93
+
94
+ assert.deepEqual(result?.labels?.map((l) => l.labelId).sort(), [
95
+ "lbl-1",
96
+ "lbl-2",
97
+ ]);
98
+ assert.deepEqual(
99
+ result?.labels?.find((l) => l.labelId === "lbl-1"),
100
+ { labelId: "lbl-1", name: "Receipts", color: "Blue" },
101
+ );
102
+ });
103
+
104
+ test("omits labels entirely when the message has none", async () => {
105
+ const rows = [threadRow("tm-1", "msg-1")];
106
+ const [result] = await enrichThreadRows(rows, buildClient([], []), "acc-1");
107
+ assert.equal(result?.labels, undefined);
108
+ });
109
+
110
+ test("skips a MessageLabel row whose Label was deleted out from under it", async () => {
111
+ const rows = [threadRow("tm-1", "msg-1")];
112
+ const messageLabels = [
113
+ {
114
+ messageLabelId: "ml-1",
115
+ messageId: "msg-1",
116
+ labelId: "gone",
117
+ accountConfigId: "acc-1",
118
+ createdAt: 1,
119
+ updatedAt: 1,
120
+ },
121
+ ] as unknown as MessageLabelItem[];
122
+
123
+ const [result] = await enrichThreadRows(
124
+ rows,
125
+ buildClient(messageLabels, []),
126
+ "acc-1",
127
+ );
128
+ assert.equal(result?.labels, undefined);
129
+ });
130
+
131
+ test("never cross-wires labels between rows for different messages", async () => {
132
+ const rows = [threadRow("tm-1", "msg-1"), threadRow("tm-2", "msg-2")];
133
+ const messageLabels = [
134
+ {
135
+ messageLabelId: "ml-1",
136
+ messageId: "msg-1",
137
+ labelId: "lbl-1",
138
+ accountConfigId: "acc-1",
139
+ createdAt: 1,
140
+ updatedAt: 1,
141
+ },
142
+ ] as unknown as MessageLabelItem[];
143
+ const labels = [
144
+ {
145
+ labelId: "lbl-1",
146
+ accountConfigId: "acc-1",
147
+ name: "Receipts",
148
+ normalizedName: "receipts",
149
+ color: "Blue",
150
+ createdAt: 1,
151
+ updatedAt: 1,
152
+ },
153
+ ] as unknown as LabelItem[];
154
+
155
+ const [first, second] = await enrichThreadRows(
156
+ rows,
157
+ buildClient(messageLabels, labels),
158
+ "acc-1",
159
+ );
160
+ assert.equal(first?.labels?.length, 1);
161
+ assert.equal(second?.labels, undefined);
162
+ });
163
+ });
@@ -1,7 +1,9 @@
1
1
  import type { ThreadMessageResponse } from "@remit/api-openapi-types";
2
2
  import type {
3
3
  AddressItem,
4
+ LabelItem,
4
5
  MessageItem,
6
+ MessageLabelItem,
5
7
  ThreadMessageItem,
6
8
  } from "@remit/data-ports";
7
9
  import { deriveAddressId } from "@remit/data-ports/id";
@@ -24,6 +26,12 @@ export interface EnrichClient {
24
26
  addressIds: string[],
25
27
  ): Promise<AddressItem[]>;
26
28
  };
29
+ messageLabel: {
30
+ listByMessageIds(messageIds: string[]): Promise<MessageLabelItem[]>;
31
+ };
32
+ label: {
33
+ listByAccountConfig(accountConfigId: string): Promise<LabelItem[]>;
34
+ };
27
35
  }
28
36
 
29
37
  const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
@@ -102,23 +110,54 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
102
110
  * Message with no stored category coalesces to `uncategorized` (RFC 032 Tier 2).
103
111
  * `senderTrust` defaults to `"unknown"`. `autoMoved` is omitted whenever the
104
112
  * move isn't a real, in-effect auto-move (or the Message row is absent).
113
+ *
114
+ * Not annotated `Promise<ThreadMessageResponse[]>`: `labels` is a new field on
115
+ * it in this same PR, and that package publishes separately from this repo —
116
+ * an explicit annotation here would contextually type the literal below
117
+ * against the currently-*published* shape (which lacks `labels`) and fail
118
+ * `check-consumer-typecheck`/`release:dry-run`. Callers assign the (wider,
119
+ * inferred) result into an already-typed `ThreadMessageResponse[]` property,
120
+ * which is a plain assignability check, not an excess-property one.
105
121
  */
106
122
  export const enrichThreadRows = async (
107
123
  rows: ThreadMessageItem[],
108
124
  client: EnrichClient,
109
125
  accountConfigId: string,
110
- ): Promise<ThreadMessageResponse[]> => {
126
+ ) => {
111
127
  if (rows.length === 0) return [];
112
128
 
113
129
  const plan = planBatchFetch(rows);
114
130
 
115
- const [messages, addresses] = await Promise.all([
131
+ const [messages, addresses, messageLabels, labels] = await Promise.all([
116
132
  plan.messageIds.length ? client.message.get(plan.messageIds) : [],
117
133
  plan.addressIds.length
118
134
  ? client.address.getAddress(accountConfigId, plan.addressIds)
119
135
  : [],
136
+ client.messageLabel.listByMessageIds(plan.messageIds),
137
+ client.label.listByAccountConfig(accountConfigId),
120
138
  ]);
121
139
 
140
+ const labelById = new Map(labels.map((label) => [label.labelId, label]));
141
+ const labelsByMessageId = new Map<
142
+ string,
143
+ { labelId: string; name: string; color: LabelItem["color"] }[]
144
+ >();
145
+ for (const messageLabel of messageLabels) {
146
+ const label = labelById.get(messageLabel.labelId);
147
+ if (!label) continue;
148
+ const entry = {
149
+ labelId: label.labelId,
150
+ name: label.name,
151
+ color: label.color,
152
+ };
153
+ const existing = labelsByMessageId.get(messageLabel.messageId);
154
+ if (existing) {
155
+ existing.push(entry);
156
+ } else {
157
+ labelsByMessageId.set(messageLabel.messageId, [entry]);
158
+ }
159
+ }
160
+
122
161
  const categoryByMessageId = new Map(
123
162
  messages.map((m) => [
124
163
  m.messageId,
@@ -144,11 +183,13 @@ export const enrichThreadRows = async (
144
183
  const senderTrust = addressId
145
184
  ? (trustByAddressId.get(addressId) ?? SenderTrust.Unknown)
146
185
  : SenderTrust.Unknown;
186
+ const labels = labelsByMessageId.get(row.messageId);
147
187
  return {
148
188
  ...base,
149
189
  ...(category !== undefined ? { category } : {}),
150
190
  ...(authenticity !== undefined ? { authenticity } : {}),
151
191
  ...(autoMoved !== undefined ? { autoMoved } : {}),
192
+ ...(labels !== undefined ? { labels } : {}),
152
193
  senderTrust,
153
194
  };
154
195
  });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import type { UpdateFilterInput } from "@remit/data-ports";
3
+ import type { UpdateFilterInput as UpdateFilterRequestBody } from "@remit/api-openapi-types";
4
4
  import { FilterScope, FilterState } from "@remit/domain-enums";
5
5
  import {
6
6
  pickFilterUpdate,
@@ -35,7 +35,7 @@ describe("pickFilterUpdate", () => {
35
35
  ruleChangedAt: 999,
36
36
  filterId: "sneaky",
37
37
  };
38
- const patch = pickFilterUpdate(raw as Partial<UpdateFilterInput>);
38
+ const patch = pickFilterUpdate(raw as Partial<UpdateFilterRequestBody>);
39
39
  assert.deepEqual(patch, { name: "Receipts" });
40
40
  });
41
41
  });
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CreateFilterInput,
3
3
  FilterResponse,
4
+ UpdateFilterInput as UpdateFilterRequestBody,
4
5
  } from "@remit/api-openapi-types";
5
6
  import type { FilterItem, UpdateFilterInput } from "@remit/data-ports";
6
7
  import { BadRequestError } from "@remit/data-ports/errors";
@@ -93,19 +94,9 @@ export const deriveFilterTtl = (
93
94
  * dropped. `scope`/`expiresAt` land here as the caller sent them; a patch that
94
95
  * touches either still needs `resolveFilterScopeExpiry` to merge them against
95
96
  * the stored row and derive `ttl`/`state` before this reaches the repo.
96
- *
97
- * Typed against the internal `@remit/data-ports` `UpdateFilterInput`, not the
98
- * generated `@remit/api-openapi-types` one: that package publishes separately
99
- * from this repo, so a PR that both widens the TypeSpec model and reads the
100
- * new field in the same change would typecheck in-tree (fresh local codegen)
101
- * but fail `check-consumer-typecheck`/`release:dry-run`, which resolve
102
- * generated `@remit/*` packages off the registry, not the local `build/`
103
- * output. The data-ports shape carries every field this reads regardless —
104
- * it derives from the full `Filter` row, which has never gated `scope` or
105
- * `expiresAt` behind an API-visibility distinction.
106
97
  */
107
98
  export const pickFilterUpdate = (
108
- body: Partial<UpdateFilterInput>,
99
+ body: Partial<UpdateFilterRequestBody>,
109
100
  ): Partial<UpdateFilterInput> => {
110
101
  const patch: Partial<UpdateFilterInput> = {};
111
102
  if (Object.hasOwn(body, "name")) patch.name = body.name;
@@ -368,7 +359,7 @@ export const FilterDetailOperations: Record<
368
359
  assertAccountOwnership(account, accountConfigId, "act");
369
360
 
370
361
  const { filter } = client;
371
- const patch = pickFilterUpdate(body as Partial<UpdateFilterInput>);
362
+ const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
372
363
  const touchesScopeOrExpiry =
373
364
  Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
374
365
  const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
@@ -5,6 +5,7 @@ import { AddressDetailOperations, AddressOperations } from "./address.js";
5
5
  import { ConfigOperations } from "./config.js";
6
6
  import { FilterDetailOperations, FilterOperations } from "./filter.js";
7
7
  import { FolderRoleOperations } from "./folder-role.js";
8
+ import { LabelDetailOperations, LabelOperations } from "./label.js";
8
9
  import {
9
10
  MailboxDetailOperations,
10
11
  MailboxOperations,
@@ -33,6 +34,8 @@ export const handlers: Record<OperationIds, OperationHandler<any>> = {
33
34
  ...FolderRoleOperations,
34
35
  ...FilterOperations,
35
36
  ...FilterDetailOperations,
37
+ ...LabelOperations,
38
+ ...LabelDetailOperations,
36
39
  ...OrganizeOperations,
37
40
  ...OrganizeJobDetailOperations,
38
41
  ...TrashOperations,
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type { FilterItem, LabelItem } from "@remit/data-ports";
4
+ import {
5
+ deleteLabelWithCascade,
6
+ findFiltersForLabel,
7
+ type LabelCrudDeps,
8
+ } from "./label.js";
9
+
10
+ const filter = (filterId: string, actionLabelId: string): FilterItem =>
11
+ ({ filterId, actionLabelId }) as unknown as FilterItem;
12
+
13
+ const label = (labelId: string): LabelItem =>
14
+ ({
15
+ labelId,
16
+ accountConfigId: "acc-1",
17
+ name: "x",
18
+ color: "Default",
19
+ }) as LabelItem;
20
+
21
+ const buildDeps = (filters: FilterItem[]) => {
22
+ const deletedFilterIds: string[] = [];
23
+ const removedLabelIds: string[] = [];
24
+ const deletedLabelIds: string[] = [];
25
+
26
+ const deps: LabelCrudDeps = {
27
+ label: {
28
+ create: async () => label("unused"),
29
+ get: async (_accountConfigId: string, labelId: string) => label(labelId),
30
+ update: async (_accountConfigId: string, labelId: string) =>
31
+ label(labelId),
32
+ delete: async (_accountConfigId: string, labelId: string) => {
33
+ deletedLabelIds.push(labelId);
34
+ },
35
+ listPageByAccountConfig: async () => ({
36
+ items: [],
37
+ continuationToken: undefined,
38
+ }),
39
+ },
40
+ filter: {
41
+ listByAccountConfig: async () => filters,
42
+ delete: async (_accountConfigId: string, filterId: string) => {
43
+ deletedFilterIds.push(filterId);
44
+ },
45
+ },
46
+ messageLabel: {
47
+ removeAllByLabelId: async (_accountConfigId: string, labelId: string) => {
48
+ removedLabelIds.push(labelId);
49
+ },
50
+ },
51
+ };
52
+
53
+ return { deps, deletedFilterIds, removedLabelIds, deletedLabelIds };
54
+ };
55
+
56
+ describe("findFiltersForLabel", () => {
57
+ test("returns only the filters whose actionLabelId matches", async () => {
58
+ const filters = [
59
+ filter("f1", "label-1"),
60
+ filter("f2", "label-2"),
61
+ filter("f3", "label-1"),
62
+ ];
63
+ const { deps } = buildDeps(filters);
64
+
65
+ const found = await findFiltersForLabel(deps, "acc-1", "label-1");
66
+ assert.deepEqual(found.map((f) => f.filterId).sort(), ["f1", "f3"]);
67
+ });
68
+
69
+ test("returns an empty list when no filter uses the label", async () => {
70
+ const { deps } = buildDeps([filter("f1", "label-2")]);
71
+ const found = await findFiltersForLabel(deps, "acc-1", "label-1");
72
+ assert.deepEqual(found, []);
73
+ });
74
+ });
75
+
76
+ describe("deleteLabelWithCascade", () => {
77
+ test("deletes every filter referencing the label, clears applied messages, then deletes the label", async () => {
78
+ const filters = [
79
+ filter("f1", "label-1"),
80
+ filter("f2", "label-2"),
81
+ filter("f3", "label-1"),
82
+ ];
83
+ const { deps, deletedFilterIds, removedLabelIds, deletedLabelIds } =
84
+ buildDeps(filters);
85
+
86
+ await deleteLabelWithCascade(deps, "acc-1", "label-1");
87
+
88
+ assert.deepEqual(deletedFilterIds.sort(), ["f1", "f3"]);
89
+ assert.deepEqual(removedLabelIds, ["label-1"]);
90
+ assert.deepEqual(deletedLabelIds, ["label-1"]);
91
+ });
92
+
93
+ test("never touches a filter that references a different label", async () => {
94
+ const filters = [filter("f1", "label-2")];
95
+ const { deps, deletedFilterIds } = buildDeps(filters);
96
+
97
+ await deleteLabelWithCascade(deps, "acc-1", "label-1");
98
+
99
+ assert.deepEqual(deletedFilterIds, []);
100
+ });
101
+
102
+ test("is a no-op on the filter/messageLabel side when nothing references the label", async () => {
103
+ const { deps, deletedFilterIds, removedLabelIds, deletedLabelIds } =
104
+ buildDeps([]);
105
+
106
+ await deleteLabelWithCascade(deps, "acc-1", "label-1");
107
+
108
+ assert.deepEqual(deletedFilterIds, []);
109
+ assert.deepEqual(removedLabelIds, ["label-1"]);
110
+ assert.deepEqual(deletedLabelIds, ["label-1"]);
111
+ });
112
+ });
@@ -0,0 +1,210 @@
1
+ import type {
2
+ CreateLabelInput as CreateLabelRequestBody,
3
+ LabelResponse,
4
+ UpdateLabelInput as UpdateLabelRequestBody,
5
+ } from "@remit/api-openapi-types";
6
+ import type {
7
+ CreateLabelInput,
8
+ FilterItem,
9
+ LabelItem,
10
+ UpdateLabelInput,
11
+ } from "@remit/data-ports";
12
+ import type { APIGatewayProxyEvent } from "aws-lambda";
13
+ import { getAccountConfigIdFromEvent } from "../auth.js";
14
+ import { getClient } from "../service/dynamodb.js";
15
+ import type {
16
+ LabelDetailOperationIds,
17
+ LabelOperationIds,
18
+ OperationHandler,
19
+ } from "../types.js";
20
+ import { assertAccountOwnership } from "./account-ownership.js";
21
+
22
+ /**
23
+ * Minimal label-service surface the CRUD handlers need — declared as a `Pick`
24
+ * so tests can stub it without a live table.
25
+ */
26
+ export interface LabelCrudDeps {
27
+ label: {
28
+ create(input: CreateLabelInput): Promise<LabelItem>;
29
+ get(accountConfigId: string, labelId: string): Promise<LabelItem>;
30
+ update(
31
+ accountConfigId: string,
32
+ labelId: string,
33
+ input: UpdateLabelInput,
34
+ ): Promise<LabelItem>;
35
+ delete(accountConfigId: string, labelId: string): Promise<void>;
36
+ listPageByAccountConfig(
37
+ accountConfigId: string,
38
+ options?: { limit?: number; continuationToken?: string },
39
+ ): Promise<{ items: LabelItem[]; continuationToken: string | undefined }>;
40
+ };
41
+ filter: {
42
+ listByAccountConfig(accountConfigId: string): Promise<FilterItem[]>;
43
+ delete(accountConfigId: string, filterId: string): Promise<void>;
44
+ };
45
+ messageLabel: {
46
+ removeAllByLabelId(accountConfigId: string, labelId: string): Promise<void>;
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Every filter in the account whose action applies this label (issue #26). The
52
+ * count backs `LabelResponse.filterCount`; the list backs the cascade delete.
53
+ */
54
+ export const findFiltersForLabel = async (
55
+ deps: Pick<LabelCrudDeps, "filter">,
56
+ accountConfigId: string,
57
+ labelId: string,
58
+ ): Promise<FilterItem[]> => {
59
+ const filters = await deps.filter.listByAccountConfig(accountConfigId);
60
+ return filters.filter((filter) => filter.actionLabelId === labelId);
61
+ };
62
+
63
+ /**
64
+ * `filterCount` is computed here, not stored (issue #26) — every other field
65
+ * comes straight off the `LabelItem` row.
66
+ */
67
+ const toLabelResponse = (
68
+ item: LabelItem,
69
+ filterCount: number,
70
+ ): LabelResponse => ({
71
+ labelId: item.labelId,
72
+ accountConfigId: item.accountConfigId,
73
+ name: item.name,
74
+ color: item.color,
75
+ filterCount,
76
+ createdAt: item.createdAt,
77
+ updatedAt: item.updatedAt,
78
+ });
79
+
80
+ /**
81
+ * Delete a label and cascade in both directions (issue #26): every filter
82
+ * whose `actionLabelId` is this label is deleted outright — never left
83
+ * dangling, never blocking the delete — and every `MessageLabel` row for it is
84
+ * removed. The label row itself is deleted last so a failure mid-cascade never
85
+ * leaves the label gone while its filters or applied-to-messages still exist.
86
+ */
87
+ export const deleteLabelWithCascade = async (
88
+ deps: LabelCrudDeps,
89
+ accountConfigId: string,
90
+ labelId: string,
91
+ ): Promise<void> => {
92
+ const filters = await findFiltersForLabel(deps, accountConfigId, labelId);
93
+ for (const filter of filters) {
94
+ await deps.filter.delete(accountConfigId, filter.filterId);
95
+ }
96
+ await deps.messageLabel.removeAllByLabelId(accountConfigId, labelId);
97
+ await deps.label.delete(accountConfigId, labelId);
98
+ };
99
+
100
+ export const LabelOperations: Record<
101
+ LabelOperationIds,
102
+ OperationHandler<LabelOperationIds>
103
+ > = {
104
+ LabelOperations_listLabels: async (context, ...args: unknown[]) => {
105
+ const event = args[0] as APIGatewayProxyEvent;
106
+ const accountConfigId = getAccountConfigIdFromEvent(event);
107
+ const { accountId } = context.request.params as { accountId: string };
108
+ const { continuationToken } = context.request.query as {
109
+ continuationToken?: string;
110
+ };
111
+
112
+ const client = await getClient();
113
+ const account = await client.account.get(accountId);
114
+ assertAccountOwnership(account, accountConfigId, "read");
115
+
116
+ const page = await client.label.listPageByAccountConfig(accountConfigId, {
117
+ continuationToken,
118
+ });
119
+ const filters = await client.filter.listByAccountConfig(accountConfigId);
120
+ const filterCountByLabelId = new Map<string, number>();
121
+ for (const filter of filters) {
122
+ const labelId = filter.actionLabelId;
123
+ filterCountByLabelId.set(
124
+ labelId,
125
+ (filterCountByLabelId.get(labelId) ?? 0) + 1,
126
+ );
127
+ }
128
+
129
+ return {
130
+ items: page.items.map((item) =>
131
+ toLabelResponse(item, filterCountByLabelId.get(item.labelId) ?? 0),
132
+ ),
133
+ continuationToken: page.continuationToken,
134
+ };
135
+ },
136
+
137
+ LabelOperations_createLabel: async (context, ...args: unknown[]) => {
138
+ const event = args[0] as APIGatewayProxyEvent;
139
+ const accountConfigId = getAccountConfigIdFromEvent(event);
140
+ const { accountId } = context.request.params as { accountId: string };
141
+ const input = context.request.requestBody as CreateLabelRequestBody;
142
+
143
+ const client = await getClient();
144
+ const account = await client.account.get(accountId);
145
+ assertAccountOwnership(account, accountConfigId, "act");
146
+
147
+ const label = await client.label.create({
148
+ accountConfigId,
149
+ name: input.name,
150
+ color: input.color,
151
+ });
152
+ return toLabelResponse(label, 0);
153
+ },
154
+ };
155
+
156
+ export const LabelDetailOperations: Record<
157
+ LabelDetailOperationIds,
158
+ OperationHandler<LabelDetailOperationIds>
159
+ > = {
160
+ LabelDetailOperations_getLabel: async (context, ...args: unknown[]) => {
161
+ const event = args[0] as APIGatewayProxyEvent;
162
+ const accountConfigId = getAccountConfigIdFromEvent(event);
163
+ const { accountId, labelId } = context.request.params as {
164
+ accountId: string;
165
+ labelId: string;
166
+ };
167
+
168
+ const client = await getClient();
169
+ const account = await client.account.get(accountId);
170
+ assertAccountOwnership(account, accountConfigId, "read");
171
+
172
+ const label = await client.label.get(accountConfigId, labelId);
173
+ const filters = await findFiltersForLabel(client, accountConfigId, labelId);
174
+ return toLabelResponse(label, filters.length);
175
+ },
176
+
177
+ LabelDetailOperations_updateLabel: async (context, ...args: unknown[]) => {
178
+ const event = args[0] as APIGatewayProxyEvent;
179
+ const accountConfigId = getAccountConfigIdFromEvent(event);
180
+ const { accountId, labelId } = context.request.params as {
181
+ accountId: string;
182
+ labelId: string;
183
+ };
184
+ const body = context.request.requestBody as Partial<UpdateLabelRequestBody>;
185
+
186
+ const client = await getClient();
187
+ const account = await client.account.get(accountId);
188
+ assertAccountOwnership(account, accountConfigId, "act");
189
+
190
+ const updated = await client.label.update(accountConfigId, labelId, body);
191
+ const filters = await findFiltersForLabel(client, accountConfigId, labelId);
192
+ return toLabelResponse(updated, filters.length);
193
+ },
194
+
195
+ LabelDetailOperations_deleteLabel: async (context, ...args: unknown[]) => {
196
+ const event = args[0] as APIGatewayProxyEvent;
197
+ const accountConfigId = getAccountConfigIdFromEvent(event);
198
+ const { accountId, labelId } = context.request.params as {
199
+ accountId: string;
200
+ labelId: string;
201
+ };
202
+
203
+ const client = await getClient();
204
+ const account = await client.account.get(accountId);
205
+ assertAccountOwnership(account, accountConfigId, "act");
206
+
207
+ await deleteLabelWithCascade(client, accountConfigId, labelId);
208
+ return { statusCode: 204 };
209
+ },
210
+ };
@@ -2,7 +2,6 @@ import type {
2
2
  BodyPartResponse,
3
3
  EnvelopeAddressResponse,
4
4
  EnvelopeResponse,
5
- MessageSummaryResponse,
6
5
  } from "@remit/api-openapi-types";
7
6
  import type { MailboxItem } from "@remit/data-ports";
8
7
  import {
@@ -351,7 +350,26 @@ export const MessageOperations: Record<
351
350
  const envelope = description.envelope[0];
352
351
 
353
352
  const autoMoved = deriveAutoMoved(message);
354
- const messageSummary: MessageSummaryResponse = {
353
+
354
+ // Labels applied to this message (issue #26) — filter-, organize-, and
355
+ // manually-applied alike. A message rarely carries more than a handful, so
356
+ // a `get` per label id (not a batch fetch) is the whole cost.
357
+ const messageLabelRows = await client.messageLabel.listByMessageId(
358
+ message.messageId,
359
+ );
360
+ const labels = await Promise.all(
361
+ messageLabelRows.map(async (row) => {
362
+ const label = await client.label.get(accountConfigId, row.labelId);
363
+ return { labelId: label.labelId, name: label.name, color: label.color };
364
+ }),
365
+ );
366
+
367
+ // Not typed against the generated `MessageSummaryResponse`: `labels` is a
368
+ // new field on it in this same PR, and that package publishes separately
369
+ // from this repo — a local-codegen-only field would fail
370
+ // `check-consumer-typecheck`/`release:dry-run`, which resolve generated
371
+ // `@remit/*` packages off the registry, not the local `build/` output.
372
+ const messageSummary = {
355
373
  messageId: message.messageId,
356
374
  mailboxId: message.mailboxId,
357
375
  uid: message.uid,
@@ -360,6 +378,7 @@ export const MessageOperations: Record<
360
378
  messageIdHeader: message.messageIdHeader,
361
379
  authenticity: message.authenticity,
362
380
  ...(autoMoved ? { autoMoved } : {}),
381
+ ...(labels.length > 0 ? { labels } : {}),
363
382
  };
364
383
 
365
384
  // Batch-fetch the resolved Address rows so each EnvelopeAddressResponse can
@@ -883,4 +902,49 @@ export const MessageBulkOperations: Record<
883
902
  failureCount: 0,
884
903
  };
885
904
  },
905
+
906
+ MessageBulkOperations_updateMessageLabels: async (
907
+ context,
908
+ ...args: unknown[]
909
+ ) => {
910
+ const event = args[0] as APIGatewayProxyEvent;
911
+ const accountConfigId = getAccountConfigIdFromEvent(event);
912
+ const { messageIds, labelId, action } = context.request.requestBody as {
913
+ messageIds: string[];
914
+ labelId: string;
915
+ action: "Apply" | "Remove";
916
+ };
917
+
918
+ if (messageIds.length === 0) {
919
+ return { successCount: 0, failureCount: 0 };
920
+ }
921
+
922
+ const client = await getClient();
923
+ await assertMessagesOwned(client, messageIds, accountConfigId, "act");
924
+
925
+ // Scoped by accountConfigId (RFC 030) — a foreign labelId 404s here rather
926
+ // than silently applying to messages under someone else's label.
927
+ await client.label.get(accountConfigId, labelId);
928
+
929
+ // `appliedByFilterId` stays absent — this is the manual "just these" scope
930
+ // (RFC 034 Decision 3.3), never filter-attributed.
931
+ if (action === "Apply") {
932
+ for (const messageId of messageIds) {
933
+ await client.messageLabel.apply({
934
+ accountConfigId,
935
+ messageId,
936
+ labelId,
937
+ });
938
+ }
939
+ } else {
940
+ for (const messageId of messageIds) {
941
+ await client.messageLabel.remove(messageId, labelId);
942
+ }
943
+ }
944
+
945
+ return {
946
+ successCount: messageIds.length,
947
+ failureCount: 0,
948
+ };
949
+ },
886
950
  };
@@ -28,10 +28,7 @@ const build = (): BuildFilterAnchor => {
28
28
  const embedder = buildEmbeddingServiceFromEnv();
29
29
  const store = buildVectorStoreFromEnv(embedder.dimensions);
30
30
  return (accountConfigId, anchorMessageId) =>
31
- buildMessageAnchor(
32
- { store, embedder },
33
- { accountConfigId, anchorMessageId },
34
- );
31
+ buildMessageAnchor({ store }, { accountConfigId, anchorMessageId });
35
32
  };
36
33
 
37
34
  export const buildFilterAnchor: BuildFilterAnchor = (
@@ -341,10 +341,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
341
341
  const store = buildVectorStoreFromEnv(embedder.dimensions);
342
342
  cachedSemantic = {
343
343
  buildAnchor: (accountConfigId, anchorMessageId) =>
344
- buildMessageAnchor(
345
- { store, embedder },
346
- { accountConfigId, anchorMessageId },
347
- ),
344
+ buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
348
345
  vectorStore: store,
349
346
  };
350
347
  return cachedSemantic;
package/src/types.ts CHANGED
@@ -31,6 +31,11 @@ export type OperationIds =
31
31
  | "FilterDetailOperations_getFilter"
32
32
  | "FilterDetailOperations_updateFilter"
33
33
  | "FilterDetailOperations_deleteFilter"
34
+ | "LabelOperations_listLabels"
35
+ | "LabelOperations_createLabel"
36
+ | "LabelDetailOperations_getLabel"
37
+ | "LabelDetailOperations_updateLabel"
38
+ | "LabelDetailOperations_deleteLabel"
34
39
  | "OrganizeOperations_createOrganizeJob"
35
40
  | "OrganizeOperations_previewOrganize"
36
41
  | "OrganizeJobDetailOperations_getOrganizeJob"
@@ -48,6 +53,7 @@ export type OperationIds =
48
53
  | "MessageBulkOperations_moveMessages"
49
54
  | "MessageBulkOperations_updateFlags"
50
55
  | "MessageBulkOperations_copyMessages"
56
+ | "MessageBulkOperations_updateMessageLabels"
51
57
  | "TrashOperations_emptyTrash"
52
58
  | "OutboxOperations_createOutboxMessage"
53
59
  | "OutboxOperations_listOutboxMessages"
@@ -96,6 +102,13 @@ export type FilterDetailOperationIds = MatchPrefix<
96
102
  OperationIds
97
103
  >;
98
104
 
105
+ export type LabelOperationIds = MatchPrefix<"LabelOperations_", OperationIds>;
106
+
107
+ export type LabelDetailOperationIds = MatchPrefix<
108
+ "LabelDetailOperations_",
109
+ OperationIds
110
+ >;
111
+
99
112
  export type OrganizeOperationIds = MatchPrefix<
100
113
  "OrganizeOperations_",
101
114
  OperationIds