Skip to content

fix(797): persist Folder Settings and repair the User Email lookup - #804

Merged
drmoisan merged 5 commits into
mainfrom
bug/folder-settings-never-persist-797
Sep 7, 2026
Merged

fix(797): persist Folder Settings and repair the User Email lookup#804
drmoisan merged 5 commits into
mainfrom
bug/folder-settings-never-persist-797

Conversation

@drmoisan

@drmoisan drmoisan commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Fix Folder Settings persistence bootstrap gap and the unretried Exchange SMTP lookup

Summary

  • Folder Settings values (Archive Root Outlook, Archive Root File System, Junk Potential, Junk Email) now persist across Outlook restarts. On a machine where StoresWrapper.json has never existed — which is every fresh install — the fresh-build path now adopts the resource-defined disk configuration, so the first Save creates the file.
  • SmartSerializable<T>.Serialize() no longer returns silently when the configured disk path is empty or null. It logs at error level, so the defect class that produced this bug can no longer be invisible.
  • An explicit Save is no longer lost when Outlook closes inside the 3-second deferred-write window. Explicit saves write through a synchronous path instead of waiting on the single-shot timer.
  • User Email no longer renders the generic "Error Loading" placeholder. The lookup falls back through the Exchange primary SMTP address, the address entry, and the store display name, reports a specific message carrying the failure reason when all sources fail, and retries rather than resolving once at startup.
  • The junk-folder reflection lookup is replaced by a typed seam, IJunkFolderSelectionSink, and the failed-cast path now logs at error level rather than returning silently.
  • Two smaller defects in the same files are fixed: a null Current store selection renders the placeholder instead of throwing, and Inbox / Root Folder are displayed without the leading \\ store prefix.

Why

Two independent root causes were confirmed by code read and by a captured session log.

Root cause 1 — a bootstrap gap between the loader and the serializer. SmartSerializableBase.Deserialize<T,U>(loader) copies the loader's disk configuration onto the instance only inside its instance is not null branch. When the settings file does not exist, deserialization returns null, that branch is skipped, and the configuration is discarded. The loader then builds a fresh wrapper whose Config.Disk.FilePath is the path-helper default of "". Serialize() guarded on FilePath != "" and returned without writing and without logging. Because the file was never written, every subsequent start took the same null path, so the settings could never bootstrap themselves. In-session persistence appeared to work only because the values lived in the in-memory wrapper.

Root cause 2 — an unretried COM failure. GetSmtpAddressFromStore threw a COMException reading the current user from the store session. The exception was caught and converted to null, and the controller rendered null as "Error Loading". The lookup ran once during initialization and was never retried, so a single transient COM failure produced a permanently wrong display for the rest of the session.

The silent-return behaviour is what made this expensive: the defect produced no diagnostic signal at all, which is why it is fixed alongside the persistence bug rather than after it.

What Changed

Core persistence and serialization

  • UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs — error-level logging when the disk path is empty or null; a synchronous write path so an explicit Save does not depend on the deferred timer.
  • TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs — the fresh-build path adopts the loader's already-resolved disk configuration.

Store wrapper and the Folder Settings dialog

  • UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs — SMTP fallback chain and a refresh entry point.
  • UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs — null-Current guard; retry on populate; specific unavailability text carrying the failure reason.
  • UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs (new) — display projection helpers, including the store-prefix trim.

Typed seam replacing reflection

  • UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs (new).
  • TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs — implements the seam.

Tests

  • New: UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs, UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs.
  • Extended: StoreWrapperControllerTests.cs, StoreWrapperController_Tests.ButtonAndPopulate.cs, StoreWrapperTests.cs, TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs.

Project filesUtilitiesCS.csproj and UtilitiesCS.Test.csproj gain compile entries for the new files. These projects are non-SDK-style with explicit compile lists and no wildcard globbing, so the entries are hand-added. The change is additions only; no existing entry was removed or reordered.

Architecture / How It Fits Together

The settings file is owned by StoresWrapper, which derives from SmartSerializable<StoresWrapper>, so the Config the serializer reads is literally the instance property the loader populates. The fix restores the broken link in that chain: the loader's resolved configuration is copied onto the fresh instance via CopyFrom, which propagates down to the path helper's backing field. Serialize() then sees a real path and writes.

The junk-folder seam inverts a previously reflective dependency. UtilitiesCS declares IJunkFolderSelectionSink; the TaskMaster globals object implements it; the controller consumes the interface. The cast failure is now an error-level log rather than a silent return.

