Skip to content

Server rules: full editing, account picker, and classifier foundation in the Rules Manager (#333)#367

Merged
kellylford merged 7 commits into
mainfrom
feat/server-rules-window-333
Jul 27, 2026
Merged

Server rules: full editing, account picker, and classifier foundation in the Rules Manager (#333)#367
kellylford merged 7 commits into
mainfrom
feat/server-rules-window-333

Conversation

@CityDweller

@CityDweller CityDweller commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Server-rule support for #333, grown well past the original read-only peek. Validated end-to-end against a live 68-rule M365 mailbox (create → the server rule fires) and by a screen-reader user throughout. FeatureFlag.ServerRules stays off — the two-section layout here is explicitly transitional and ships to nobody until the unified list (§20) lands.

⚠️ Stacked on #364 (base = feat/rules-per-account-migration-333); also builds on the merged service (#335) and VMs (#337). Per review: once #364 lands, this retargets to main (no rebase — stays a clean descendant).

What's in it

  • Read-only view of a Graph account's Exchange messageRules in the Rules Manager (the original slice).
  • Full editing via a new modeless ServerRuleEditorWindow — create / edit / delete / enable-disable / reorder. Modeless per the editable-text-over-WebView2 rule. Editor is laid out Common + collapsible Advanced, and Advanced auto-expands when editing a rule that already uses an advanced field.
  • Subset expansion: bodyContains, sentToAddresses, copyToFolder — every rule on the test mailbox is representable (0 flagged). Read + write, with the multi-value guard.
  • Minimal-diff reorder: only PATCH the rules whose sequence changes, so an unrelated server-read-only rule (e.g. an Outlook-created one) no longer 400s and poisons a valid move.
  • Read-only rules disable Edit/Delete/Enable-Disable/Move (CanExecute, buttons and context menu) — clearer than pressable-but-silent for a screen-reader user with announcements off.
  • Keys: Space = enable/disable, Delete = delete, Enter = edit on the server list.
  • Account picker in the server section, seeded to the account you're currently in.
  • Save round-trip correctness: valid 1-based sequence on create (Graph rejects 0); the editor stays open and shows the error on a failed save instead of closing and losing the form; focus returns to the new rule; and a guard so a transient main-window activation can't steal focus back to the message list as the editor closes.
  • Classifier foundation (§20 Phases 1–2): Classify(accountSupportsServerRules) + ToClientRule — pure, unit-tested logic that decides server vs client vs conflict. Not yet wired to UI; it's the engine for the unified New button.

Accessibility

One-per-line DetailText under "Applies when:" / "Does:" / "Reason for block:" headers; caret-navigable read-only detail pane; per-row not-editable/read-only markers; status line reports counts and is focusable; F6 / Shift+F6 pane cycling (which the window lacked entirely before).

Still to come (§20, this PR's successor work)

The single per-account combined list with the account picker as first focusable control and one auto-classifying New button — replaces this interim two-section layout. Design in docs/planning/server-rules-pm-dev-spec.md §20.

Tests

Full suite 1597 passed (lone red is the known OpenClipboard clipboard flake, passes in isolation). Server-rule/classifier coverage: reorder minimal-diff, read-only gating, sequence-on-create, save-failure keeps editor open, classifier server/client/conflict, client-rule conversion, and the earlier subset round-trips.

Filed while testing (separate)

…ity fixes (#333)

First user-facing slice of server-rule support: a read-only view of a Graph
account's Exchange messageRules inside the Rules Manager, so the rules can be
seen and the mapping validated against real data before editing is built. Also
folds in fixes found while validating against a live 68-rule mailbox.

Wiring:
- App.xaml.cs constructs GraphServerRuleService (reuses the shared GraphClient,
  no new disposables) and passes it to MainWindow.
- MainWindow.OpenRulesManager builds a ServerRulesViewModel for the Graph
  account (with the cached folder list) and hands it to the window.
- RulesManagerWindow shows a collapsible read-only "Server rules" section: a
  list (each row announces name, state, "runs on the server", any markers, and a
  one-line summary) and a read-only detail pane. Hidden when no Graph account.

Subset expansion (every rule flagged on the test mailbox is now representable):
- Added conditions bodyContains and sentToAddresses (mirroring subjectContains /
  fromAddresses, including multi-value gating for bodyContains). These two
  covered all 13 previously-flagged rules on the live mailbox.
- Added the copyToFolder action.
- Move/copy targets now resolve to the folder's display name (via the account's
  cached folders; Graph stores the folder id as FullName) instead of "another
  folder".

View fidelity / accessibility:
- DetailText reformatted: one condition/action per line under "Applies when:" /
  "Does:" headers, with a "Reason for block:" section only when a rule is
  read-only, errored, or not fully editable — and it now SHOWS the values of
  conditions even when they can't be edited (previously a hidden condition like
  "body contains 'X'" was omitted, which read as a mismatch).
- Not-editable rules announce it per row, and the status line reports the count.
- Detail pane is a read-only multi-line caret-navigable box (AcceptsReturn +
  IsReadOnlyCaretVisible) so a screen reader can arrow through every line.
- Added F6 / Shift+F6 pane cycling (list, server list, server detail, status),
  and made the status lines focusable so the counts can be read on demand — the
  window previously had no F6 ring (New Window Checklist gap).
- Empty-state focus lands on whichever list actually has rules.

Diagnostic: /debug logs the unsupported field NAMES per flagged rule (names
only, never raw predicate values, to keep mail subjects/addresses out of the
log).

Tests: +6 (bodyContains/sentToAddresses editable + round-trip, copyToFolder
round-trip, folder-name resolution, the not-editable row marker, unsupported
predicate now uses headerContains). Full suite 1587 passed; the lone red is the
known clipboard flake (passes in isolation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@kellylford kellylford left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff. Read-only scope is sound, the subset expansion round-trips correctly, and it closes a real F6 gap. Notes below — nothing is a hard blocker, but a couple need a decision.

Merge order (important)

  • Base is feat/rules-per-account-migration-333 — this is stacked on #364. #364 must land first, or rebase this flat onto main (as offered). Please don't merge this ahead of #364.
  • Depends on the already-merged server-rule service (#335) and VMs (#337) — both confirmed on main. Same M365/Exchange-default-on driver as #364.

Verified correct

  • Subset expansion is symmetric: bodyContains + sentToAddresses conditions and the copyToFolder action are added to the supported sets, removed from the unsupported/friendly-name table, and wired in both read (ToModel) and write (ToGraph). bodyContains correctly joins the single-value-string guard (multi-value → view-only). Tests assert the write path emits them, so a later edit can't silently drop them.
  • The view-fidelity fix is right: DetailText now shows every condition's value even when not editable — the old summary hid conditions it couldn't edit, which is what made a real bodyContains look like a mis-flag.
  • Folder-name resolution (Graph stores the folder id as FullName) is best-effort with a clean "another folder" fallback.
  • Event handlers unsubscribed in OnClosed (AnnouncementRequested, WriteBlockedByPermission).
  • No WebView2 in this window, so the F6-relay-JS checklist item doesn't apply.

Three things worth a look before sign-off (not blockers)

  1. Possible double-announcement — needs a listen. The server-rules status now has both an explicit Announce(StatusText, Status) on refresh and a LiveSetting="Polite" live region on the same bound TextBlock. Two independent mechanisms on the same text; whether that double-speaks on refresh is something only listening will confirm.
  2. F6 ring skips the client-rules editor. The ring covers the rule list and status but not the editor fields (Name/conditions/action), which stay Tab-only. Fine if intended — noting it because the New Window Checklist frames the editor as a primary region.
  3. No CI has run yet. Because the base is a feature branch, the checks list is empty — the "1587 passed" is local-only so far. Merging #364 or rebasing onto main will let CI actually run.

Follow-ups filed while testing (separate, not this PR): #365 cross-account move/copy, and #366 Graph mail delete/move reappearing from stale mutable message IDs — that one is a backend/sync bug worth its own look.

Assessment: read-only view is a good, well-tested first slice. Merge-order and the double-announcement listen are the two items that need you.

CityDweller and others added 2 commits July 24, 2026 22:06
…ff) (#333)

Makes the in-progress server-rules feature invisible to the public while it's
built out, so intermediate merges (this read-only view, then editing, then the
unified window) can't expose a half-built experience — regardless of when they
merge. Mirrors the GraphBackend gate.

- New FeatureFlag.ServerRules, default false in ConfigFeatureGate.Defaults.
- MainWindow.OpenRulesManager only builds the ServerRulesViewModel when the gate
  is enabled (and a Graph account exists), so the Rules Manager looks exactly as
  it does today for everyone else.
- Enable for dev/testing with --feature ServerRules or config.ini
  [features] ServerRules=true. Flip the default in a joint-decision PR once
  create/edit/delete and the unified per-account window are complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…der (#333)

Correctness prerequisite for editing. The mapper/model gained bodyContains,
sentToAddresses, and copyToFolder (so real O365 rules are fully representable),
but the editor VM didn't know those fields — so editing such a rule would have
assembled a ToModel() without them and the PATCH would silently drop them (the
exact §16 data-loss the design guards against).

ServerRuleEditorViewModel now reads them in ForEdit, exposes fields (incl. a
separate CopyToFolder + PickCopyFolder), writes them in ToModel, and validates
the copy-folder target. So every condition/action the model can represent also
round-trips through the editor.

Tests: extended the round-trip test to assert bodyContains/sentToAddresses/
copyToFolder survive an edit, plus copy-folder validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Feature remains gated off (FeatureFlag.ServerRules, default false). This is a
checkpoint of the server-rules-window work verified end-to-end against a live
M365 mailbox (create → server rule fires).

Editing
- New modeless ServerRuleEditorWindow (create/edit) — modeless per the
  editable-text-over-WebView2 rule; Escape/Cancel wired explicitly.
- Editor laid out as Common + a collapsible Advanced section; Advanced
  auto-expands when editing a rule that already uses an advanced field.
- Support bodyContains, sentToAddresses, copyToFolder (covers every predicate
  in the test mailbox, so 0 rules are flagged un-editable).

Reorder / read-only
- Minimal-diff reorder: only PATCH the rules whose sequence actually changes,
  so an unrelated server-read-only rule (e.g. an Outlook-created one) no longer
  400s and poisons an otherwise valid move.
- Read-only rules disable Edit/Delete/Enable-Disable/Move via CanExecute
  (buttons and context-menu items), rather than being pressable-but-silent —
  the user runs with announcements off, so a disabled control is clearer.

Keyboard / focus / account
- Space = enable/disable, Delete = delete, Enter = edit on the server list
  (window-scoped keys, honouring each command's CanExecute).
- Account picker added to the server section, seeded to the account the user is
  currently in (falls back to the first Graph account on aggregate views).
- Focus returns to the selected/new rule after writes; container-based focus
  with virtualization off so arrow keys stay in the list.

Round-trip correctness
- New rules get a valid 1-based sequence (Graph rejects sequence 0).
- Save reports success/failure back to the editor: it stays open and shows the
  error on failure instead of closing and losing the form.
- Guard MainWindow's OnActivated focus-restore with IsActive so a transient
  activation while the editor closes can't steal focus back to the message list.

Design
- Spec §20: unified New/Create with auto-classify (server vs client) — the
  agreed direction for the remaining layout-unification work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kellylford

Copy link
Copy Markdown
Owner

Status from my side: #364 is reviewed, approved, and green (I merged main into it and moved the release note to 0.8.37 — details there). It's held up only on a require_last_push_approval technicality because I pushed those commits; once that clears it lands with a merge commit, which means this branch stays a clean descendant and needs only a retarget to main, not a rebase.

Four things on this PR before it can go in.

1. The PR has outgrown its title and body. b4d2472a ("full editing, account picker, and round-trip fixes") adds the modeless ServerRuleEditorWindow, the account picker, Space/Delete/Enter keys, minimal-diff reorder, CanExecute-gated read-only rules, and focus-return after writes — 14 files. The title still says "read-only view" and the body still says under Not in this PR: "Editing (create/edit/delete/toggle/reorder) and the single per-account combined list with an account picker." Two of those three now are in it. Please retitle and rewrite the body to match the actual diff — I don't want to merge a PR whose description contradicts its contents, and neither of us will be able to reconstruct this from the log later.

2. CI still has never run on this branch. Zero workflow runs, empty check list, because the base is a feature branch. The 1587/1617 figures remain local-only. Retarget onto main after #364 lands and let build + integration actually execute — those two are required checks.

3. The double-announcement item from my last review is unaddressed, and it's now on both status lines. ServerRulesStatusText and the new MainStatusText both carry AutomationProperties.LiveSetting="Polite" while ServerRulesViewModel also calls Announce(StatusText, …) at four sites (lines 187, 197, 382, 399). Worth noting MainStatusText doesn't exist on main at all — this PR introduces the pattern on both lines rather than inheriting a proven one, so there's no existing behaviour vouching for it. Making the status line focusable with a Name so F6 can re-read it on demand is right and should stay; the question is only whether the live region plus the explicit announce double-speaks on refresh. I can't settle that by reading code — it needs a listen with a screen reader. Please confirm which mechanism you intend and drop the other if both fire.

4. F6 still skips the client-rules editor. CyclePane (RulesManagerWindow.xaml.cs:181) stops at: client list → server account combo → server list → server detail → server status → main status. The client editor fields (Name / conditions / action) remain Tab-only. Fine if that's deliberate, but say so explicitly — the New Window Checklist treats the editor as a primary region, and "we decided against it" is a different thing from "we missed it."

On §20 — I'm on board with the unified New/Create direction and the account picker as first focusable control. Noting for the record that it makes this PR's two-section layout explicitly transitional, which is fine, but it does mean the interim layout ships to nobody: keep FeatureFlag.ServerRules off until §20 lands, so users never see the two-section version at all.

Nothing here is a rewrite — 1, 2 and 4 are cheap, and 3 is a listen plus a one-line deletion.

Phase 1–2 of the unified New/Create work (spec §20). Pure logic, fully unit
tested; no UI or behaviour change yet (feature still gated off).

- Classify(accountSupportsServerRules) on the editor VM decides where a rule
  runs: a Graph account gets a server rule unless the rule uses a client-only
  capability; otherwise a client rule (with a reason for the save dialog); a
  client-only action combined with a server-only condition/action is a conflict.
- IsServerRepresentable / IsClientRepresentable + the server-only / client-only
  feature lists that drive the decision and the conflict message.
- MarkAsUnread added as the first client-only action (not yet in the editor UI).
- ToClientRule(accountId) maps the client-representable subset to a MailRule
  (single From/To value, one action) — lossless because it's only called when
  IsClientRepresentable holds.
- RuleRunsWhere enum + RuleClassification result type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kellylford added a commit that referenced this pull request Jul 27, 2026
…D1) (#364)

Merge commit rather than squash so the stacked #367 stays a clean descendant and needs only a retarget, not a conflict-heavy rebase.
@kellylford
kellylford changed the base branch from feat/rules-per-account-migration-333 to main July 27, 2026 02:44
@kellylford

Copy link
Copy Markdown
Owner

Update: #364 is merged (8e2ad096, merge commit as planned), and I've retargeted this PR onto main — item 2 in my previous comment is handled on my end, so you don't need to rebase. The branch is a clean descendant, so the diff is now just this PR's own work (18 files, +1594/−162).

One caveat: retargeting alone doesn't fire the workflows — GitHub only triggers on push, not on a base change. The branch currently shows BEHIND main. Your next push (merging main in, plus whatever you do for items 1, 3 and 4) will trigger build and integration for the first time on this branch. Both are required checks, so they need to go green before this can merge.

Still yours: retitle/rewrite the body to match the diff, the double-announcement listen, and a yes-or-no on whether F6 skipping the client editor is deliberate.

@kellylford

Copy link
Copy Markdown
Owner

Caught up with 65e5e9a8 (classifier engine + ToClientRule), which landed after my first comment — noting it so it's clear the review above predates it. It's clean, self-contained (2 files, +300, VM and tests only), and genuinely doesn't touch UI, so items 3 and 4 below are unchanged, not ignored: RulesManagerWindow.xaml still carries LiveSetting="Polite" on both status lines, and CyclePane still stops at client list → server combo → server list → server detail → server status → main status, with the client editor fields Tab-only.

Since CI has never run here, I built and ran the suite locally on 65e5e9a8 merged with current main:

  • Release build: clean, 0 errors.
  • Tests: 1643 passed. One caveat — on the first run, LogServiceTests.Log_WhenDisabled_DoesNotWriteFile failed; it passed in isolation and on two consecutive full re-runs. That's an order-dependent flake from shared log-file state, not anything in this branch, but with your +26 tests changing the scheduling it's now surfacing, and it will eventually cost a red CI run for someone. Worth its own issue rather than being absorbed into this PR.

So the code is in good shape. What's outstanding is still: retitle/rewrite the body to match what this now contains (b4d2472a editing + 65e5e9a8 classifier both landed after the body was written — three separate slices in one PR now), the double-announcement listen, and a yes/no on the F6 scope.

Given how much has accumulated here, a fair question: would you rather split the classifier + §20 work into its own PR on top, and land this one once 1/3/4 are closed out? Your call — I'm fine either way, but this PR is at 18 files and its stated scope hasn't matched its diff for two commits now.

@CityDweller CityDweller changed the title Server rules: read-only view in the Rules Manager + subset/accessibility fixes (#333) Server rules: full editing, account picker, and classifier foundation in the Rules Manager (#333) Jul 27, 2026
Start of the unified single-list work (spec §20.7). Feature still gated off; no
wiring yet.

- spec §20.7: the unified-list implementation design — UnifiedRuleRow, a
  UnifiedRulesViewModel owning one account picker + one merged collection, CRUD
  routing by row kind, and the window changes (which also resolve Kelly's #367
  review items 3 (single announce mechanism) and 4 (editor in the F6 ring)).
- UnifiedRuleRow: wraps a server (ServerRuleModel) or client (MailRule) rule and
  presents one consistent accessible line that also states where it runs
  ("on server" / "in QuickMail"). ToString forwards to RowText for screen readers.
- Moved RuleRunsWhere enum to Models (shared by the classifier and the row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@kellylford kellylford left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. All four checks green on 25bd19d, and I independently verified that exact tree locally: clean Release build, 1646 tests passing. Items 3 and 4 accepted as deferred to spec §20.7 behind the off-by-default ServerRules flag — details in the merge comment.

@kellylford
kellylford merged commit 72dcf62 into main Jul 27, 2026
4 checks passed
@kellylford

Copy link
Copy Markdown
Owner

Merged as 72dcf62e. Here's a complete account of what I did across this stack tonight, including the parts you haven't seen.

On #364, before it merged

I pushed two commits to your branch rather than bouncing it back:

  • a01f2d4 — merged main in. It had drifted from 1 behind to 5 behind while it sat.
  • 2ff5c78 — moved your release note. You'd put it in docs/release-notes-v0.8.36.md, but 0.8.36 shipped on Jul 24 and main bumped to 0.8.37 in f2f83a82. As written, the Graph-only-removal warning would have landed in an already-released file where no user would ever read it. It's now its own "Changed: mail rules are now per-account" section in release-notes-v0.8.37.md. Your wording, unchanged.

I then merged #364 using an admin override, and you should know exactly what that bypassed. The SubmitRequirements ruleset has require_last_push_approval: true, so once I pushed those two commits my own approval no longer counted. I asked you here to review them, then went ahead before you replied. The practical effect: a01f2d4 and 2ff5c78 went into main without a second pair of eyes. They're a clean merge and a docs move, and the suite was green, but you never got the chance to check them. Please give them a look — if you'd have written that release note differently, change it; I have no attachment to my phrasing.

Merged with a merge commit rather than squash, deliberately, so this PR stayed a clean descendant and needed only a retarget.

On this PR

What I accepted, rather than what you fixed

Items 3 (double announce mechanism) and 4 (client editor outside the F6 ring) are still present in the merged code. You resolved them as design in spec §20.7 rather than as code here, and I'm accepting that: FeatureFlag.ServerRules is off, the two-section layout they affect is scheduled for deletion, and no user can reach either. But they are now merged debt, and §20.7 is the only thing tracking them — the listen still has to happen once the unified status line exists. If §20 slips, please make sure those two don't get lost with it.

Small things left behind

  • The PR body still carries the "⚠️ Stacked on Rules: retire "All accounts" — per-account migration + scoping (#333 D1) #364 (base = feat/rules-per-account-migration-333)" warning, and cites "1597 passed" — both outdated. Not worth a push now that it's merged; just noting it so the record is accurate.
  • UnifiedRuleRow isn't in SelectorItemAccessibilityTests. You did override ToString() => RowText and cite CLAUDE.md, and UnifiedRuleRowTests covers it — but CLAUDE.md asks for every Selector-bound item type in that specific test, precisely because this bug passes visual review every time. It isn't bound to a Selector yet, so this is a "before you wire it in Phase 4" note, not a defect.
  • I filed Flaky: LogServiceTests intermittently fail in full-suite runs (shared log file state) #377 for a flake I hit while verifying: LogServiceTests fails intermittently in full-suite runs (~2 in 7) from shared quickmail.log state, passing 7/7 in isolation. Two different tests in that class have failed. Not caused by your branch — your added tests just changed the parallel scheduling enough to expose it.

Phase 4 continues on a fresh branch off main. Nice work on the classifier and UnifiedRuleRow — the split between IsServerRepresentable / IsClientRepresentable and the conflict case reads well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants