@remit/web-client 0.0.75 → 0.0.77

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/LabelApplyTrigger.tsx +61 -0
  3. package/src/components/mail/MessageList.tsx +1 -0
  4. package/src/components/mail/MessageListItem.test.ts +43 -0
  5. package/src/components/mail/MessageListItem.tsx +1 -0
  6. package/src/components/mail/MoveToTrigger.tsx +2 -2
  7. package/src/components/mail/SelectionToolbar.render.test.ts +51 -1
  8. package/src/components/mail/SelectionToolbar.stories.tsx +5 -0
  9. package/src/components/mail/SelectionToolbar.tsx +21 -0
  10. package/src/components/mail/organize/OrganizeRuleEditor.tsx +20 -0
  11. package/src/components/settings/DeleteFolderDialog.tsx +2 -1
  12. package/src/components/settings/FilterEditor.render.test.ts +1 -0
  13. package/src/components/settings/FilterEditor.tsx +18 -0
  14. package/src/components/settings/FilterEditorSurface.tsx +9 -1
  15. package/src/components/settings/FiltersList.render.test.ts +16 -0
  16. package/src/components/settings/FiltersList.tsx +32 -8
  17. package/src/components/settings/LabelsList.tsx +112 -0
  18. package/src/components/settings/settings-filter.stories.tsx +10 -1
  19. package/src/hooks/useApplyLabel.ts +68 -0
  20. package/src/hooks/useCreateMailbox.render.test.ts +109 -9
  21. package/src/hooks/useCreateMailbox.ts +64 -23
  22. package/src/hooks/useLabels.ts +124 -0
  23. package/src/hooks/useRuleEditorState.ts +8 -0
  24. package/src/lib/mailbox-sync-wait.test.ts +186 -0
  25. package/src/lib/mailbox-sync-wait.ts +98 -0
  26. package/src/lib/organize/filter-edit-model.test.ts +38 -0
  27. package/src/lib/organize/filter-edit-model.ts +15 -10
  28. package/src/lib/organize/label-delete-copy.test.ts +21 -0
  29. package/src/lib/organize/label-delete-copy.ts +22 -0
  30. package/src/lib/organize/organize-model.test.ts +35 -7
  31. package/src/lib/organize/organize-model.ts +17 -12
  32. package/src/lib/organize/rule-model.test.ts +5 -0
  33. package/src/lib/organize/rule-model.ts +1 -0
  34. package/src/routeTree.gen.ts +21 -0
  35. package/src/routes/settings/filters.tsx +19 -1
  36. package/src/routes/settings/labels.tsx +242 -0
  37. package/src/routes/settings.tsx +7 -1
