Email Ingestion, Channels & the Notification System

Memex talks to people over real-world channels. Two directions:

This document covers both, the NodeTypes involved, configuration, and how to send a notification from your own code (with a runnable sample). For the outbound credential/Graph setup specifically, see SendingEmail.md; for the onboarding gate see InvitationOnlyOnboarding.md.


1. Ingestion — mail as a chat device

A person emails the portal mailbox (e.g. memex@systemorph.com). A Microsoft Graph change-notification subscription on that inbox calls back to the portal, which turns each message into an agent conversation.

Pipeline

inbound mail ──▶ Graph subscription ──▶ POST /api/email (webhook)
                                              │
                                              ▼
                                   EmailInboundProcessor.Route
                                              │
                  ┌───────────────────────────┴───────────────────────────┐
            sender is a known Memex user                        sender is anyone else
                  │                                                        │
   Email node {recipient}/_Email/{id}                         Email node Admin/Inbox/{id}
                  └───────────────────────────┬───────────────────────────┘
                                              ▼
                        claim ▸ find-or-start a thread ON the Email node
                            ({emailPath}/_Thread/{id}, Email as MainNode)
                                              ▼
                        the Email Router agent works it, and either replies
                        (an Outbound Email node) or forwards it to info@

This is the mail instance of one pattern the platform runs three times — a red log becomes a LogIncident and a triage thread, a ticket becomes a thread, a delivered message becomes a thread. Both mail lanes are the same code; the difference is only whose partition the mail lands in and whether the agent acts on the sender's behalf.

Key properties:

Moving parts (code)

Everything inbound rides the MeshWeaver.Mail.MicrosoftGraph module (Systemorph/MeshWeaver.Plugins src/), because the Graph SDK is 43 MB a deployment that sends no mail should not carry. Only the outbound drain and the no-op fallback are compiled into the portal.

Concern Type
Reactive Graph client (read message, mark read, manage subscription) GraphMail (module)
Webhook endpoint (/api/email) — validation echo + notification batch EmailWebhookEndpoints (module)
Keeps the Graph subscription alive (create on ApplicationStarted, renew every 24 h) GraphSubscriptionService (module)
Claim, route, find-or-start the thread, notify EmailInboundProcessor (module)
The deterministic ids the claims rest on InboundMailIdentity (module)
Forward a shared-inbox mail to info@ InboxForward / the MemexInbox agent tool (module)
Outbound: drains Email nodes with Direction=Outbound, Status=New and sends them OutboundEmailSender (portal)

Startup ordering matters. Both hosted services defer their work to IHostApplicationLifetime.ApplicationStarted: the Graph subscription can only be created once Kestrel is listening (Graph validates the webhook URL synchronously), and the outbound watcher can only query the mesh once the Orleans client + mesh hub are up. Touching the hub in StartAsync races startup and NREs — don't.


2. Ingest channels (extensible transport)

The pipeline below the transport is transport-agnostic (inbound message → find-or-create thread → agent → reply), so each channel is just an adapter onto it — not a second pipeline:


3. The notification system

A notification is "something happened that a person might care about". Memex decides — per the recipient's own rules — whether it's worth telling them and through which channel(s).

NodeTypes

Type Owner Path Purpose
Notification system {entity}/_Notification/{id} the in-app bell item (always-on default channel)
NotificationChannel user {username}/_NotificationChannel/{id} a delivery channel the user has: InApp / Email / Teams (+ optional target)
NotificationRule user {username}/_NotificationRule/{id} a plain-English (or lightly structured) rule: which events go to which channel

Notification is registered in the AddGraph() chain (AddNotificationType) — the bell exists in every deployment. NotificationChannel / NotificationRule ride the MeshWeaver.Notifications.Channels module together with the triage watcher (Modules:Assemblies in production; AddNotificationChannels() for explicit composition — see Modules). A deployment without the module keeps the bell and the deterministic email preferences; it has no rules/channels lane.

Triage agent

The Notification Triage agent runs on the chat model tier (fast + cheap — the everyday round; see Model Tiers). Given an event and a recipient it:

  1. loads the recipient's enabled NotificationRules and NotificationChannels,
  2. applies the rules (plain English is the recipient's intent — honored; order resolves precedence),
  3. decides the channel set (empty is common and fine — most events are not worth an email), and
  4. creates the delivery node(s): an in-app Notification, and/or an Outbound Email (and Teams once that transport exists).

Defaults when a user has no rules: in-app only — never escalate to email/Teams without a rule asking for it, and never notify a user about their own action.

Example rules (what a user writes)

"Send approval requests to my Teams right away. Batch general thread completions to my work email. Don't notify me about anything I did myself."

The user creates one NotificationRule node per rule (or several intents in one RuleText) under {username}/_NotificationRule, plus the NotificationChannel nodes they reference.


4. How to send — from your own code

Three levels, cheapest first.

a) Fire a one-off email (no node)

The simplest path — resolve IEmailSender (registered when Email:Enabled=true) or use the hub extension:

// IObservable<bool> — cold; you MUST subscribe.
mesh.SendEmail("alice@example.com", "Build finished", "<p>Your build is green ✅</p>")
    .Subscribe(ok => { /* sent */ }, ex => logger.LogWarning(ex, "send failed"));

Create an Outbound Email node; OutboundEmailSender drains it (claims New → Sending, sends, flips to Sent/Failed). Dedup + restart-safety live in the node's status — no in-memory queue.

