Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MeshWeaver.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
<Project Path="src/MeshWeaver.Kernel.Hub/MeshWeaver.Kernel.Hub.csproj" />
<Project Path="src/MeshWeaver.Kernel/MeshWeaver.Kernel.csproj" />
<Project Path="src/MeshWeaver.Layout/MeshWeaver.Layout.csproj" />
<Project Path="src/MeshWeaver.Mail.MicrosoftGraph/MeshWeaver.Mail.MicrosoftGraph.csproj" />
<Project Path="src/MeshWeaver.Markdown/MeshWeaver.Markdown.csproj" />
<Project Path="src/MeshWeaver.Markdown.Collaboration/MeshWeaver.Markdown.Collaboration.csproj" />
<Project Path="src/MeshWeaver.Markdown.Export/MeshWeaver.Markdown.Export.csproj" />
Expand Down
1 change: 1 addition & 0 deletions memex/Memex.Portal.Monolith/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"MeshWeaver.AI.Copilot.dll",
"MeshWeaver.AI.WebSearch.dll",
"MeshWeaver.Courses.dll",
"MeshWeaver.Mail.MicrosoftGraph.dll",
"MeshWeaver.Blazor.Radzen.dll",
"MeshWeaver.Blazor.Analysis.dll",
"MeshWeaver.Blazor.GoogleMaps.dll",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using MeshWeaver.Mesh; // IEaGraphAuth — the SDK-free seam, now in the mesh contract
using MeshWeaver.Messaging; // AccessService
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand Down
5 changes: 5 additions & 0 deletions memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ public sealed class EaGraphAuth(
private string Authority => $"https://login.microsoftonline.com/{TenantId}/oauth2/v2.0";

/// <summary>True when the sign-in app credentials needed for the delegated flow are configured.</summary>
/// <inheritdoc />
// The route stays defined ONCE, by the controller that registers it; this only
// surfaces it through the seam so the module-side EA tools can link to it.
public string ConnectPath => EaConsentController.ConnectPath;

public bool IsConfigured => !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(ClientSecret);

/// <summary>The Microsoft consent/authorize URL to send the user to (incremental consent, forces the prompt).</summary>
Expand Down
25 changes: 0 additions & 25 deletions memex/Memex.Portal.Shared/Authentication/IEaGraphAuth.cs

This file was deleted.

86 changes: 0 additions & 86 deletions memex/Memex.Portal.Shared/Email/EmailWebhookController.cs

This file was deleted.

1 change: 0 additions & 1 deletion memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="NuGet.Versioning" />
<PackageReference Include="Microsoft.Graph" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
Expand Down
31 changes: 17 additions & 14 deletions memex/Memex.Portal.Shared/MemexConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,29 +138,32 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder)
services.AddSingleton(emailOptions);
if (emailOptions.Enabled)
{
services.AddSingleton<IEmailSender, GraphEmailSender>();
// Executive Assistant: per-user JUST-IN-TIME delegated Graph access (the user consents to the
// EA touching THEIR OWN mailbox/calendar only when they first use the tool — no standing app
// permission). EaGraphAuth drives the consent/token flow; the plugin uses the per-user token.
services.AddHttpClient<Authentication.IEaGraphAuth, Authentication.EaGraphAuth>();
services.AddSingleton<MeshWeaver.AI.Plugins.IAgentPlugin, ExecutiveAssistantPlugin>();
// EA touching THEIR OWN mailbox only when they first use the tool — no standing app
// permission). EaGraphAuth drives the consent/token flow; it is raw OAuth over HTTP, so it
// stays HERE with its consent controller — the EA's mailbox TOOLS (which do use the Graph
// SDK) ride the MeshWeaver.Mail.MicrosoftGraph module and depend only on this seam.
services.AddHttpClient<IEaGraphAuth, Authentication.EaGraphAuth>();
// The notification triage runner (escalates in-app notifications to email/Teams per each
// recipient's NotificationRules) rides the MeshWeaver.Notifications.Channels module
// (Modules:Assemblies); its hosted service self-skips unless Email:Enabled.
}
else
services.AddSingleton<IEmailSender, NoOpEmailSender>();

// 🚨 TryAdd, deliberately. The Graph sender lives in the MeshWeaver.Mail.MicrosoftGraph
// module, which registers it with a plain AddSingleton. The pairing is ORDER-INDEPENDENT:
// whichever runs first, the LAST registration of a service type is what GetRequiredService
// returns and TryAdd declines when any registration already exists. Module listed ⇒ the
// Graph sender wins; module absent ⇒ this no-op keeps OutboundEmailSender and
// InvitationEmailSender — both of which GetRequiredService<IEmailSender> — resolvable
// instead of throwing at startup.
services.TryAddSingleton<IEmailSender, NoOpEmailSender>();