@@ -0,0 +1,186 @@
1
+ /**
2
+ * waitForMailboxSynced — the gate a dependent write (a filter, a move) holds
3
+ * behind while a freshly-created folder is confirmed on the mail server. It
4
+ * resolves only on `synced`, rejects distinctly on `failed` and on timeout, and
5
+ * keeps polling while the row is still `pending` or not yet listed.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { MailboxSyncStatus } from "@remit/domain-enums";
11
+ import {
12
+ MAILBOX_SYNC_FAILED_MESSAGE,
13
+ MAILBOX_SYNC_TIMEOUT_MESSAGE,
14
+ type MailboxSyncSignal,
15
+ waitForMailboxSynced,
16
+ } from "./mailbox-sync-wait.js";
17
+
18
+ const row = (
19
+ mailboxId: string,
20
+ syncStatus?: MailboxSyncSignal["syncStatus"],
21
+ extra: Record<string, unknown> = {},
22
+ ): MailboxSyncSignal & Record<string, unknown> => ({
23
+ mailboxId,
24
+ syncStatus,
25
+ ...extra,
26
+ });
27
+
28
+ const noDelay = () => Promise.resolve();
29
+
30
+ describe("waitForMailboxSynced", () => {
31
+ it("resolves with the confirmed row once it reaches synced", async () => {
32
+ const responses = [
33
+ [row("mbx-1", MailboxSyncStatus.pending)],
34
+ [row("mbx-1", MailboxSyncStatus.pending)],
35
+ [row("mbx-1", MailboxSyncStatus.synced, { fullPath: "Server/Receipts" })],
36
+ ];
37
+ let call = 0;
38
+ const result = await waitForMailboxSynced({
39
+ mailboxId: "mbx-1",
40
+ fetchMailboxes: async () => responses[call++],
41
+ delay: noDelay,
42
+ });
43
+ assert.equal(result.syncStatus, MailboxSyncStatus.synced);
44
+ assert.equal(
45
+ (result as Record<string, unknown>).fullPath,
46
+ "Server/Receipts",
47
+ );
48
+ assert.equal(call, 3);
49
+ });
50
+
51
+ it("keeps polling while the row is not yet listed", async () => {
52
+ const responses = [
53
+ [] as MailboxSyncSignal[],
54
+ [row("other", MailboxSyncStatus.synced)],
55
+ [row("mbx-1", MailboxSyncStatus.synced)],
56
+ ];
57
+ let call = 0;
58
+ const result = await waitForMailboxSynced({
59
+ mailboxId: "mbx-1",
60
+ fetchMailboxes: async () => responses[call++],
61
+ delay: noDelay,
62
+ });
63
+ assert.equal(result.mailboxId, "mbx-1");
64
+ assert.equal(call, 3);
65
+ });
66
+
67
+ it("rejects with the failure message when the create is reported failed", async () => {
68
+ await assert.rejects(
69
+ waitForMailboxSynced({
70
+ mailboxId: "mbx-1",
71
+ fetchMailboxes: async () => [row("mbx-1", MailboxSyncStatus.failed)],
72
+ delay: noDelay,
73
+ }),
74
+ (error: unknown) =>
75
+ error instanceof Error && error.message === MAILBOX_SYNC_FAILED_MESSAGE,
76
+ );
77
+ });
78
+
79
+ it("rejects with the timeout message when the row never confirms", async () => {
80
+ let clock = 0;
81
+ let fetches = 0;
82
+ await assert.rejects(
83
+ waitForMailboxSynced({
84
+ mailboxId: "mbx-1",
85
+ fetchMailboxes: async () => {
86
+ fetches += 1;
87
+ return [row("mbx-1", MailboxSyncStatus.pending)];
88
+ },
89
+ timeoutMs: 30_000,
90
+ pollIntervalMs: 1_000,
91
+ now: () => clock,
92
+ delay: async (ms) => {
93
+ clock += ms;
94
+ },
95
+ }),
96
+ (error: unknown) =>
97
+ error instanceof Error &&
98
+ error.message === MAILBOX_SYNC_TIMEOUT_MESSAGE,
99
+ );
100
+ assert.ok(fetches > 1, "polls more than once before timing out");
101
+ });
102
+
103
+ it("does not treat failed as timeout even past the deadline", async () => {
104
+ let clock = 100_000;
105
+ await assert.rejects(
106
+ waitForMailboxSynced({
107
+ mailboxId: "mbx-1",
108
+ fetchMailboxes: async () => [row("mbx-1", MailboxSyncStatus.failed)],
109
+ timeoutMs: 1,
110
+ now: () => clock++,
111
+ delay: noDelay,
112
+ }),
113
+ (error: unknown) =>
114
+ error instanceof Error && error.message === MAILBOX_SYNC_FAILED_MESSAGE,
115
+ );
116
+ });
117
+
118
+ const isAbort = (error: unknown): boolean =>
119
+ typeof error === "object" &&
120
+ error !== null &&
121
+ (error as { name?: unknown }).name === "AbortError";
122
+
123
+ it("rejects without polling when the signal is already aborted", async () => {
124
+ const controller = new AbortController();
125
+ controller.abort();
126
+ let fetches = 0;
127
+ await assert.rejects(
128
+ waitForMailboxSynced({
129
+ mailboxId: "mbx-1",
130
+ signal: controller.signal,
131
+ fetchMailboxes: async () => {
132
+ fetches += 1;
133
+ return [row("mbx-1", MailboxSyncStatus.pending)];
134
+ },
135
+ delay: noDelay,
136
+ }),
137
+ isAbort,
138
+ );
139
+ assert.equal(fetches, 0);
140
+ });
141
+
142
+ it("stops polling and rejects when the signal aborts mid-wait", async () => {
143
+ const controller = new AbortController();
144
+ let fetches = 0;
145
+ await assert.rejects(
146
+ waitForMailboxSynced({
147
+ mailboxId: "mbx-1",
148
+ signal: controller.signal,
149
+ fetchMailboxes: async () => {
150
+ fetches += 1;
151
+ if (fetches === 2) controller.abort();
152
+ return [row("mbx-1", MailboxSyncStatus.pending)];
153
+ },
154
+ delay: noDelay,
155
+ }),
156
+ isAbort,
157
+ );
158
+ assert.equal(fetches, 2);
159
+ });
160
+
161
+ it("resolves across the real timer delay between polls", async () => {
162
+ const responses = [
163
+ [row("mbx-1", MailboxSyncStatus.pending)],
164
+ [row("mbx-1", MailboxSyncStatus.synced)],
165
+ ];
166
+ let call = 0;
167
+ const result = await waitForMailboxSynced({
168
+ mailboxId: "mbx-1",
169
+ pollIntervalMs: 1,
170
+ fetchMailboxes: async () => responses[call++],
171
+ });
172
+ assert.equal(result.syncStatus, MailboxSyncStatus.synced);
173
+ });
174
+
175
+ it("aborts an in-progress real timer delay", async () => {
176
+ const controller = new AbortController();
177
+ const pending = waitForMailboxSynced({
178
+ mailboxId: "mbx-1",
179
+ signal: controller.signal,
180
+ pollIntervalMs: 10_000,
181
+ fetchMailboxes: async () => [row("mbx-1", MailboxSyncStatus.pending)],
182
+ });
183
+ setTimeout(() => controller.abort(), 5);
184
+ await assert.rejects(pending, isAbort);
185
+ });
186
+ });
@@ -0,0 +1,98 @@
1
+ import { MailboxSyncStatus } from "@remit/domain-enums";
2
+
3
+ type MailboxSyncStatusValue =
4
+ (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus];
5
+
6
+ /**
7
+ * A folder created for a dependent write — a filter that will move mail into it,
8
+ * or a move that lands mail there — is not usable the instant the create is
9
+ * queued: the row exists locally with `syncStatus: pending`, and the folder does
10
+ * not exist on the mail server until the imap-worker confirms the create and
11
+ * flips it to `synced`. Binding the dependent write to a `pending` row races the
12
+ * folder into existence across separate FIFO queues and cannot report a create
13
+ * that fails. This waits for the confirmation before the dependent write runs.
14
+ *
15
+ * The standalone create (a folder made in settings with no dependent write) does
16
+ * not use this — it may stay optimistic. The wait is only for the dependent case.
17
+ *
18
+ * The wait honours an `AbortSignal`: the surface that started the create passes
19
+ * one and aborts it on unmount/cancel/close, so a folder that confirms after the
20
+ * surface is gone never resolves and never fires the dependent bind or move.
21
+ */
22
+
23
+ /** The read fields the wait needs off a mailbox row. */
24
+ export interface MailboxSyncSignal {
25
+ mailboxId: string;
26
+ syncStatus?: MailboxSyncStatusValue;
27
+ }
28
+
29
+ export interface WaitForMailboxSyncedOptions<T extends MailboxSyncSignal> {
30
+ /** Reads the current mailbox rows; called once per poll (forces a fresh read). */
31
+ fetchMailboxes: () => Promise<readonly T[]>;
32
+ /** The row to wait on. */
33
+ mailboxId: string;
34
+ /** Aborts the wait; a late confirmation after abort resolves nothing. */
35
+ signal?: AbortSignal;
36
+ /** How long to wait for confirmation before giving up. */
37
+ timeoutMs?: number;
38
+ /** Gap between polls. */
39
+ pollIntervalMs?: number;
40
+ /** Injectable clock/sleep for tests. */
41
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
42
+ now?: () => number;
43
+ }
44
+
45
+ export const MAILBOX_SYNC_TIMEOUT_MS = 30_000;
46
+ export const MAILBOX_SYNC_POLL_INTERVAL_MS = 1_000;
47
+
48
+ export const MAILBOX_SYNC_FAILED_MESSAGE =
49
+ "The folder couldn't be created on the mail server. Please try again.";
50
+ export const MAILBOX_SYNC_TIMEOUT_MESSAGE =
51
+ "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
52
+
53
+ const defaultDelay = (ms: number, signal?: AbortSignal): Promise<void> =>
54
+ new Promise((resolve, reject) => {
55
+ if (signal?.aborted) {
56
+ reject(signal.reason);
57
+ return;
58
+ }
59
+ const timer = setTimeout(() => {
60
+ signal?.removeEventListener("abort", onAbort);
61
+ resolve();
62
+ }, ms);
63
+ const onAbort = () => {
64
+ clearTimeout(timer);
65
+ reject(signal?.reason);
66
+ };
67
+ signal?.addEventListener("abort", onAbort, { once: true });
68
+ });
69
+
70
+ /**
71
+ * Resolve with the mailbox row once its `syncStatus` reaches `synced` — the
72
+ * server-confirmed row, carrying the path the server normalized the create to.
73
+ * Reject with a failure message when the create is reported `failed`, with a
74
+ * distinct timeout message when it never confirms within `timeoutMs`, and with
75
+ * the signal's reason (an `AbortError`) when `signal` aborts. A row that is still
76
+ * `pending` (or not yet in the list) keeps the poll running.
77
+ */
78
+ export async function waitForMailboxSynced<T extends MailboxSyncSignal>({
79
+ fetchMailboxes,
80
+ mailboxId,
81
+ signal,
82
+ timeoutMs = MAILBOX_SYNC_TIMEOUT_MS,
83
+ pollIntervalMs = MAILBOX_SYNC_POLL_INTERVAL_MS,
84
+ delay = defaultDelay,
85
+ now = Date.now,
86
+ }: WaitForMailboxSyncedOptions<T>): Promise<T> {
87
+ const deadline = now() + timeoutMs;
88
+ for (;;) {
89
+ signal?.throwIfAborted();
90
+ const mailboxes = await fetchMailboxes();
91
+ const mailbox = mailboxes.find((entry) => entry.mailboxId === mailboxId);
92
+ if (mailbox?.syncStatus === MailboxSyncStatus.synced) return mailbox;
93
+ if (mailbox?.syncStatus === MailboxSyncStatus.failed)
94
+ throw new Error(MAILBOX_SYNC_FAILED_MESSAGE);
95
+ if (now() >= deadline) throw new Error(MAILBOX_SYNC_TIMEOUT_MESSAGE);
96
+ await delay(pollIntervalMs, signal);
97
+ }
98
+ }
@@ -58,6 +58,16 @@ describe("filterToRule", () => {
58
58
  assert.equal(rule.moveMailboxId, undefined);
59
59
  });