The display concerns were extracted into a partial class so the controller file did not grow further.

Verification

Completed (all recorded as evidence artifacts under the feature folder):

  • CSharpier format and check: exit 0, 1605 files checked, zero unformatted.
  • .NET analyzers, /t:Rebuild with EnableNETAnalyzers and EnforceCodeStyleInBuild: exit 0, 0 Error(s), 0 warnings.
  • Nullable / warnings-as-errors, /t:Rebuild with TreatWarningsAsErrors: exit 0, 0 Error(s), 0 warnings.
  • MSTest via vstest across UtilitiesCS.Test and TaskMaster.Test with /InIsolation: 5262 total, 5262 passed, 0 failed, 0 skipped.
  • Coverage: baseline 53.23% (44426/83466), post-change 53.26% (44489/83537), changed-line coverage 91.09% (92 of 101 executable changed lines). lines-valid moved 0.085%, so the two documents are comparable and no-regression holds. Each new member measures at full line and branch coverage except GetSmtpAddressFromStore at 0.8710 line / 0.9444 branch.
  • Feature review: policy audit PASS, code review PASS, feature audit PASS, 0 blocking findings.

Coverage authority note: every coverage run here is scoped to the two test assemblies this change touches, which is a narrower denominator than the full-suite denominator the repository-wide floor is written against. The binding gates for this change are therefore the same-scope no-regression comparison and the changed-line figure, both of which pass. The absolute percentage under this narrowed scope is a pre-existing condition that this change neither created nor resolved.

Not verified in this PR — AC3 requires observing a saved value survive a full Outlook restart. That needs a live VSTO host and cannot be performed in an automated environment. The procedure is recorded step by step, marked not performed, and handed to the maintainer at evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md. Its acceptance checkbox is deliberately left unchecked.

Recommended before merge:

dotnet tool run csharpier check .
msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true
msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true

Backward Compatibility / Migration Notes

  • No public API is removed or renamed. IJunkFolderSelectionSink is additive.
  • On first run after this change, a settings file is created where previously none existed. Existing installs that somehow do have the file are unaffected: the populated path continues to be used.
  • The junk-folder selections remain persisted by two mechanisms, per the recorded design decision. Divergence between them is a known, accepted consequence checked during manual verification rather than resolved here.

Risks and Mitigations

  • Deferred-write ordering. The synchronous save path re-arms the single-shot guard, which can fire a redundant second timer up to 3 seconds early. Both interleavings were traced; no interleaving drops an explicit save. The redundant write is idempotent.
  • UI-thread work in the save path. The new save path takes a write lock and performs file I/O on the UI thread. Bounded to explicit Save actions.
  • Synchronous COM read on the UI thread. The User Email retry reintroduces a blocking Outlook property read at populate time, attempted only when the address is still null. See Follow-ups: the bound is looser than originally documented.
  • Rollback. The change is confined to seven production files and is revertable as a unit; no data migration is performed.

Review Guide

  1. SmartSerializable.cs and AppOlObjects.StoreLoading.cs — the persistence fix and the highest-risk ordering change.
  2. StoreWrapper.cs and StoreWrapperController.cs — the fallback chain and the retry gate.
  3. IJunkFolderSelectionSink.cs and AppOlObjects.JunkFolders.cs — the seam replacing reflection. Note that the explicit interface implementation's unqualified self-named call binds to the internal method rather than recursing, because explicit implementations are excluded from member lookup.
  4. StoreWrapperController.Display.cs — mechanical extraction plus the prefix trim.
  5. Tests and project files last; the .csproj diff is four added lines.

Follow-ups

  • The AC6 retry bound is documented as "at most once per dialog open", but PopulateWithCurrent is reached from the store re-selection handler, which fires on every selection change. A failed retry leaves the address null, so the gate stays open. The real bound is one blocking COM chain per populate on a still-null store. This does not affect the acceptance criterion as written, which requires only retry-on-open, but the in-code comments and the specification prose overstate the bound and should be corrected.
  • A genuinely non-blocking SMTP read remains out of scope.
  • The serializer file is 658 lines, over the 500-line cap. This was pre-existing at 613 lines and is deliberately not split here.
  • A separate intermittent ETL test failure observed in this repository is tracked independently and is unrelated to these files; it did not fire in any run of this change.

GitHub Auto-close

🤖 Generated with Claude Code