// Inbound email→agent channel (intake). Mail is treated as a chat device: each inbound email
// finds-or-creates a conversation thread and appends its latest message (referencing the email
// by path). The Graph subscription self-skips unless Email:Enabled && Email:InboundEnabled.
services.AddSingleton<GraphMail>(sp => new GraphMail(
sp.GetRequiredService<EmailOptions>()));
services.AddSingleton<EmailInboundProcessor>(sp => new EmailInboundProcessor(
sp.GetRequiredService<PortalApplication>().Hub,
sp.GetRequiredService<GraphMail>(),
sp.GetService<Microsoft.Extensions.Logging.ILogger<EmailInboundProcessor>>()));
services.AddHostedService<GraphSubscriptionService>();
// (GraphMail, EmailInboundProcessor and the Graph subscription watcher ride the
// MeshWeaver.Mail.MicrosoftGraph module, together with POST /api/email — the change
// notification webhook — which it contributes through MapMeshModuleEndpoints.)
// Mesh-driven reply sender: drains agent-emitted Outbound Email nodes (Status=New) via Graph.
services.AddHostedService<OutboundEmailSender>();
// Mesh-driven invitation emailer: emails any Pending Invitation node not yet emailed
Expand Down
1 change: 1 addition & 0 deletions memex/MeshModulesPublish.targets
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<MeshModuleClosure Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.AI.Copilot/MeshWeaver.AI.Copilot.csproj" />
<MeshModuleClosure Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.AI.WebSearch/MeshWeaver.AI.WebSearch.csproj" />
<MeshModuleClosure Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Courses/MeshWeaver.Courses.csproj" />
<MeshModuleClosure Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Mail.MicrosoftGraph/MeshWeaver.Mail.MicrosoftGraph.csproj" />

<!-- Alternative storage backends. These were BORN flipped: no host has ever referenced them
(only their own test projects do), and both attributes state the intent verbatim —
Expand Down
1 change: 1 addition & 0 deletions memex/aspire/Memex.Portal.Distributed/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"MeshWeaver.AI.Copilot.dll",
"MeshWeaver.AI.WebSearch.dll",
"MeshWeaver.Courses.dll",
"MeshWeaver.Mail.MicrosoftGraph.dll",
"MeshWeaver.Blazor.Radzen.dll",
"MeshWeaver.Blazor.Analysis.dll",
"MeshWeaver.Blazor.GoogleMaps.dll",
Expand Down
1 change: 1 addition & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ The current first-party inventory and each module's configuration section:
| `MeshWeaver.Notifications.Channels.dll` | Notification delivery channels (rule/channel node types + AI triage escalation) | `Email` (triage self-skips unless `Email:Enabled`) |
| `MeshWeaver.Social.dll` | LinkedIn publishing: connect/publish/page-sync endpoints + node-menu actions | `Social:LinkedIn` |
| `MeshWeaver.Courses.dll` | Course delivery: the entitlement-gated `/assets/{Space}/…` route over a Space's synced repo | `GitHub:App:*` (shared with GitSync) |
| `MeshWeaver.Mail.MicrosoftGraph.dll` | Mail over Microsoft Graph: system email, inbound intake + its webhook, the Executive Assistant's mailbox tools | `Email` (`Enabled`, `InboundEnabled`) |
| `MeshWeaver.Hosting.Grpc.dll` | The mesh gRPC transport: `meshweaver.v1.Mesh` + gRPC-web, `py`/`node` foreign participants AND the React GUI's browser data plane | `Grpc` (`TrustedPort`) |
| `MeshWeaver.Hosting.Cosmos.dll` | Cosmos DB storage backend (keyed adapter factory + native query) | selected by `Graph:Storage:Type` = `Cosmos` |
| `MeshWeaver.Hosting.Snowflake.dll` | Snowflake storage backend (persistence, change feed, cross-schema query, access projection) | selected by `Graph:Storage:Type` = `Snowflake` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
Name: Mail over Microsoft Graph is a module
Category: Feature
Description: System email, inbound mail intake and the Executive Assistant's mailbox tools moved into their own module, taking the 43 MB Microsoft Graph SDK out of every deployment that does not send mail.
Icon: Mail
Order: -20260817
---

# Mail over Microsoft Graph is a module

Everything that talks to Microsoft Graph — sending system email, taking delivery of inbound mail,
and the Executive Assistant's mailbox tools — now ships as `MeshWeaver.Mail.MicrosoftGraph`
instead of being compiled into every portal.

The Microsoft Graph SDK was the heaviest dependency in the image: **43 MB across ten assemblies**,
carried by every deployment for the benefit of the ones that send mail. It also cost at runtime,
where `Microsoft.Graph.dll` alone materializes a 41 MiB block of native metadata in the script
engine's reference set — previously identified as a direct contributor to memory pressure.

Nothing changes for a portal that sends mail: it lists the module, keeps the same `Email`
configuration, and the webhook, senders and EA tools behave exactly as before. A portal that does
not send mail now carries none of it, and its invitation and outbound-mail services keep working
against the built-in no-op sender rather than failing to start.

Everything mail-shaped that never touched the SDK stayed where it was — the invitation emailer,
the outbound drain, and the per-user consent flow that lets the assistant act on your own mailbox.
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
using Microsoft.Extensions.Logging;
using Microsoft.Graph.Models;

namespace Memex.Portal.Shared.Email;
namespace MeshWeaver.Mail.MicrosoftGraph;

/// <summary>
/// Routes an inbound email by sender, with all matching driven through <see cref="IMeshQueryCore"/> —
/// Routes an inbound email by sender, with all matching driven through System-identity queries —
/// no in-memory registries.
/// <list type="bullet">
/// <item><b>email → user:</b> structured exact query <c>nodeType:User content.email:{from}</c>.</item>
Expand All @@ -44,7 +44,6 @@ public sealed class EmailInboundProcessor(

private readonly IMeshService meshService = hub.ServiceProvider.GetRequiredService<IMeshService>();
private readonly AccessService accessService = hub.ServiceProvider.GetRequiredService<AccessService>();
private readonly IMeshQueryCore query = hub.ServiceProvider.GetRequiredService<IMeshQueryCore>();

/// <summary>A parsed inbound email — the Graph-free input to <see cref="Route"/>.</summary>
public sealed record InboundMessage(
Expand Down Expand Up @@ -80,10 +79,16 @@ public IObservable<Unit> Route(InboundMessage m)
if (string.Equals(m.From, graphMail.Mailbox, StringComparison.OrdinalIgnoreCase))
return Observable.Return(Unit.Default);

// email → user: structured exact match through IMeshQueryCore.
return query.Query<MeshNode>(MeshQueryRequest.FromQuery(
$"nodeType:{UserNodeType.NodeType} content.email:{m.From} limit:1"),
hub.JsonSerializerOptions)
// email → user: structured exact match, read AS SYSTEM. An inbound email arrives with no
// viewer, so the lookup must see every user regardless of who (nobody) is signed in. This
// used the INTERNAL IMeshQueryCore — "raw queries without user context", explicitly
// documented as not for application code — which is unreachable from a module without
// granting it InternalsVisibleTo on the mesh contract. WellKnownUsers.System is the public
// equivalent (Permission.All, so nothing is filtered out) and the same shape the course-asset
// endpoint uses for its own viewer-independent reads.
return meshService.Query<MeshNode>(MeshQueryRequest.FromQuery(
$"nodeType:{UserNodeType.NodeType} content.email:{m.From} limit:1",
WellKnownUsers.System))
.Take(1)
.Select(change => change.Items.FirstOrDefault(n => n.State == MeshNodeState.Active))
.SelectMany(userNode => userNode is not null
Expand Down Expand Up @@ -151,7 +156,9 @@ private IObservable<Unit> HandleNonUser(InboundMessage m)
var safeSubject = SanitizeForQuery(subject);
var q = $"{safeSubject} nodeType:{EmailNodeType.NodeType} namespace:{username}/{EmailNodeType.UserEmailSegment} limit:10";
var jsonOptions = hub.JsonSerializerOptions;
return query.Query<MeshNode>(MeshQueryRequest.FromQuery(q), jsonOptions)
// Same viewer-independent read as above (was IMeshQueryCore): the thread being searched for
// belongs to the recipient, not to any signed-in viewer.
return meshService.Query<MeshNode>(MeshQueryRequest.FromQuery(q, WellKnownUsers.System))
.Take(1)
.Select(change => change.Items
.Select(n => EmailOf(n, jsonOptions))
Expand Down
Loading
Loading