Skip to content

feat: mail over Microsoft Graph becomes its own module - #1737

Merged
rbuergi merged 5 commits into
mainfrom
feat/email-graph-module
Aug 17, 2026
Merged

rbuergi merged 5 commits into
mainfrom
feat/email-graph-module

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Tier 2 of the modular factor-out — the biggest dependency win available.

The prize, measured

Microsoft.Graph + Kiota was the heaviest dependency in the portal image: 43 MB across ten assemblies, carried by every deployment for four files of code. It also costs at runtime — KernelScriptReferences documents Microsoft.Graph.dll as a 41 MiB native metadata block in every Roslyn script reference set, named there as a direct cause of CI memory-pressure flakes.

After the move, measured on a Release build of the Monolith:

  • Zero Microsoft.Graph* / Microsoft.Kiota* assemblies in the app output.
  • All ten in modules/MeshWeaver.Mail.MicrosoftGraph/ (43.6 MB).

What moved, and what deliberately did not

Moved: GraphMail, GraphEmailSender, EmailInboundProcessor, GraphSubscriptionService, ExecutiveAssistantPlugin, and the change-notification webhook.

Stayed in the host — everything mail-shaped that never touched the SDK: InvitationEmailSender, OutboundEmailSender, NoOpEmailSender, and EaGraphAuth with its consent controller (that flow is raw OAuth over HTTP). The seam already existed: IEmailSender/EmailOptions live in the mesh contract and HubEmailExtensions resolves the sender optionally.

Three couplings designed rather than moved

1. IEmailSender resolution is order-independent. Two host services call GetRequiredService<IEmailSender>(), so "module absent + Email:Enabled=true" would throw at startup. The host now registers the no-op with TryAddSingleton while the module uses plain AddSingleton — whichever runs first, the last registration wins for GetRequiredService and TryAdd declines when one exists.

2. 🚨 The internal-query back-door, and the one security-relevant change here. EmailInboundProcessor read through IMeshQueryCore — an internal interface documented as "raw queries without user context … must not be exposed to application code". Reaching it from a module means granting InternalsVisibleTo on the mesh contract, re-opening precisely the IVT coupling #1686 retired. Both call sites now use the public IMeshService.Query with WellKnownUsers.System — the same viewer-independent read CourseAssetEndpoints already performs, and System holds Permission.All so the result set is unchanged.

This changes HOW an unauthenticated inbound email finds its recipient, not WHICH recipient it finds — please review that specifically. If you would rather it not travel with a packaging change, the alternative is leaving intake in the host; the SDK still leaves the image, since the sender and EA tools are the other SDK users.

3. The consent route had exactly one definition, and still does. The EA's "please connect" link took its path from EaConsentController (a host MVC controller). IEaGraphAuth now exposes ConnectPath, with the host implementation surfacing the controller's constant — so the module references no host type and the route is still defined once. IEaGraphAuth itself moved to the mesh contract; it is deliberately SDK-free (strings and bools).

The webhook