60
60
 
61
+ it("loads a label action (issue #26)", () => {
62
+ const rule = filterToRule(filter({ actionLabelId: "lbl-1" }));
63
+ assert.equal(rule.labelId, "lbl-1");
64
+ });
65
+
66
+ it("maps the None label sentinel to no action", () => {
67
+ const rule = filterToRule(filter({ actionLabelId: "None" }));
68
+ assert.equal(rule.labelId, undefined);
69
+ });
70
+
61
71
  it("loads a Temporary filter as an until-a-date rule", () => {
62
72
  const rule = filterToRule(
63
73
  filter({ scope: "Temporary", expiresAt: "2027-03-04T23:59:59+00:00" }),
@@ -131,6 +141,13 @@ describe("ruleChangesPredicateOrAction", () => {
131
141
  true,
132
142
  );
133
143
  });
144
+
145
+ it("is true when the label target changes (issue #26)", () => {
146
+ assert.equal(
147
+ ruleChangesPredicateOrAction({ ...base, labelId: "lbl-new" }, base),
148
+ true,
149
+ );
150
+ });
134
151
  });
135
152
 
136
153
  describe("ruleChangesScopeOrExpiry (reader #266)", () => {
@@ -212,6 +229,27 @@ describe("buildUpdateFilterInput", () => {
212
229
  assert.equal(body.actionMailboxId, "None");
213
230
  });
214
231
 
232
+ it("sends a chosen label target (issue #26)", () => {
233
+ const changed: FilterRule = {
234
+ ...original,
235
+ labelId: "lbl-receipts",
236
+ matchOperator: "any",
237
+ };
238
+ const body = buildUpdateFilterInput(changed, original);
239
+ assert.equal(body.actionLabelId, "lbl-receipts");
240
+ });
241
+
242
+ it("drops a cleared label target to the None sentinel", () => {
243
+ const withLabel = { ...original, labelId: "lbl-receipts" };
244
+ const changed = {
245
+ ...withLabel,
246
+ labelId: undefined,
247
+ matchOperator: "any" as const,
248
+ };
249
+ const body = buildUpdateFilterInput(changed, withLabel);
250
+ assert.equal(body.actionLabelId, "None");
251
+ });
252
+
215
253
  it("is empty when nothing changed", () => {
216
254
  assert.deepEqual(buildUpdateFilterInput(original, original), {});
217
255
  });
@@ -14,8 +14,9 @@ import { NO_ACTION } from "./organize-model";
14
14
  * cannot evaluate it (D4) — the rule then matches by its literal clauses only.
15
15
  *
16
16
  * The scope maps `Standing` → "standing" and `Temporary` → "until"; a persisted
17
- * filter is never the one-time "once" scope. `moveMailboxId` drops the `"None"`
18
- * sentinel to `undefined` so an empty folder select reads as "no move action".
17
+ * filter is never the one-time "once" scope. `moveMailboxId` and `labelId` both
18
+ * drop the `"None"` sentinel to `undefined` so an empty select reads as "no
19
+ * action" (issue #26).
19
20
  */
20
21
  export const filterToRule = (
21
22
  filter: RemitImapFilterResponse,
@@ -34,6 +35,8 @@ export const filterToRule = (
34
35
  : undefined,
35
36
  moveMailboxId:
36
37
  filter.actionMailboxId !== NO_ACTION ? filter.actionMailboxId : undefined,
38
+ labelId:
39
+ filter.actionLabelId !== NO_ACTION ? filter.actionLabelId : undefined,
37
40
  scope: filter.scope === "Temporary" ? "until" : "standing",
38
41
  until:
39
42
  filter.scope === "Temporary"
@@ -65,15 +68,16 @@ const predicateActionKey = (rule: FilterRule): string =>
65
68
  clauses: rule.clauses.map((clause) => [clause.field, clause.value]),
66
69
  operator: rule.matchOperator,
67
70
  move: rule.moveMailboxId ?? null,
71
+ label: rule.labelId ?? null,
68
72
  widen: rule.widen ? (rule.widen.inactive ? "inactive" : "active") : "none",
69
73
  });
70
74
 
71
75
  /**
72
76
  * Whether the edited rule changes the predicate or the action versus the one it
73
- * was loaded from — the clauses, the match operator, the move target, or the
74
- * widen's presence. A change here is what bumps `ruleChangedAt` and offers the
75
- * re-back-apply (RFC 034 Decision 3.2); the name is deliberately excluded, so a
76
- * cosmetic rename is not a rule change.
77
+ * was loaded from — the clauses, the match operator, the move target, the
78
+ * label target, or the widen's presence. A change here is what bumps
79
+ * `ruleChangedAt` and offers the re-back-apply (RFC 034 Decision 3.2); the name
80
+ * is deliberately excluded, so a cosmetic rename is not a rule change.
77
81
  */
78
82
  export const ruleChangesPredicateOrAction = (
79
83
  rule: FilterRule,
@@ -104,10 +108,10 @@ export const ruleChangesScopeOrExpiry = (
104
108
  * The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
105
109
  * so the server's `changesRuleAssertion` guard leaves `ruleChangedAt`
106
110
  * untouched (RFC 034 Decision 3.2). A predicate or action change sends the
107
- * operator, clauses, and move target; a scope or expiry change sends `scope`
108
- * and, for the `until` scope, `expiresAt` (reader #266) — either bumps
109
- * `ruleChangedAt`. The label action and the anchor are never in the editor's
110
- * gift, so they never enter the patch; the partial update preserves them.
111
+ * operator, clauses, move target, and label target; a scope or expiry change
112
+ * sends `scope` and, for the `until` scope, `expiresAt` (reader #266) —
113
+ * either bumps `ruleChangedAt`. The anchor is never in the editor's gift, so
114
+ * it never enters the patch; the partial update preserves it.
111
115
  */
112
116
  export const buildUpdateFilterInput = (
113
117
  rule: FilterRule,
@@ -123,6 +127,7 @@ export const buildUpdateFilterInput = (
123
127
  value: clause.value,
124
128
  }));
125
129
  body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
130
+ body.actionLabelId = rule.labelId ?? NO_ACTION;
126
131
  }
127
132
  if (ruleChangesScopeOrExpiry(rule, original)) {
128
133
  body.scope = rule.scope === "until" ? "Temporary" : "Standing";
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { deleteLabelConfirmCopy } from "./label-delete-copy";
4
+
5
+ describe("deleteLabelConfirmCopy", () => {
6
+ it("names the label with no blast-radius note when no filter uses it", () => {
7
+ const copy = deleteLabelConfirmCopy("Receipts", 0);
8
+ assert.equal(copy.title, 'Delete the "Receipts" label?');
9
+ assert.equal(copy.description, undefined);
10
+ });
11
+
12
+ it("names exactly one filter in the singular", () => {
13
+ const copy = deleteLabelConfirmCopy("Receipts", 1);
14
+ assert.match(copy.description ?? "", /1 filter that applies it/);
15
+ });
16
+
17
+ it("names several filters in the plural", () => {
18
+ const copy = deleteLabelConfirmCopy("Receipts", 3);
19
+ assert.match(copy.description ?? "", /3 filters that apply it/);
20
+ });
21
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The delete-confirmation copy for a label (issue #26). Deleting a label
3
+ * cascades in both directions server-side — every filter whose action applies
4
+ * it is deleted outright, never left dangling — so the confirmation names the
5
+ * filter count up front rather than surprising the user after the fact. The
6
+ * API itself never blocks the delete; the confirmation is the only gate.
7
+ */
8
+ export const deleteLabelConfirmCopy = (
9
+ labelName: string,
10
+ filterCount: number,
11
+ ): { title: string; description?: string } => {
12
+ if (filterCount === 0) {
13
+ return { title: `Delete the "${labelName}" label?` };
14
+ }
15
+ const filterNoun = filterCount === 1 ? "filter" : "filters";
16
+ return {
17
+ title: `Delete the "${labelName}" label?`,
18
+ description: `This also deletes ${filterCount} ${filterNoun} that ${
19
+ filterCount === 1 ? "applies" : "apply"
20
+ } it — they can't be recovered.`,
21
+ };
22
+ };
@@ -15,13 +15,15 @@ const baseDraft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
15
15
  });
16
16
 
17
17
  describe("hasCommittableAction", () => {
18
- it("is false when no move target is chosen — labeling has no backend yet, so a keep-in-place draft has nothing to commit", () => {
18
+ it("is false when neither a move nor a label is chosen — a keep-in-place draft has nothing to commit", () => {
19
19
  assert.equal(hasCommittableAction(baseDraft()), false);
20
20
  });
21
21
 
22
- it("is false for the None sentinel", () => {
22
+ it("is false for the None sentinel on both actions", () => {
23
23
  assert.equal(
24
- hasCommittableAction(baseDraft({ moveMailboxId: NO_ACTION })),
24
+ hasCommittableAction(
25
+ baseDraft({ moveMailboxId: NO_ACTION, labelId: NO_ACTION }),
26
+ ),
25
27
  false,
26
28
  );
27
29
  });
@@ -32,10 +34,23 @@ describe("hasCommittableAction", () => {
32
34
  true,
33
35
  );
34
36
  });
37
+
38
+ it("is true once a real label is chosen, with no move target (issue #26)", () => {
39
+ assert.equal(hasCommittableAction(baseDraft({ labelId: "lbl-1" })), true);
40
+ });
41
+
42
+ it("is true when both a move and a label are chosen", () => {
43
+ assert.equal(
44
+ hasCommittableAction(
45
+ baseDraft({ moveMailboxId: "mbx-1", labelId: "lbl-1" }),
46
+ ),
47
+ true,
48
+ );
49
+ });
35
50
  });
36
51
 
37
52
  describe("buildOrganizeInput", () => {
38
- it("carries the anchor and defaults the action to None when no move is set", () => {
53
+ it("carries the anchor and defaults both actions to None when neither is set", () => {
39
54
  const input = buildOrganizeInput(baseDraft({ anchorMessageId: "msg-1" }));
40
55
  assert.equal(input.anchorMessageId, "msg-1");
41
56
  assert.equal(input.actionMailboxId, NO_ACTION);
@@ -53,12 +68,16 @@ describe("buildOrganizeInput", () => {
53
68
  assert.equal("anchorMessageId" in input, false);
54
69
  });
55
70
 
56
- it("labels the label action None even when a move target is set — label writes have no endpoint", () => {
71
+ it("carries the move and label actions independently (issue #26)", () => {
57
72
  const input = buildOrganizeInput(
58
- baseDraft({ anchorMessageId: "msg-1", moveMailboxId: "mbx-9" }),
73
+ baseDraft({
74
+ anchorMessageId: "msg-1",
75
+ moveMailboxId: "mbx-9",
76
+ labelId: "lbl-9",
77
+ }),
59
78
  );
60
79
  assert.equal(input.actionMailboxId, "mbx-9");
61
- assert.equal(input.actionLabelId, NO_ACTION);
80
+ assert.equal(input.actionLabelId, "lbl-9");
62
81
  });
63
82
  });
64
83
 
@@ -101,4 +120,13 @@ describe("buildCreateFilterInput", () => {
101
120
  );
102
121
  assert.equal("ttl" in input, false);
103
122
  });
123
+
124
+ it("carries a label action (issue #26)", () => {
125
+ const input = buildCreateFilterInput(
126
+ baseDraft({ labelId: "lbl-1" }),
127
+ "standing",
128
+ "Receipts",
129
+ );
130
+ assert.equal(input.actionLabelId, "lbl-1");
131
+ });
104
132
  });
@@ -27,9 +27,10 @@ export const NO_ACTION = "None";
27
27
 
28
28
  /**
29
29
  * The user's in-progress organize decision, independent of which scope they
30
- * land on. The anchor and predicate drive the match set; `moveMailboxId` is the
31
- * one committable action today (labeling has no backend yet — see
32
- * `labelPlaceholder`).
30
+ * land on. The anchor and predicate drive the match set; `moveMailboxId` and
31
+ * `labelId` are the two committable actions (issue #26) independent of each
32
+ * other, since a move is exclusive and a label is additive (RFC 034 Decision
33
+ * 3.1).
33
34
  */
34
35
  export interface OrganizeDraft {
35
36
  /** Semantic anchor — "mail like this one". The first selected message. */
@@ -43,6 +44,11 @@ export interface OrganizeDraft {
43
44
  * Decision 3.1). Absent means "keep where they are": no move action.
44
45
  */
45
46
  moveMailboxId?: string;
47
+ /**
48
+ * Label applied to the match set — the additive action (RFC 034 Decision
49
+ * 3.1, issue #26). Absent applies no label.
50
+ */
51
+ labelId?: string;
46
52
  /**
47
53
  * ISO 8601 date-time with zone offset. Present only for the `temporary`
48
54
  * scope; a plain picked date (RFC 034 non-goal: no event-based expiry).
@@ -51,14 +57,13 @@ export interface OrganizeDraft {
51
57
  }
52
58
 
53
59
  /**
54
- * Whether the draft carries a committable action. Labeling is not wired yet
55
- * (no Label API RFC 030's `Label`/`MessageLabel` entities exist in TypeSpec
56
- * but have no CRUD endpoint), so a move target is the only real action. A draft
57
- * with no move target has nothing to commit; the caller disables the CTA and
58
- * says why (ux.md).
60
+ * Whether the draft carries a committable action a move destination and/or
61
+ * a label (issue #26), either satisfies this. A draft with neither has
62
+ * nothing to commit; the caller disables the CTA and says why (ux.md).
59
63
  */
60
64
  export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
61
- draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION;
65
+ (draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION) ||
66
+ (draft.labelId !== undefined && draft.labelId !== NO_ACTION);
62
67
 
63
68
  /**
64
69
  * Whether the draft's predicate can be back-applied over the existing corpus. A
@@ -75,7 +80,7 @@ export const canBackApplyDraft = (draft: OrganizeDraft): boolean =>
75
80
  * Build the read-only preview / back-apply matcher input. The action fields do
76
81
  * not affect which messages match — the preview returns exactly the set a job
77
82
  * with the same predicate would apply to — so a widen preview can pass this
78
- * before the user has chosen a folder.
83
+ * before the user has chosen a folder or a label.
79
84
  */
80
85
  export const buildOrganizeInput = (
81
86
  draft: OrganizeDraft,
@@ -83,7 +88,7 @@ export const buildOrganizeInput = (
83
88
  ...(draft.anchorMessageId ? { anchorMessageId: draft.anchorMessageId } : {}),
84
89
  matchOperator: draft.matchOperator,
85
90
  literalClauses: draft.literalClauses,
86
- actionLabelId: NO_ACTION,
91
+ actionLabelId: draft.labelId ?? NO_ACTION,
87
92
  actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
88
93
  });
89
94
 
@@ -107,7 +112,7 @@ export const buildCreateFilterInput = (
107
112
  : {}),
108
113
  matchOperator: draft.matchOperator,
109
114
  literalClauses: draft.literalClauses,
110
- actionLabelId: NO_ACTION,
115
+ actionLabelId: draft.labelId ?? NO_ACTION,
111
116
  actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
112
117
  ...(draft.anchorMessageId
113
118
  ? { anchorMessageId: draft.anchorMessageId }
@@ -247,6 +247,11 @@ describe("the previewed set equals the applied set", () => {
247
247
  "mbx-archive",
248
248
  );
249
249
  });
250
+
251
+ it("carries the label target into the draft (issue #26)", () => {
252
+ const labeled = { ...semanticRule, labelId: "lbl-receipts" };
253
+ assert.equal(ruleToDraft(labeled, "msg-1").labelId, "lbl-receipts");
254
+ });
250
255
  });
251
256
 
252
257
  describe("derivePreview", () => {
@@ -164,6 +164,7 @@ export const ruleToDraft = (
164
164
  matchOperator: predicate.matchOperator,
165
165
  literalClauses: predicate.literalClauses,
166
166
  moveMailboxId: rule.moveMailboxId,
167
+ labelId: rule.labelId,
167
168
  expiresAt:
168
169
  rule.scope === "until"
169
170
  ? pickedDateToExpiresAt(rule.until ?? "")