workspace.GetMeshNodeStream($"{recipient}/_Email/{Guid.NewGuid()}").Update(_ =>
    new MeshNode("Email", $"{recipient}/_Email/{id}")
    {
        NodeType = EmailNodeType.NodeType,
        Content = new Email
        {
            Direction = EmailDirection.Outbound,
            To = "alice@example.com",
            Subject = "Build finished",
            Body = "<p>Your build is green ✅</p>",
            Status = EmailStatus.New,
        }
    }).Subscribe(_ => { }, ex => logger.LogWarning(ex, "queue failed"));

c) Notify through the recipient's rules (let triage decide)

Raise a notification and let the triage agent route it to whatever channels the recipient configured — this is the right call when you shouldn't hard-code the channel. Create the in-app Notification (the bell) and/or hand the event to triage; triage creates the channel deliveries.


5. Runnable sample — "email me a test notification" button

Drop this in as a Code MeshNode (a layout area). Rendering it shows a button; clicking it sends an email to the signed-in user via IEmailSender. This is the smallest end-to-end proof of the outbound path.

using System.Reactive.Linq;
using MeshWeaver.Layout;
using MeshWeaver.Mesh;                 // IEmailSender
using MeshWeaver.Mesh.Security;        // AccessService — who am I
using Microsoft.Extensions.DependencyInjection;

public static class EmailNotificationSample
{
    public static object TestEmailButton(LayoutAreaHost host, RenderingContext _)
    {
        var sp     = host.Hub.ServiceProvider;
        var access = sp.GetRequiredService<AccessService>();
        var sender = sp.GetService<IEmailSender>();     // null-safe: NoOp when Email:Enabled=false
        var me     = access.Context?.Name ?? access.CircuitContext?.Name;   // the signed-in user's email

        return Controls.Stack
            .WithView(Controls.Markdown(me is null
                ? "Sign in to email yourself a test notification."
                : $"Send a test notification to **{me}**."))
            .WithView(Controls.Button("Email me a test notification")
                .WithClickAction(ctx =>
                {
                    if (sender is null || me is null)
                    {
                        ctx.Host.UpdateData("emailResult", "Email is not configured (Email:Enabled=false).");
                        return Task.CompletedTask;
                    }
                    sender.SendEmail(me,
                            "Memex test notification",
                            "<p>👋 This is a test notification sent from Memex when you pressed the button.</p>")
                        .Subscribe(
                            ok => ctx.Host.UpdateData("emailResult", ok ? $"Sent to {me} ✅" : "Send returned false."),
                            ex => ctx.Host.UpdateData("emailResult", $"Send failed: {ex.Message}"));
                    return Task.CompletedTask;
                }))
            .WithView((h, _) => h.Stream.GetDataStream<string>("emailResult")
                .Select(msg => (object?)Controls.Markdown(msg ?? "")));
    }
}

Notes:


6. Configuration

All keys live under the Email section (env-var form uses __). Outbound needs only the first block; inbound adds the subscription block. The client secret comes from Key Vault in prod (email-clientsecret → Email__ClientSecret), never from a checked-in file.

Key Meaning
Email:Enabled master switch — false registers a NoOp sender (local dev/tests never send)
Email:MailboxAddress the mailbox to send/receive as (e.g. memex@systemorph.com)
Email:TenantId / Email:ClientId / Email:ClientSecret app-only Graph credential (Mail.Send + Mail.ReadWrite)
Email:UseManagedIdentity use a managed identity instead of a client secret (prod)
Email:InboundEnabled turn on the inbound channel (Graph subscription + webhook)
Email:WebhookBaseUrl public base URL Graph calls back (e.g. https://portal.example.com)
Email:SubscriptionClientState shared secret echoed on each inbound notification (webhook validation)
the model the triage agent runs on is DATA, not config: label one model node "tier": "chat" (Model Tiers). The deprecated ModelTier:Light key still works.

Deploy parameters (Memex.Deploy.AppHost → the Deployment record's email block, WithEmail(…)) map 1:1: email-enabled, email-mailbox-address, email-tenant-id, email-client-id, email-inbound-enabled, email-webhook-base-url; the client secret and email-subscription-client-state are secrets (WithSecret under Aspire, the record's keyVaultSecrets map on AKS) — ConfiguringAnInstanceFromAspire.

Graph permissions: the shared-mailbox app registration needs the application permissions Mail.Send and Mail.ReadWrite with tenant-admin consent, and a real licensed/shared mailbox it may act as. Missing consent → Graph 403. See SendingEmail.md. (The Executive Assistant is separate — it uses per-user delegated scopes on the sign-in app, not these application permissions; see ExecutiveAssistant.md.)


7. Executive Assistant — a mail & calendar agent

The Executive Assistant agent gives each user a personal assistant over their own mailbox and calendar (triage/write mail, "do my booking"). Unlike the shared memex@ ingestion mailbox — which uses an application Graph credential — the EA acts with per-user, just-in-time delegated consent: the user grants the EA access to their own mailbox/calendar only when they first use the tool, and every Graph call targets /me/… with that user's own delegated token. No standing application-wide grant.

See ExecutiveAssistant.md for the full design (consent flow, the EaCredential encrypted-token store, tools) and the one-time Azure setup (delegated scopes + the /auth/ea/callback redirect URI on the sign-in app).

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.