An MVC controller became a minimal-API endpoint, because the module endpoint hook maps route handlers, not controllers (a module's controller would additionally need its assembly registered as an MVC ApplicationPart). Same route, verb, shapes and status codes. AllowAnonymous because Graph posts unauthenticated and the shared clientState is the guard — and because module routes are otherwise authenticated by default.

Verification

  • -c Release -p:CIRun=true -warnaserror clean for the module, Memex.Portal.Monolith and Memex.Portal.Shared.Test.
  • 6/6 email tests green.
  • Boot smoke test: POST /api/email?validationToken=mw-probe-123 answers 200 text/plain with the token echoed and no redirect — the Graph subscription handshake working anonymously through the module contribution. No endpoint-collision refusal at startup.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1

Everything that speaks to Microsoft Graph — the system-email sender, inbound mail intake with its
change-notification webhook, and the Executive Assistant's mailbox tools — moves into
MeshWeaver.Mail.MicrosoftGraph. Microsoft.Graph was the heaviest dependency in the image at 43 MB
across ten assemblies, carried by every deployment for four files of code, and
KernelScriptReferences documents Microsoft.Graph.dll as a 41 MiB NATIVE metadata block in every
Roslyn script reference set — named there as a direct cause of CI memory-pressure flakes.

Measured after the move: ZERO Microsoft.Graph/Kiota assemblies in the app output; all ten in
modules/MeshWeaver.Mail.MicrosoftGraph/ (43.6 MB).

The seam already existed — IEmailSender and EmailOptions live in the mesh contract and
HubEmailExtensions resolves the sender OPTIONALLY — so everything mail-shaped that never touched
the SDK stayed in the host: InvitationEmailSender, OutboundEmailSender, NoOpEmailSender, and
EaGraphAuth with its consent controller (that flow is raw OAuth over HTTP, not an SDK call).

Three couplings had to be designed rather than moved:

1. IEmailSender resolution is now ORDER-INDEPENDENT. Two host services do
   GetRequiredService<IEmailSender>, so "module absent + Email:Enabled" would have thrown at
   startup. The host registers the no-op with TryAddSingleton while the module uses plain
   AddSingleton: whichever runs first, the last registration wins for GetRequiredService and
   TryAdd declines when one exists, so the module wins when listed and the no-op covers when not.

2. EmailInboundProcessor read through IMeshQueryCore — an INTERNAL interface documented as "raw
   queries without user context … must not be exposed to application code". Reaching it from a
   module would mean granting InternalsVisibleTo on the mesh contract, re-opening exactly the IVT
   coupling #1686 retired. Both call sites now use the public IMeshService.Query with
   WellKnownUsers.System, which is the same viewer-independent read CourseAssetEndpoints already
   performs (System holds Permission.All, so the result set is unchanged). 🚨 This is the one
   security-relevant change in the PR: it alters HOW an unauthenticated inbound email finds its
   recipient, not WHICH recipient it finds.

3. The EA's "please connect" link took its path from EaConsentController, a host MVC controller.
   IEaGraphAuth now exposes ConnectPath and the host implementation surfaces the controller's
   constant, so the consent route still has exactly ONE definition and the module references no
   host type. IEaGraphAuth itself moved to the mesh contract — it is deliberately SDK-free.

The webhook was an MVC controller and is a minimal-API endpoint here: the module endpoint hook
maps route handlers, not controllers. Same route, verb, shapes and status codes, AllowAnonymous
because Graph posts unauthenticated and the shared clientState is the guard.

Verified: Release -warnaserror clean for the module, the Monolith and the portal test project;
6/6 email tests green; and a boot smoke test where POST /api/email?validationToken=… answers
200 text/plain with the token echoed and no redirect — the Graph subscription handshake working
anonymously through the module — with no endpoint-collision refusal at startup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1
Copilot AI lite review requested due to automatic review settings August 17, 2026 11:34
# Conflicts:
#	memex/Memex.Portal.Monolith/appsettings.json
#	memex/MeshModulesPublish.targets
#	memex/aspire/Memex.Portal.Distributed/appsettings.json

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR modularizes the Microsoft Graph–backed mail implementation into a dedicated MeshWeaver.Mail.MicrosoftGraph module so deployments that don’t send/receive mail no longer carry the heavy Microsoft.Graph/Kiota dependency footprint.

Changes:

  • Introduces MeshWeaver.Mail.MicrosoftGraph as a first-class module (project + module/endpoint providers), moving Graph mail transport, inbound intake, the /api/email webhook, and the EA mailbox tools into it.
  • Moves the SDK-free IEaGraphAuth seam into MeshWeaver.Mesh.Contract and updates host wiring to keep the OAuth consent/token flow in the host while letting module code link to the consent route via ConnectPath.
  • Updates solution/build/publish wiring and docs (Modules inventory + What’s New), and removes the host MVC webhook controller + host-local IEaGraphAuth definition.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/MeshWeaver.Mesh.Contract/IEaGraphAuth.cs Adds SDK-free EA delegated-auth seam to the contract, including ConnectPath.
src/MeshWeaver.Mail.MicrosoftGraph/README.md Documents purpose/activation of the new mail module.
src/MeshWeaver.Mail.MicrosoftGraph/MeshWeaver.Mail.MicrosoftGraph.csproj New module project carrying Microsoft.Graph and module dependencies.
src/MeshWeaver.Mail.MicrosoftGraph/MailMicrosoftGraphModuleAttribute.cs Adds module + endpoint providers and central DI registration entry point.
src/MeshWeaver.Mail.MicrosoftGraph/GraphSubscriptionService.cs Moves subscription keepalive into the module (namespace relocation).
src/MeshWeaver.Mail.MicrosoftGraph/GraphMail.cs Moves Graph client wrapper into the module (namespace relocation).
src/MeshWeaver.Mail.MicrosoftGraph/GraphEmailSender.cs Updates EA consent link to go through IEaGraphAuth.ConnectPath (host decoupling).
src/MeshWeaver.Mail.MicrosoftGraph/ExecutiveAssistantPlugin.cs Updates EA “connect” link composition to use IEaGraphAuth.ConnectPath.
src/MeshWeaver.Mail.MicrosoftGraph/EmailWebhookEndpoints.cs Replaces host MVC webhook with a module-contributed minimal API endpoint.
src/MeshWeaver.Mail.MicrosoftGraph/EmailInboundProcessor.cs Replaces internal-query usage with System-identity IMeshService.Query.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-mail-over-microsoft-graph-is-a-module.md Adds What’s New entry for the module extraction.
src/MeshWeaver.Documentation/Data/Architecture/Modules.md Adds the new module to the module inventory list.
MeshWeaver.slnx Adds the new module project to the solution.
memex/MeshModulesPublish.targets Adds module project to the module publish closure.
memex/Memex.Portal.Shared/MemexConfiguration.cs Removes Graph mail registrations from host; keeps no-op fallback via TryAddSingleton.
memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj Removes Microsoft.Graph package reference from the host shared project.
memex/Memex.Portal.Shared/Email/EmailWebhookController.cs Removes host MVC controller (webhook moved into module).
memex/Memex.Portal.Shared/Authentication/IEaGraphAuth.cs Removes host-local auth seam (now in contract).
memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs Implements ConnectPath on the host-side auth implementation.
memex/Memex.Portal.Shared/Authentication/EaConsentController.cs Updates imports for the moved IEaGraphAuth.
memex/Memex.Portal.Monolith/appsettings.json Ensures the monolith loads the new module assembly.
memex/aspire/Memex.Portal.Distributed/appsettings.json Ensures the distributed portal loads the new module assembly.
Suppressed comments (3)

src/MeshWeaver.Mail.MicrosoftGraph/EmailInboundProcessor.cs:93

  • IMeshService.Query is taken with .Take(1) without filtering to the initial/reset snapshot. For deterministic viewer-independent reads (and to avoid consuming a later delta emission), filter ChangeType to Initial/Reset before Take(1) (same pattern as other System-identity snapshots).

This issue also appears on line 161 of the same file.
src/MeshWeaver.Mail.MicrosoftGraph/EmailInboundProcessor.cs:163

  • Same issue as the user lookup above: this query takes the first emission without constraining it to the initial/reset snapshot. Filtering to Initial/Reset makes the conversation match deterministic and avoids consuming a later delta emission.
    src/MeshWeaver.Mail.MicrosoftGraph/GraphEmailSender.cs:89
  • The XML doc for ConnectAsUserHref contains two consecutive <summary> blocks, and the first summary describes a boolean rather than a link. This makes generated docs confusing and can break doc tooling.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    65 files  ±0      65 suites  ±0   46m 5s ⏱️ +51s
10 761 tests ±0  10 455 ✅ ±0  306 💤 ±0  0 ❌ ±0 
11 148 runs  +1  10 842 ✅ +1  306 💤 ±0  0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

…undProcessor

EmailThreadKeyTests pins EmailInboundProcessor.ThreadKey, and the project referenced only
Memex.Portal.Shared — which no longer holds the type. Caught by CI's "Build solution (once)":
per-project builds structurally cannot see a cross-assembly type move, which is why a PR that
moves public types needs a full solution build (MW_ALLOW_SOLUTION_BUILD=1, the override the
local guard documents for exactly this case) before it is pushed. Now verified: whole solution
Release -warnaserror, 0 Error(s) 0 Warning(s).

Swept every moved type across src/, memex/, test/, samples/ and tools/ rather than only the
one CI named: the remaining mentions of GraphMail, EmailInboundProcessor and GraphEmailSender
are comments (Teams' pool remarks, MeshNodeExtensions' in-process-writer note, Graph.Test's
discriminator note). IEmailSender's doc no longer claims the sender lives in the portal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1
@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 21s ⏱️ +2s
2 101 tests ±0  1 905 ✅ ±0  196 💤 ±0  0 ❌ ±0 
2 488 runs  +1  2 292 ✅ +1  196 💤 ±0  0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

920 tests  ±0   919 ✅ ±0   11m 9s ⏱️ -1s
 10 suites ±0     1 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 371 tests  ±0   1 370 ✅ ±0   6m 7s ⏱️ ±0s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 731 tests  ±0   1 730 ✅ ±0   8m 11s ⏱️ + 1m 0s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 400 tests  ±0   2 396 ✅ ±0   7m 27s ⏱️ -3s
   11 suites ±0       4 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

2 238 tests  ±0   2 135 ✅ ±0   7m 47s ⏱️ -10s
   11 suites ±0     103 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 364b865. ± Comparison against base commit a11a82c.

♻️ This comment has been updated with latest results.

Three places said "nine assemblies" while the PR body and What's New said ten. Both numbers were
measured, from different globs: nine match Microsoft.Graph*/Microsoft.Kiota*, which is what the
app-root `du` counted; the module folder receives TEN, the tenth being
Microsoft.IO.RecyclableMemoryStream, a private dep of the Graph client.

Now stated as ten everywhere, with the composition named so the two counts can't drift apart
again. A measurement that appears twice with different values stops being evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1
@rbuergi

rbuergi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in fc372d3 — and you were right that it needed reconciling rather than picking one.

Both numbers were measured, from different globs. Nine matched Microsoft.Graph*/Microsoft.Kiota*, which is what the app-root du counted. The module folder receives ten: the tenth is Microsoft.IO.RecyclableMemoryStream, a private dep of the Graph client that the glob simply didn't match.

It now says ten everywhere, with the composition spelled out so the two counts can't silently diverge again. A measurement that appears twice with different values stops being evidence, which is the real cost here.

# Conflicts:
#	memex/Memex.Portal.Monolith/appsettings.json
#	memex/MeshModulesPublish.targets
#	memex/aspire/Memex.Portal.Distributed/appsettings.json
#	src/MeshWeaver.Documentation/Data/Architecture/Modules.md
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