Platform
Mailbox Watcher
Watch an Exchange mailbox and run workflow actions when messages arrive — the email mirror of the File Watcher. Graph change-notification subscriptions for near-real-time detection, a delta-query reconciliation loop as the safety net, and the same two-phase action pipeline.
| Status | In Progress |
| Last updated | 2026-07-14 |
| Related |
Platform Workflow Engine · Microsoft Platform Module IT Inventory |
Overview
Several city processes receive their data by email: an external vendor emails lightning-strike reports for the area, and program staff forward records that were entered in external systems so they don't have to re-key the data into MyGov. Today those messages dead-end in a human inbox.
The Mailbox Watcher makes an email arrival a first-class workflow trigger, exactly parallel to the File Watcher: an admin configures a mailbox to watch, attaches an ordered list of workflow actions, and the system detects new messages, seeds a workflow context from the message (sender, subject, body, attachments), and runs the actions through the existing WorkflowActionRunner. Every existing handler — MsSql, Http, Cityworks, TeamsNotify, conditions — works unchanged.
Detection is push, not poll. The service registers a Microsoft Graph change notification subscription on the watched folder; Microsoft POSTs to a MyGov endpoint when mail arrives. Subscriptions have a bounded lifetime, so the hosted service renews them on a schedule (the subscription is on an interval; the detection is not). A reconciliation loop backs the subscription up so a missed notification never means a lost message.
Design Principle — Mirror the File Watcher
The File Watcher trio (it.FileWatcherJob / FileWatcherAction / FileWatcherLog) is proven: config row → ordered actions implementing IWorkflowAction → per-item log row that doubles as the deduplication guard, with a two-phase execution split (Phase 1 synchronous and mission-critical, Phase 2 queued fire-and-forget). The Mailbox Watcher deliberately reuses that shape one-for-one so admins, the portal UI, and the runner all treat it as "the same thing with a different source":
| File Watcher concept | Mailbox Watcher equivalent |
|---|---|
Watched directory (WatchPath) | Watched mailbox + folder (MailboxAddress, FolderPath, default Inbox) |
FileSystemWatcher events | Graph change-notification subscription (push from Microsoft) |
| 30-second reconciliation directory scan | Graph delta query reconciliation loop (catches missed notifications; also the poll-only fallback mode) |
Dedup on (JobId, FileName) | Dedup on (JobId, MessageKey) — Graph immutable message id |
MoveOnProcess → /processed / /failed subdirectories | Disposition on process → move to Processed / Failed subfolders of the watched folder |
Phase 1 / Phase 2 split via IsPhaseOne | Identical — Phase 1 runs before the message is dispositioned; failure aborts the move |
| Context seeded from parsed XML + file metadata | Context seeded from message envelope + body + attachment metadata |
WorkflowTrigger.FileWatcherJob | New WorkflowTrigger.MailboxWatcherJob + a matching StartWorkflow overload |
Detection Architecture
1. Graph change-notification subscription (primary)
Per enabled watcher, the hosted service maintains one Graph subscription on /users/{mailbox}/mailFolders('{folder}')/messages with changeType: created. Mechanics that shape the design:
- Public HTTPS endpoint. Graph must be able to POST to our
notificationUrlfrom the internet, and it performs a validation handshake (echovalidationTokenwithin 10 seconds) at subscription create/renew time. The API is public, so it hosts the routes directly; Microsoft's notification ranges are allowed in the core firewall/WAF policy (see Settled Decisions). - Bounded lifetime, renewed on an interval. Outlook message subscriptions currently allow up to 7 days (10,080 minutes). The hosted service runs a renewal loop that PATCHes
expirationDateTimewell before expiry (e.g. renew when <24h remain, checked hourly). The "renew every 30 minutes" experience from EWS push/streaming notifications does not apply here — Graph subscriptions are long-lived. We treat the max lifetime as data, not a constant: renewal margin is generous so a Microsoft-side change or a weekend outage doesn't silently kill detection. clientStatesecret. Each subscription carries a per-watcher random secret; the notification endpoint rejects payloads whoseclientStatedoesn't match. This is the authenticity check on inbound notifications (the endpoint is anonymous by necessity).- Lifecycle notifications. The subscription also registers a
lifecycleNotificationUrlso Graph can tell us aboutreauthorizationRequired,subscriptionRemoved, andmissedevents — each triggers an immediate renew/recreate plus a reconciliation pass rather than waiting for the loops. - Basic notifications, not rich. The notification payload carries only the message's resource id — no content. On receipt we GET the full message via Graph. Rich notifications (content in the payload) require certificate-based payload encryption for negligible benefit here; one extra GET per message is the simpler, chosen path.
- Fast ACK, queued work. The notification endpoint validates
clientState, writes/claims the log row, returns202immediately, and enqueues processing onBackgroundTaskQueue— Graph throttles or drops subscriptions whose endpoints respond slowly.
2. Delta-query reconciliation (safety net + fallback)
Notifications are best-effort; Microsoft documents that consumers must reconcile. Each watcher keeps a Graph deltaLink for its folder. A reconciliation loop (default every 5 minutes, configurable) walks the delta since the last sync and processes anything not already in the log — the exact analog of the File Watcher's 30-second directory rescan. Two extra jobs this loop does:
- Poll-only mode. A watcher whose subscription can't be established (dev environments without a public endpoint, or an infrastructure outage) still works — detection latency just degrades to the reconciliation interval. This is a per-watcher
Mode(Subscription|PollOnly), withSubscriptionmode automatically falling back to the poll while the subscription is unhealthy. - Startup recovery. On service start, reconcile before (re)creating subscriptions — anything that arrived during downtime is picked up, mirroring
RecoverStaleLogsAsync.
Subscription health is observable
Subscription id, expiry, last-renewed, last-notification-received, and current mode/health are persisted on the job row and surfaced in the portal — a watcher silently degraded to polling should be visible at a glance, not discovered from latency complaints.
Data Model (it schema)
it.MailboxWatcherJob
| Field | Notes |
|---|---|
Id, Name, Description, IsEnabled | As FileWatcherJob. |
MailboxAddress | The watched mailbox (shared mailbox expected; any mail-enabled account works). |
FolderPath | Display name of the watched folder, default Inbox — human-readable only (portal lists/forms), never used to call Graph. Watching a subfolder lets Exchange transport rules pre-sort traffic before we ever see it. |
FolderId | The folder's real Graph folder id — the value actually used by every reconciliation/subscription/disposition call. Set by the portal's folder picker at create/edit time (added 7/15 after discovering every single call was re-resolving the folder by name); for jobs saved without one (pre-picker, or created directly via the API) the hosted service resolves it by FolderPath on first use and caches the result back here, so the fallback lookup only ever runs once per job. |
FromFilter, SubjectFilter | Optional coarse filters (substring/address match) applied at detection. Messages that don't match are logged as Skipped and dispositioned without running actions. Fine-grained routing stays in workflow condition actions — these exist only so a noisy shared inbox doesn't burn action runs. |
CredentialId | FK to it.Credential — the Graph app registration used for this watcher (ClientId/ClientSecret/Namespace=TenantId), resolved through ITokenService. Same pattern as every other Graph consumer. |
Mode | Subscription (default) or PollOnly. |
MoveOnProcess | Per-job, default true (mirrors FileWatcher). When true, processed messages are moved to a Processed subfolder (failures to Failed) and marked read — the inbox stays meaningful as "not yet handled". When false, messages stay in place and get an Outlook category instead, for mailboxes humans also work out of where a move would disrupt them. |
IncludeAttachments, MaxAttachmentMb | Whether/how attachment content is materialized for the workflow — see Processing Pipeline. |
Subscription state: GraphSubscriptionId, SubscriptionExpiresAt, ClientState (encrypted), DeltaLink, LastNotificationAt, LastRenewedAt, LastSubscriptionError | Operational state owned by the hosted service; visible read-only in the portal. LastSubscriptionError (added 7/15) is the most recent create/renew failure message, cleared on the next success — persisted, not just logged, because a silently-degraded Subscription-mode job (create/renew failures were originally logged at Warning, not Error) went unnoticed in real usage. The subscription creation/renewal failure paths now log at Error too. |
LastMessageReceived, LastMessageStatus | As FileWatcherJob — the at-a-glance health fields. |
it.MailboxWatcherAction
Identical shape to FileWatcherAction: Name, ActionType, Config (same Op/Data/FieldMap JSON schema), Order, per-action CredentialId override, IsPhaseOne. Implements IWorkflowAction so the runner and the portal action editor are shared, not copied.
it.MailboxWatcherLog
| Field | Notes |
|---|---|
MessageKey | Graph immutable id (requested with Prefer: IdType="ImmutableId") — stable across folder moves, which matters because we move messages on process. Unique with MailboxWatcherJobId: the dedup guard, claimed with an insert before processing so a notification and a reconciliation pass racing on the same message can't double-run. |
InternetMessageId | The RFC 5322 id — kept for cross-system correlation and human debugging, not for dedup (senders can reuse/blank it). |
Subject, FromAddress, ReceivedAt | Envelope snapshot for the log list view. |
DetectedAt, ProcessedAt, Status, ErrorMessage | Same lifecycle as FileWatcherLog (Detected → Processing → DbComplete → Success / Failed, plus Skipped for filter misses). |
TriggerEvent | Notification | Reconciliation | Manual (portal replay). |
WorkflowRunId | Links to WorkflowLog rows, same as today. |
Processing Pipeline
- Detect (notification or reconciliation) → claim the log row (
Detected). Filter check →Skipped+ disposition if no match. - Fetch the full message via Graph; build the workflow context.
- Phase 1 — run
IsPhaseOneactions synchronously. Failure →Failed, message moved toFailedfolder, operator email (existing runner notification path). No Phase 2. - Disposition — move to
Processedsubfolder + mark read (whenMoveOnProcess). - Phase 2 — remaining actions queued to
BackgroundTaskQueue, sharing the run id.
Workflow context seeding
Mirrors the file trigger's "parsed content + source metadata" split:
| Context key | Value |
|---|---|
Message.Subject, Message.From, Message.FromName, Message.To | Envelope fields, nested under Message rather than context root. Subject/From/To are a reserved cross-handler convention (MailHandler, MSGraphHandler's SendMail/SendTemplate read a root-level From as "send using this identity instead of the default," and MailHandler reads root Subject/To the same way) — seeding the *incoming* message's sender/subject at root collided with that when a mailbox-watched workflow chained into a Mail/MSGraph send action. A workflow that needs to forward the original sender/subject into an outbound send does so explicitly (e.g. a Data action copying $.Message.From to $.From) rather than inheriting it silently. |
ReceivedAt | Not reserved elsewhere, stays at context root. |
BodyText | Plain-text rendering of the body (HTML stripped) — what MsSql/Http field maps will usually consume. |
_RawBody | Original body (HTML or text) for handlers that parse structure themselves — the analog of _RawXml. |
Attachments | Array of { Name, ContentType, SizeBytes, FilePath? }. When IncludeAttachments is on, contents under the size cap are written to a per-job working directory and FilePath is set — so attachment-borne data (a vendor CSV/XML) flows through the same file-shaped handler patterns the File Watcher already exercises. |
MessageKey, MailboxWatcherJobId, DetectedAt | Provenance, as the file trigger seeds FileName/FilePath/job id. |
If the vendor's payload turns out to be a structured attachment rather than the body, no design change is needed — the actions just map from the attachment file instead of BodyText.
Extraction Pipeline — Config-Driven Text-to-Entity
Decision: unstructured email bodies are parsed via a generic, config-driven TextExtract workflow handler, not a one-off typed handler per source. The rule of thumb: flat-ish records → config; genuinely relational/transactional documents → a typed handler (the CAD ingestion handler is the precedent for the latter — eight related tables, MERGE upserts, terminal-state guards, deadlock retries; no config schema was ever going to express that). Every mailbox source seen so far — a lightning report, a program-registration forward — is flat fields per record, which is exactly what config can express. Onboarding source #3, #4, #5 then becomes a portal exercise (new job + action config), not a C# handler + API deploy.
A message may contain one or more records (e.g. several lightning strikes in one report). Multiplicity is handled structurally, not as an edge case: the extractor always emits an array, and the write step always fans out over it.
1. TextExtract.Extract — segment, then extract typed fields
Two-stage config. Segment splits the source text into record scopes:
StartPattern(regex, required) — marks where each record scope begins.ClosePattern(regex, optional) — marks where a scope ends; scanning resumes after it. Omitted → a scope runs to just before the next unconsumedStartPatternmatch, or end of text.Filter(regex, optional) — applied to each scope's text; non-matching scopes are excluded (not failed) — this is how a greeting/signature paragraph falls out without special-casing.
Start/Close patterns were chosen over a fixed Mode enum (paragraph/table-row/…) deliberately — strictly more flexible, same functionality, and the "pick a mode" UX still exists as portal-side presets that fill in the two patterns (see Development Tasks). Presets: Paragraph — Start (?:\A|\n[ \t]*\n)\s*(?=\S), no Close (anchors only at true paragraph boundaries — a wrapped multi-line paragraph is one scope, not one per line). HTML table row — Start <tr[^>]*>, Close </tr>. Whole message — Start \A, no Close.
Fields then runs per surviving scope: each output field is a Pattern (regex; first capture group, or whole match if none), a Type (string | number | boolean | datetime), an optional Format (.NET date format, for datetime), and Required (default false). Geospatial coordinates are just two number fields — no special-casing in the extractor; what lat/lng means is a write-step/location-layer concern.
Failure is loud and message-scoped (the dedup/log unit is the whole message, not a record): a Required field missing or unparseable in any surviving scope fails the entire action, naming the record index and the pattern that missed. Zero scopes surviving segmentation/filtering also fails — a message that matched the job's From/Subject filters but yielded nothing is a format change worth an alert, not a silent no-op.
Example — a paragraph-per-strike lightning report
{
"Op": { "Action": "Extract", "Source": "$._RawBody", "Target": "$.Records" },
"Segment": {
"StartPattern": "(?:\\A|\\n[ \\t]*\\n)\\s*(?=\\S)",
"Filter": "strike"
},
"Fields": {
"OccurredAt": { "Pattern": "on (\\d{2}/\\d{2}/\\d{4} at \\d{2}:\\d{2}:\\d{2})", "Type": "datetime", "Format": "MM/dd/yyyy 'at' HH:mm:ss", "Required": true },
"StrikeType": { "Pattern": "a[n]? (cloud-to-ground|intra-cloud) strike", "Type": "string", "Required": true },
"Latitude": { "Pattern": "coordinates (-?\\d{1,2}\\.\\d+)", "Type": "number", "Required": true },
"Longitude": { "Pattern": "coordinates -?\\d{1,2}\\.\\d+,\\s*(-?\\d{1,3}\\.\\d+)", "Type": "number", "Required": true },
"Amplitude": { "Pattern": "([\\d.]+) ?kA", "Type": "number", "Required": false }
}
}
Source/Target are context paths, matching the convention used everywhere else in the engine (TemplateHelper.ResolvePath). Target names the single top-level context key the output object is written to — $.Records → context key Records; blank defaults to $.Extract. Nested target paths aren't supported (the engine has no path-based context writer today — every handler writes to one top-level key).
Output is one namespaced object, not a bare array, so future additions (e.g. a debug _RawItems with each scope's raw text) stay contained rather than polluting root context:
"Records": {
"Items": [
{ "OccurredAt": "2026-07-14T18:32:07", "StrikeType": "cloud-to-ground", "Latitude": 32.609855, "Longitude": -85.480782, "Amplitude": 34.2 },
{ "OccurredAt": "2026-07-14T18:36:44", "StrikeType": "intra-cloud", "Latitude": 32.615210, "Longitude": -85.472099, "Amplitude": null }
],
"Count": 2
}
2. Write — MsSql.Merge, already array-aware, extended with MatchOn
No new write handler was needed — MsSql.Merge already resolves its Source to an array and upserts every row via a temp-table MERGE. The one gap: dedup. Re-sent/overlapping source reports need to update, not duplicate, and there's no primary key to match on — only a natural key (e.g. timestamp + coordinates). Merge gained an optional MatchOn config key: an array of column names forming a composite join key, replacing the prior single-column Id/Key/Name heuristic when present. Matched rows have every other shared column updated (important — a re-sent report should refresh amplitude/type/etc., not just no-op); unmatched rows insert. Progressive by construction: omitting MatchOn reproduces the exact prior SQL (same heuristic, same generated statement shape) — existing Merge configs are unaffected.
{
"Op": { "Action": "Merge", "Target": "[weather].[LightningStrike]", "Source": "$.Records.Items" },
"MatchOn": ["OccurredAt", "Latitude", "Longitude"]
}
Two implementation notes: (1) if a MatchOn set isn't actually unique, SQL MERGE throws on a multi-row match — the correct loud failure, not something to catch and smooth over. (2) Match-set columns should be typed decimal/datetime2/string, not raw float — floating-point equality invites silent duplicate rows from precision drift; a per-table design call at the C2 implementation step, not a config concern.
Deferred alternative, recorded for when a source needs it: a key-resolving step that looks up existing PKs and enriches the array before a PK-based Merge — necessary once child tables need the resolved parent key (CAD-shaped territory), not needed for flat single-table upserts.
Deferred engine upgrades this design leans on
Two workflow-engine capabilities were deliberately deferred in favor of a lighter substitute, each recorded in Workflow Engine so the eventual need doesn't get solved ad hoc: branching (onSuccess/onFail step routing — substitute today is strict ordering + the existing graceful-stop flag) and ForEach (running a non-write action once per extracted record, e.g. a per-strike Teams notification — substitute today is the array fan-out inside Merge itself, which covers every known ingestion case).
Services & Seams
| Piece | Responsibility |
|---|---|
MailboxWatcherHostedService | Owns watcher lifecycle: startup recovery, subscription create/renew, reconciliation loop, reload signal (mirror of IFileWatcherReloadSignal so portal edits take effect without restart), two-phase orchestration. |
ExchangeResourceService (extended) | Gains the Graph mail seam: subscription CRUD, delta queries, message fetch, move/categorize, attachment download — same patterns it already uses (ITokenService, named MicrosoftGraph HttpClient, tuple results). Decision: consolidate here rather than a sibling service; split the mail concern out later only if the file becomes genuinely unwieldy. |
| Notification endpoint | POST /webhooks/graph/mail (+ lifecycle endpoint) in the webhooks controller area: handshake echo, clientState validation, fast-ACK enqueue. Anonymous by necessity; authenticity = clientState + the fact that ids are opaque and re-fetched from Graph (a forged notification can at worst cause a no-op lookup). |
| Workflow engine touch | New WorkflowTrigger.MailboxWatcherJob enum member + StartWorkflow(MailboxWatcherJob, MailboxWatcherLog, context, actions, runId) overload. Handlers untouched. |
Portal (my/) | A mailbox-watchers module mirroring the file-watchers module: job list with health badges (subscription state, last message), job detail with ordered action editor (shared action-config components), log list with replay. Lives beside File Watchers under IT Admin — per the IT Inventory containment model, both are intrinsic functions of the API website entity. |
Security
- Least-privilege mailbox access is the headline risk. Application-permission
Mail.Read(andMail.ReadWritefor move/mark-read) is tenant-wide by default — the app could read any mailbox. Constrain it with an ApplicationAccessPolicy (or RBAC for Applications) scoping the app registration to a mail-enabled security group containing only watched mailboxes. Adding a watcher for a new mailbox = add the mailbox to the group; this is an IT provisioning step, surfaced in the portal as guidance, and a natural fit for the existingExchangeResourceRequestprovisioning-log pattern. - App registration — decision: reuse the existing API Graph app, adding
Mail.ReadWriteif it doesn't already carry it. The ApplicationAccessPolicy above is what keeps that grant from being tenant-wide, so scoping the policy is a prerequisite of the permission grant, not an optional hardening step. - Inbound notification trust: clientState secret per subscription (stored encrypted via
IEncryptionService), plus the re-fetch-from-Graph design meaning notification payloads are never trusted as data. - Email content is untrusted input. Body and attachment values flow into workflow field maps exactly like webhook payloads do today — parameterized MsSql handler writes are already the norm; the doc calls it out so nobody ever templates raw body text into SQL or HTML.
- Attachment hygiene: size cap per job, working directory cleaned after Phase 2, no execution of attachment content — files are data handed to handlers, nothing more.
Settled Decisions
| Decision | Outcome & reasoning |
|---|---|
| App registration | Reuse the existing API Graph app, adding Mail.ReadWrite if not already granted. One less registration to manage; the ApplicationAccessPolicy (Security section) is what keeps the grant from being tenant-wide, so it ships with the permission, not after it. Rejected: a dedicated app — cleaner blast-radius isolation, but not worth a second credential lifecycle here. |
| Service placement | Consolidate into ExchangeResourceService — it is already the Graph/Exchange seam with the right patterns (ITokenService, named client, tuple results). Split mail out into a sibling later only if the combined service becomes genuinely problematic. Rejected-for-now: sibling MailboxGraphService. |
| Message disposition | Move to Processed/Failed subfolders + mark read, as a per-job MoveOnProcess option defaulting true — mirrors the FileWatcher exactly. The off setting (leave in place + category) exists for mailboxes humans also work out of. |
| Notification payload style | Basic notifications + re-fetch, not rich notifications — avoids certificate-based payload encryption for the cost of one GET per message, and means inbound payloads are never trusted as data. |
| Dedup key | Graph immutable message id (stable across the folder moves we ourselves perform); InternetMessageId kept for correlation only. |
| Reconciliation cadence | 5-minute default, per-job override. In PollOnly mode this is the detection latency. |
| Notification endpoint | The API is public and hosts the anonymous POST /webhooks/graph/mail + lifecycle routes directly — no relay. Microsoft's notification service ranges get an allowance in the core firewall/WAF policy. Endpoint trust = per-subscription clientState secret + ids-only payloads re-fetched from Graph, so a forged notification is at worst a no-op lookup. |
Open Items
Details still needed (not decisions)
- The two concrete mailboxes/senders for v1 (lightning vendor + first program), and whether those are existing shared mailboxes or need provisioning.
- Sample messages from each source — filters, context mapping, and the Skipped-vs-processed boundary get designed against real payloads. In particular: does the lightning vendor put data in the body or in a CSV/XML/PDF attachment, and do the program forwards nest the original as an
.emlattachment (which adds an "unwrap forwarded message" step worth knowing about now)? - Expected volume (messages/day) — shapes nothing structural, but informs the reconciliation cadence and whether Skipped logging needs retention trimming.
Development Tasks
Ordered so each step is independently reviewable and the system is exercisable end-to-end as early as possible (poll-only detection works before the subscription plumbing exists). Provisioning steps are operator-run.
Phase A — Backend foundation
Done — backend builds clean end-to-end, migration staged and unapplied. Deferred out of this pass: the File Watcher's startup stale-log recovery (app-restart-mid-process cleanup) has no Mailbox Watcher analog yet — a restart mid-Phase-1 currently leaves a Processing row that the operator retries manually via A6's retry endpoint. Worth a follow-up task once real usage surfaces whether that's common enough to automate.
Real-usage fix (7/15): folder id caching + subscription-failure visibility. Real configuration surfaced two related gaps: (1) every reconciliation/subscription/disposition call was re-resolving FolderPath to a Graph folder id from scratch — no caching at all, an unnecessary Graph call on every single cycle. (2) A Subscription-mode job whose subscription failed to create degraded to permanent reconciliation-only polling silently — the failure paths only logged at Warning, so "no failures in the logs" was consistent with a genuinely broken subscription. Both fixed together: MailboxWatcherJob.FolderId now caches the resolved id (see Data Model), populated by a new portal folder picker (GET /mailbox-watcher/folders?mailboxAddress=, backed by ExchangeResourceService.ListMailFoldersAsync) rather than free-text entry, with a lazy self-caching fallback in the hosted service for jobs saved without one; subscription create/renew failures now log at Error and persist to the new LastSubscriptionError field, shown directly on the job detail page instead of requiring a log search.
| # | Task | Status | Scope & notes |
|---|---|---|---|
| A1 | Models + migration | Done | it.MailboxWatcherJob / MailboxWatcherAction (implements IWorkflowAction) / MailboxWatcherLog per the Data Model section, plus ReconciliationIntervalSeconds (per-job cadence override, called for in Settled Decisions but originally missed here). Unique index on (MailboxWatcherJobId, MessageKey). DbSets registered; EF migration AddMailboxWatcherTables generated and staged — not applied, per the workspace golden rule. |
| A2 | Graph mail seam in ExchangeResourceService |
Done | Subscription create / renew / delete; folder resolution + child-folder get-or-create; delta query with paging; message fetch with Prefer: IdType="ImmutableId"; move / mark-read / categorize; attachment metadata + capped content download. Same token, client, and tuple-result patterns as the existing methods. |
| A3 | Workflow engine trigger | Done | WorkflowTrigger.MailboxWatcherJob enum member; StartWorkflow(MailboxWatcherJob, MailboxWatcherLog, envelope, actions, runId, rawBody) overload seeding Message (Subject/From/FromName/To)/ReceivedAt/BodyText/Attachments/_RawBody/MessageKey. Handlers untouched. (7/15: Subject/From/To moved under Message — they collided with MailHandler/MSGraphHandler's reserved root-level sender-override convention.) |
| A4 | Detection pipeline + hosted service (poll path) | Done | MailboxWatcherHostedService: per-job reconciliation on its own cadence, claim-then-process dedup (insert-first), FromFilter/SubjectFilter → Skipped, Phase 1 → disposition → Phase 2 via BackgroundTaskQueue, attachment materialization under the size cap, reload signal (IMailboxWatcherReloadSignal, mirrors the File Watcher's). A PollOnly watcher is fully functional at this point. |
| A5 | Notification endpoints + subscription manager | Done | GraphMailWebhookController: POST /webhooks/graph/mail + /lifecycle, validation-token handshake, clientState check (encrypted at rest, compared via IEncryptionService), fast-ACK (202) + BackgroundTaskQueue enqueue. Hosted-service subscription loop: create-if-missing, renew inside the configured margin (default 24h, checked hourly), lifecycle-event handling (reauthorizationRequired → renew, subscriptionRemoved → clear for recreation, missed → immediate reconcile). A Subscription-mode job runs on the reconciliation timer until its subscription exists — the "automatic poll fallback" behavior falls out of the design rather than needing separate health-detection logic. |
| A6 | CRUD + operational endpoints | Done | MailboxWatcherJobController (mirrors the File Watcher controller surface): job/action CRUD, enable/disable, reload, log list/search, and a retry endpoint that reprocesses a message directly by its stored Graph message id (delta query won't resurface an already-seen message, so retry can't reuse the reconciliation path the way File Watcher's does). |
Phase B — Portal (my/)
Done — module builds on js/modules/mailbox-watcher/, mirrors js/modules/file-watcher/'s shape. Module doc: js/modules/mailbox-watcher/MAILBOX-WATCHER.md.
Two real bugs found in real usage (7/15) and fixed: (1) MailboxWatcherJobDetailPage.renderSections() wrapped <workflow-action-editor> in DetailPage._section('Workflow Actions', ...) — but the editor already renders its own .card + "Workflow Actions" header internally, so this doubled the heading/card visually. Fixed by returning the bare tag. (2) WorkflowActionEditor.actions was a plain instance property, not reactive — a parent page setting it after an async fetch (the universal pattern: `editor.actions = await api.getActions(id)`) raced the editor's own internal `connectedCallback()` metadata fetch; whichever resolved first silently won, and if the editor's own fetch won, the table rendered against the still-empty initial value with nothing to trigger a later re-render — surfaced as "workflow actions not loading on navigating back" (timing-dependent on cache state, so it didn't reproduce every time). Fixed by making actions a getter/setter backed by a private field that re-renders the table on every post-connection assignment — this fixes the shared component for every consumer (File Watcher, Webhook/ServerJob forms), not just this page. Both fixes verified in a real DOM by deliberately forcing each race ordering, not just by reasoning about the code.
This means the original "full interactive click-through wasn't run" caveat from the initial Phase B pass was exactly where these bugs were hiding — headless import/registration checks proved the code loads, not that it behaves correctly once actually used. Worth remembering for future modules.
| # | Task | Status | Scope & notes |
|---|---|---|---|
| B1 | Mailbox-watchers module | Done | MailboxWatcherJobsPage (list + health/detection badges + a create drawer — closing a gap File Watcher's own module still has) and MailboxWatcherJobDetailPage, built on DetailPage (the current portal standard, not File Watcher's older hand-rolled pattern) with a subscription-health block (Live/Degraded, expiry, last notification/renewal, LastSubscriptionError when present) that has no File Watcher analog. Edit drawer, enable/disable, reload (triggers immediate reconciliation), and delete are all wired. The shared create/edit form (MailboxWatcherJobForm) uses a real <mail-folder-select> picker (added 7/15, backed by the new folders endpoint) instead of free-text folder entry — reactive to the mailbox-address field, stores the folder's real Graph id directly. |
| B2 | Action editor + logs | Done | The shared <workflow-action-editor> reused as-is (timingMode="phase", pointed at /mailbox-watcher/{id}/action) — zero mailbox-specific code needed in the editor itself. MailboxWatcherLogsPage on EnhancedListPage (subject/from + expandable error detail, status filter incl. Skipped, date range, run-timeline link, retry) and MailboxWatcherRunDetailPage as a thin wrapper around the shared <workflow-run-timeline>. |
Phase C — Provisioning & first watchers
Not started — blocked on C1 (operator-run) and the sample messages noted below.
| # | Task | Scope & notes |
|---|---|---|
| C1 | Provisioning (operator) | Add Mail.ReadWrite to the API Graph app if not present; create the watched-mailboxes security group + ApplicationAccessPolicy; allow Microsoft's notification-service ranges in the core firewall/WAF policy; apply the A1 migration. |
| C2 | V1 watchers | Configure the lightning-vendor watcher and the first program-forwarding watcher against sample messages (filters, context mapping, action configs). Confirms the Skipped boundary and whether an unwrap-forwarded-.eml step is needed — feed findings back into this doc. |
Phase D — Extraction pipeline
D1/D2/D3 done. See the Extraction Pipeline section above for the full design.
| # | Task | Status | Scope & notes |
|---|---|---|---|
| D1 | TextExtractHandler |
Done | api/Classes/Workflow/Handlers/TextExtractHandler.cs — Extract operation: Start/Close-pattern segmentation (with a next-start fallback when Close is omitted), Filter exclusion, typed field extraction (string|number|boolean|datetime), loud required-field/zero-scope failures, single top-level-key output ({ Items, Count }). Registered in DI alongside the other handlers. 5 xunit tests in Api.Tests/TextExtractHandlerTests.cs exercise it through the real ExecuteAsync path (not private helpers) — paragraph segmentation with multiple records, type coercion, HTML table-row segmentation via Start+Close, the required-field failure message, and the zero-surviving-scopes failure. All passing; full solution build clean. |
| D2 | MsSql.Merge — MatchOn |
Done | Optional composite-key config on the existing Merge operation (api/Classes/Workflow/Handlers/MsSqlHandler.cs). Progressive: absent MatchOn reproduces the prior single-column Id/Key/Name-heuristic SQL exactly (verified — the join-column and update-clause logic is now list-based but behaves identically for the N=1 case). New guards: MatchOn columns must exist in both source data and the target table (clear error naming the missing column(s) otherwise); if the match columns are the entire merged row (nothing left to update), the generated SQL now omits the WHEN MATCHED branch entirely rather than emitting an invalid empty UPDATE SET. |
| D3 | Admin editor support for TextExtract / MatchOn |
Done | <workflow-action-editor> (my/js/components/WorkflowActionEditor.js) gained three dedicated config-key widgets, following the existing Parameters repeatable-row editor as the template: a Segment editor (Preset dropdown — Paragraph / HTML table row / Whole message — that fills Start/Close Pattern, plus directly-editable Start/Close/Filter inputs); a repeatable Fields row editor (name / pattern / type select / required checkbox, with a Format input that only appears when Type is datetime); and a repeatable MatchOn column-name list for Merge. All three: pre-fill correctly in edit mode from existing config, participate in required-field validation, round-trip through the "Browse existing configurations → Copy" reference dialog, and serialize to exactly the shape the handlers expect (verified against a mocked metadata payload matching the real API's WorkflowActionDefinition shape — Segment/Filter fill, preset fill, Fields add/remove/type-toggle, MatchOn add/remove/pre-fill, and all four required-validation states all exercised end-to-end in a real DOM, not just code review). Deferred: a "paste sample text → see what extracts" live preview — the highest-leverage next improvement given how easily a hand-written pattern silently breaks on a vendor format change, but out of scope for this pass. |