Repository navigation
feat: mail over Microsoft Graph becomes its own module - #1737
Conversation
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
# Conflicts: # memex/Memex.Portal.Monolith/appsettings.json # memex/MeshModulesPublish.targets # memex/aspire/Memex.Portal.Distributed/appsettings.json
There was a problem hiding this comment.
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.MicrosoftGraphas a first-class module (project + module/endpoint providers), moving Graph mail transport, inbound intake, the/api/emailwebhook, and the EA mailbox tools into it. - Moves the SDK-free
IEaGraphAuthseam intoMeshWeaver.Mesh.Contractand updates host wiring to keep the OAuth consent/token flow in the host while letting module code link to the consent route viaConnectPath. - Updates solution/build/publish wiring and docs (Modules inventory + What’s New), and removes the host MVC webhook controller + host-local
IEaGraphAuthdefinition.
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.Queryis taken with.Take(1)without filtering to the initial/reset snapshot. For deterministic viewer-independent reads (and to avoid consuming a later delta emission), filterChangeTypetoInitial/ResetbeforeTake(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/Resetmakes the conversation match deterministic and avoids consuming a later delta emission.
src/MeshWeaver.Mail.MicrosoftGraph/GraphEmailSender.cs:89 - The XML doc for
ConnectAsUserHrefcontains 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.
…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
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
|
Fixed in fc372d3 — and you were right that it needed reconciling rather than picking one. Both numbers were measured, from different globs. Nine matched 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
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 —KernelScriptReferencesdocumentsMicrosoft.Graph.dllas 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:
Microsoft.Graph*/Microsoft.Kiota*assemblies in the app output.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, andEaGraphAuthwith its consent controller (that flow is raw OAuth over HTTP). The seam already existed:IEmailSender/EmailOptionslive in the mesh contract andHubEmailExtensionsresolves the sender optionally.Three couplings designed rather than moved
1.
IEmailSenderresolution is order-independent. Two host services callGetRequiredService<IEmailSender>(), so "module absent +Email:Enabled=true" would throw at startup. The host now registers the no-op withTryAddSingletonwhile the module uses plainAddSingleton— whichever runs first, the last registration wins forGetRequiredServiceandTryAdddeclines when one exists.2. 🚨 The internal-query back-door, and the one security-relevant change here.
EmailInboundProcessorread throughIMeshQueryCore— aninternalinterface documented as "raw queries without user context … must not be exposed to application code". Reaching it from a module means grantingInternalsVisibleToon the mesh contract, re-opening precisely the IVT coupling #1686 retired. Both call sites now use the publicIMeshService.QuerywithWellKnownUsers.System— the same viewer-independent readCourseAssetEndpointsalready performs, and System holdsPermission.Allso 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).IEaGraphAuthnow exposesConnectPath, with the host implementation surfacing the controller's constant — so the module references no host type and the route is still defined once.IEaGraphAuthitself 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.
AllowAnonymousbecause Graph posts unauthenticated and the sharedclientStateis the guard — and because module routes are otherwise authenticated by default.Verification
-c Release -p:CIRun=true -warnaserrorclean for the module,Memex.Portal.MonolithandMemex.Portal.Shared.Test.POST /api/email?validationToken=mw-probe-123answers200 text/plainwith 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