drmoisan and others added 5 commits September 7, 2026 00:20
Preparation-mode outputs for issue #797, Folder Settings values never persisting and User Email rendering a generic placeholder. No production or test source is changed by this commit.

Includes the promoted bug record, the issue document carrying the eight maintainer-settled acceptance criteria verbatim, the research findings, the specification, and the atomic plan (7 phases, 72 tasks).

The plan passed the MCP plan validator on four successive revisions and cleared atomic-executor preflight after four review rounds, which resolved 35 findings. Notable ones: the repository-local .NET SDK, the dotnet-coverage global tool and the packages directory are all absent and are now bootstrapped in Phase 0; msbuild and vstest are resolved through vswhere rather than assumed on PATH; the new controller partial needs a nullable pragma or the type-check gate fails on CS8632; and the coverage helper must derive the test-assembly module exclusion in memory, because coverage.config alone would place test code in the denominator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two independent root causes made Settings -> Folder Settings unusable on any
machine where StoresWrapper.json had never been created.

Root cause 1, the bootstrap gap between the loader and the serializer:

- AC1 LoadStoresAsync now applies the already-resolved loader configuration to
  the freshly built stores wrapper, so the wrapper carries the resource-defined
  path instead of the FilePathHelper default empty string. The fix is confined
  to the store-loading globals partial; the shared deserialize overload is
  unchanged because its null return is a load-bearing fail-soft contract for the
  folder-predictor load path.
- AC2 SmartSerializable<T>.Serialize() rejects a null or empty configured path
  with an error-level log naming the serialized item type and the rejected
  value, instead of returning silently. The previous guard compared only against
  the empty string, so a null path passed it and reached the write path.
- AC4 a new SerializeNow entry point writes inline through the existing
  thread-safe write method rather than through the three-second deferred timer,
  so an explicit Save is not lost when Outlook exits inside that window. The
  deferred behaviour for every other caller is unchanged and is pinned by its
  own test. The AC2 guard runs first so the fix does not substitute one silent
  failure for another.
- AC5 the junk-folder call site no longer reflects over a method name. A new
  narrow IJunkFolderSelectionSink interface in UtilitiesCS is implemented
  explicitly by the TaskMaster globals partial, so the call is compile-checked
  and a globals implementation without the seam is reported at error level.

Root cause 2, the unretried COM failure in the Exchange SMTP lookup:

- AC6 GetSmtpAddressFromStore falls back in a fixed order with per-step COM
  handling instead of one outer catch: the Exchange primary SMTP address, then
  the address entry address when it contains an at-sign, then the store display
  name when it does. The failure reason is captured, the dialog renders a
  specific unavailability message naming that reason instead of the generic
  placeholder, and the lookup is retried once per dialog open when the address
  is null.

Adjacent defects in the same rendering method:

- AC7 Inbox and Root Folder render without the leading store prefix.
- AC8 a null current store selection renders the existing placeholder text
  instead of throwing, in both PopulateWithCurrent and GetRelativeFsPath.

The store wrapper controller stood at 478 lines against the 500-line cap with
four criteria landing in it, so its three rendering members were relocated
verbatim into a new display partial before any behavioural edit. The relocation
was proven behaviour-preserving on its own.

Declared test-expectation change: PopulateWithCurrent_NullCurrent_SetsErrorLoadingText
previously asserted a NullReferenceException, contradicting its own name. AC8
changes that behaviour, so the assertion is inverted to require that the act does
not throw and that the placeholder literals render. The new assertion is stricter
than the original.

AC3, that a saved value survives an Outlook restart, requires a live VSTO host
and is not automatable here. It is left unchecked and handed to the maintainer
with a nine-step manual procedure and a fail-before exception dossier.

Verification: csharpier format and check, the analyzer rebuild and the
warnings-as-errors rebuild all exit 0 with zero warnings and zero errors. The
scoped run over the two affected assemblies is green at 5262 passed and 0
failed, against 5237 at baseline; all sixteen previously failing tests pass.
Changed-line coverage is 91.09 percent and document-level line coverage moved
from 53.23 to 53.26 percent under the same scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@drmoisan
drmoisan merged commit 206a3f7 into main Sep 7, 2026
5 checks passed
drmoisan added a commit that referenced this pull request Sep 7, 2026
Files the advisory CR-1 finding from PR #804's code review as a tracked
bug: the User Email retry is documented as once-per-dialog-open but runs
on every store re-selection while the lookup keeps failing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

Bug: folder-settings-never-persist-and-user-email-error-loading

1 participant