From d42b989edd574be1ed2352a1f56baa9a76f39e02 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Wed, 30 Sep 2026 23:56:36 +0000 Subject: [PATCH 1/6] Document provider quarantine visibility and optional Microsoft permissions --- docs/email-security/api-reference.md | 10 +++ docs/email-security/automation.md | 10 +++ docs/email-security/cli.md | 8 ++ docs/email-security/provider-quarantine.md | 90 +++++++++++++++++++ .../provider-setup/microsoft-365.md | 50 ++++++++++- docs/email-security/rule-reference.md | 11 +++ mkdocs.yml | 1 + 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 docs/email-security/provider-quarantine.md diff --git a/docs/email-security/api-reference.md b/docs/email-security/api-reference.md index ca8ea30dd..430998a34 100644 --- a/docs/email-security/api-reference.md +++ b/docs/email-security/api-reference.md @@ -515,3 +515,13 @@ having if it names who really asked. The Python SDK exposes the same surface, and the CLI wraps it — see [Command Line Interface](cli.md). Both are generated against these routes, so anything documented here is reachable from either. + +## Provider quarantine and release activity + +`GET /provider-quarantine` and `GET /release-requests` require `mailsec.get`. +They accept scalar `connection`, `status`, `since`, `until`, `cursor` and `limit` +(1–1000). Delivery status values are `quarantined`, `filteredAsSpam` and `failed`; +release status values are `requested`, `released` and `denied`. Responses include +independent per-feed `coverage`; an empty list with `not_granted`, pending, stale +or error coverage is not proof of zero blocked messages. See +[Provider Quarantine](provider-quarantine.md#cli-and-api) for the row contract. diff --git a/docs/email-security/automation.md b/docs/email-security/automation.md index 08999971f..19b2b3097 100644 --- a/docs/email-security/automation.md +++ b/docs/email-security/automation.md @@ -75,6 +75,8 @@ is your abuse mailbox. The person who sent the report is `event/reporter`. | `EMAIL_ANALYSIS_COMPLETE` | When the initial analysis window closes, including messages with nothing pending. Carries terminal outcomes, final verdict snapshot and timing | | `EMAIL_ACTION` | On every remediation outcome, including failures and skips, **and on every raw-message download** (`action: get_eml`), served or refused. Who asked, what was attempted, what happened | | `EMAIL_USER_REPORT` | When a message reaches the abuse mailbox and becomes a report | +| `EMAIL_PROVIDER_QUARANTINE` | Microsoft reports a quarantined, spam-filtered or failed delivery. `provider_status` distinguishes them; this is provider delivery metadata, not an engine verdict | +| `EMAIL_RELEASE_REQUEST` | An end user requests release from Microsoft hosted quarantine. Releases/denials are retained as history and do not emit this event | | `EMAIL_INGEST_ERROR` | When a message could not be fetched or processed. Coverage honesty: failures are visible, never silent | `EMAIL_MESSAGE` is emitted once and is immutable. When a verdict changes, the @@ -82,6 +84,14 @@ original event is never rewritten — a new `EMAIL_VERDICT` is emitted instead, the verdict history is the sequence of those events and a replay can reconstruct what was known at any point in time. +### Provider visibility events + +See [Provider Quarantine](provider-quarantine.md#events-and-identity) for the +payload fields, mailbox identity, retry deduplication and correlation limits. +Match `routing/event_type` and `event/provider_status` to distinguish quarantined +mail from delivery failures. `EMAIL_RELEASE_REQUEST` observes a user's request; +it does not grant approval or execute a release. + ### `EMAIL_VERDICT` The body carries the message's identity plus one `revision` block, and it is the diff --git a/docs/email-security/cli.md b/docs/email-security/cli.md index 58ee950d7..150ac5e3b 100644 --- a/docs/email-security/cli.md +++ b/docs/email-security/cli.md @@ -428,3 +428,11 @@ limacharlie mailsec report resolve "$REPORT" --disposition malicious Because the CLI is the whole surface, it is also how an [AI triage agent](ai-triage.md) reaches Email Security — there is no separate integration for agents to learn. + +## Provider quarantine and release activity + +`mailsec provider-quarantine list` and `mailsec release-request list` observe +Microsoft delivery and hosted-quarantine activity. They support connection, +status, time-window and cursor filters and return independent coverage. They +require a CLI build containing these commands and `mailsec.get`. See +[Provider Quarantine](provider-quarantine.md#cli-and-api) for examples and limits. diff --git a/docs/email-security/provider-quarantine.md b/docs/email-security/provider-quarantine.md new file mode 100644 index 000000000..65bb8e3c0 --- /dev/null +++ b/docs/email-security/provider-quarantine.md @@ -0,0 +1,90 @@ +# Microsoft Provider Quarantine + +--8<-- "includes/email-security-beta.md" + +**Provider quarantine** shows Microsoft delivery observations and hosted-quarantine +release activity. Messages blocked before reaching a mailbox may never enter +LimaCharlie's message analysis pipeline. These observations therefore have no +LimaCharlie engine verdict, disposition or remediation result. + +The console has two views: + +- **Delivery observations**: `quarantined`, `filteredAsSpam`, and `failed` from + Microsoft's message trace. A failed delivery is **not** a quarantined message. +- **Release activity**: end-user requests and later release or denial audit + observations. Reading this view does not approve or perform a release. + +Each view shows its own connection coverage. Optional application permissions +are configured in [Microsoft 365 setup](provider-setup/microsoft-365.md). + +## Read coverage before interpreting an empty list + +| State | Meaning | +|---|---| +| `ok` | The last bounded provider window was fully stored and the polling result is current | +| `pending` | A walk is still in progress; there may be more provider pages | +| `not_granted` | The optional permission is absent or the provider refused access | +| `not_evaluated` | This connection/feed has not produced a polling result yet | +| `error` | The provider read or durable write failed; this is not evidence of zero blocked mail | +| `stale` | The last complete poll is older than ten minutes | + +`watermark` is the end of the last fully stored provider window. `last_success` +is the last complete poll; `as_of` is when the API fetched this response. +An empty list with unavailable or incomplete coverage cannot establish that +Microsoft blocked nothing. Microsoft may publish traces and audit records late. +The poller checks overlapping windows and suppresses repeated observations; +provider timestamps remain separate from the time LimaCharlie observed them. + +Visibility starts with the previous 24 hours. Recovery resumes stored progress +rather than skipping unfinished pages. A gap beyond the provider's retention +window is reported as an error. Indexed observation metadata is retained in the +organization's evidence lane, up to 400 days; no message body, attachment or raw +audit blob is retained by these feeds. + +## CLI and API + +These commands require a CLI build that includes provider visibility; confirm +availability with `limacharlie mailsec --help`. They require `mailsec.get` and an +Email Security subscription. + +```bash +limacharlie mailsec provider-quarantine list --connection m365-prod --output yaml +limacharlie mailsec provider-quarantine list --status failed --limit 100 --output yaml +limacharlie mailsec release-request list --status requested --output yaml +``` + +Both support `--connection`, `--status`, `--since`, `--until`, `--cursor` and +`--limit` (1–1000). Times are RFC3339 or Unix seconds. Pass the returned +`next_cursor` unchanged and keep all filters unchanged while paging. + +The corresponding read routes are `GET /mailsec/{oid}/provider-quarantine` and +`GET /mailsec/{oid}/release-requests`. Responses contain `provider_quarantine` or +`release_requests`, `next_cursor`, `coverage` and `as_of`. Each history row has +`id`, `connection`, `feed`, `source_id`, `status`, `ts`, `observed_at` and typed +`metadata`; the recipient is included when the provider supplies one. + +## Events and identity + +`EMAIL_PROVIDER_QUARANTINE` carries `provider_status`, `trace_id`, +`recipient_address`, `received_at`, `observed_at`, `connection` and `event_id`. +Sender, subject and Internet Message-ID are included when available. +`EMAIL_RELEASE_REQUEST` carries `audit_id`, `requested_at`, `observed_at`, +`connection` and `event_id`, plus provider-supplied recipient, actor and +`network_message_id` when available. Actual releases and denials stay in the +activity history; they do not produce new request events. + +A resolved recipient carries `mailbox: {id, address, upn?}`, using the stable +provider handle and normalized primary SMTP address. An unresolved recipient or +an audit record without a recipient uses the connection sensor. The audit actor +can be an administrator and is never guessed to be the recipient. + +Microsoft audit NetworkMessageId and message trace Internet Message-ID are +separate identifiers. A `quarantine_id` appears only with a proven correlation; +the current feeds do not supply a reliable bridge, so it is absent. Keep request, +release and denial observations as provider history rather than assuming they +identify a message in the LimaCharlie message index. + +Events may be retried after interrupted delivery. Deduplicate automated work +using `event_id`. Use ordinary D&R event matching for alerts and reporting; these +feeds do not provide a hosted-quarantine release action. LimaCharlie's mailbox +folder quarantine remains described in [remediation](remediation.md). diff --git a/docs/email-security/provider-setup/microsoft-365.md b/docs/email-security/provider-setup/microsoft-365.md index d69948bb8..5e8fa6ef4 100644 --- a/docs/email-security/provider-setup/microsoft-365.md +++ b/docs/email-security/provider-setup/microsoft-365.md @@ -34,6 +34,8 @@ consent. | Grant | Unlocks | Without it | |---|---|---| +| **ExchangeMessageTrace.Read.All** (Microsoft Graph) | Provider quarantine, spam filtering and delivery-failure visibility | Message-trace coverage reports `not_granted`; ingestion continues | +| **ActivityFeed.Read** (Office 365 Management APIs) | Hosted-quarantine release requests and release/denial history from Audit.General | Release-activity coverage reports `not_granted`; ingestion continues | | **Mail.Send** | Reporter auto-replies — the templated acknowledgement sent to a person who reported a message | Reporter replies are **refused by name**, reported as an explicitly unavailable capability rather than silently skipped. Everything else is unaffected. Check `mail_send` reports `skipped`. | !!! info "Why the consented set is readable at all" @@ -47,6 +49,21 @@ consent. permissions require a signed-in user and leave the connection failing after consent looks granted. +Message trace additionally requires the tenant service principal for Microsoft's +application `8bd644d1-64a1-4d4b-ae52-2e0cbf64e373`. An administrator can provision +it with `New-MgServicePrincipal` as described in Microsoft's +[message trace onboarding guide](https://learn.microsoft.com/en-us/exchange/monitoring/trace-an-email-message/graph-api-message-trace). +Granting the app permission alone does not provision that prerequisite. + +For release activity, select **Office 365 Management APIs → Application +permissions → ActivityFeed.Read** separately from Microsoft Graph, grant admin +consent, and enable unified audit logging. The connection uses a separate +Management API token and starts its own Audit.General subscription. Feed records +can be delayed after a subscription starts. See Microsoft's +[Management API setup](https://learn.microsoft.com/en-us/office/office-365-management-api/get-started-with-office-365-management-apis) +and [activity API reference](https://learn.microsoft.com/en-us/office/office-365-management-api/office-365-management-activity-api-reference). +See [Provider Quarantine](../provider-quarantine.md) for the coverage states. + ## Create the app registration An app registration gives Email Security its own identity in Microsoft 365, so @@ -68,9 +85,12 @@ it can connect without using your personal password. immediately; the **Secret ID** is not the credential. Record its expiry so your administrator can replace it before access stops. 5. Open **API permissions → Add a permission → Microsoft Graph → Application - permissions**. Add the required permissions listed above and any optional + permissions**. Add the required Graph permissions and any optional Graph permissions you intend to use. - 6. Select **Grant admin consent** for your directory. If you cannot, ask a + 6. For release activity, choose **Add a permission → APIs my organization + uses → Office 365 Management APIs → Application permissions** and add + **ActivityFeed.Read** separately. + 7. Select **Grant admin consent** for your directory. If you cannot, ask a Privileged Role Administrator or Global Administrator to grant it. Confirm that the permissions show consent granted before continuing. @@ -105,6 +125,30 @@ it can connect without using your personal password. az ad app permission admin-consent --id "$APP_ID" ``` +=== "Optional visibility grants (Cloud Shell)" + + Run this separately only if you want provider visibility. Keep the `APP_ID` + from the registration above, provision the message-trace prerequisite, and + enable unified audit logging for release activity. Admin consent is required. + + ```bash + # Run only after the message-trace service principal prerequisite above is provisioned. + TRACE_ROLE=$(az ad sp show --id "$GRAPH" \ + --query "appRoles[?value=='ExchangeMessageTrace.Read.All'].id | [0]" -o tsv) + az ad app permission add --id "$APP_ID" --api "$GRAPH" \ + --api-permissions "$TRACE_ROLE=Role" + + # Release activity is a DIFFERENT resource, not Microsoft Graph. + MANAGEMENT=c5393580-f805-4401-95e8-94b7a6ef2fc2 + az ad sp show --id "$MANAGEMENT" >/dev/null 2>&1 || az ad sp create --id "$MANAGEMENT" + ACTIVITY_ROLE=$(az ad sp show --id "$MANAGEMENT" \ + --query "appRoles[?value=='ActivityFeed.Read'].id | [0]" -o tsv) + az ad app permission add --id "$APP_ID" --api "$MANAGEMENT" \ + --api-permissions "$ACTIVITY_ROLE=Role" + + az ad app permission admin-consent --id "$APP_ID" + ``` + !!! danger "`credential reset` clears existing secrets" Without `--append`, `az ad app credential reset` **removes every existing password and certificate** on the app before adding the new one. @@ -204,6 +248,8 @@ limacharlie mailsec connection test m365-prod --oid $OID --output yaml | `credential` | ✅ | The tenant/client/secret triple was rejected, or the client secret expired | | `mailbox_read` | ✅ | `User.Read.All` not consented — no mailbox can be discovered. Also fails when the directory genuinely returns no mailboxes | | `mail_write` | ✅ | `Mail.ReadWrite` not consented — nothing can be read or remediated | +| `provider_quarantine` | — | Message trace permission missing; optional capability unavailable | +| `release_requests` | — | Management Activity permission missing or not evaluated; optional capability unavailable | | `mail_send` | — | `Mail.Send` not consented; reporter replies unavailable. Reported as `skipped`, and `ok` stays true | ## How ingestion works diff --git a/docs/email-security/rule-reference.md b/docs/email-security/rule-reference.md index 714b161bb..f87cecb08 100644 --- a/docs/email-security/rule-reference.md +++ b/docs/email-security/rule-reference.md @@ -932,3 +932,14 @@ historical backfill emits no `EMAIL_*` events and no completion event. Missing optional intervals are absent, never fabricated zero. A completion with `failed`, `shed`, or `timed_out` results does not classify the message as benign. See [completion triage and delay rules](automation.md#triage-after-initial-analysis). + +## Provider delivery and release events + +Provider visibility uses ordinary D&R events: match `routing/event_type` on +`EMAIL_PROVIDER_QUARANTINE` or `EMAIL_RELEASE_REQUEST`. For delivery observations, +`event/provider_status` is `quarantined`, `filteredAsSpam` or `failed`; a failure +is not quarantine. Request events contain `event/audit_id`, `event/requested_at` +and, when supplied, `event/network_message_id` and `event/recipient_address`. +Deduplicate retried work using `event/event_id`. These events have no engine +verdict and do not run `dr-mail` message analysis. See +[Provider Quarantine](provider-quarantine.md) for coverage and correlation limits. diff --git a/mkdocs.yml b/mkdocs.yml index 433a113f9..30e85c494 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -604,6 +604,7 @@ nav: - Campaigns: email-security/campaigns.md - User Reports: email-security/user-reports.md - Sample Submission: email-security/sample-submission.md + - Provider Quarantine: email-security/provider-quarantine.md - Detections & Verdicts: email-security/detections.md - Mail Rules: email-security/custom-rules.md - Rule Reference: email-security/rule-reference.md From e5b1ca69998532b8206e3b5ce91603d7bbf312af Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 00:15:16 +0000 Subject: [PATCH 2/6] Clarify scoped audit history and finite telemetry retention --- docs/email-security/provider-quarantine.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/email-security/provider-quarantine.md b/docs/email-security/provider-quarantine.md index 65bb8e3c0..1cf03bced 100644 --- a/docs/email-security/provider-quarantine.md +++ b/docs/email-security/provider-quarantine.md @@ -39,7 +39,10 @@ Visibility starts with the previous 24 hours. Recovery resumes stored progress rather than skipping unfinished pages. A gap beyond the provider's retention window is reported as an error. Indexed observation metadata is retained in the organization's evidence lane, up to 400 days; no message body, attachment or raw -audit blob is retained by these feeds. +audit blob is retained by these feeds. Unsent telemetry expires with the organization’s +configured evidence retention; a long emission outage does not extend that retention. +An invalid persisted event is marked `emission_rejected` and kept as provider history +without blocking later valid events or claiming the rejected event was delivered. ## CLI and API @@ -88,3 +91,15 @@ Events may be retried after interrupted delivery. Deduplicate automated work using `event_id`. Use ordinary D&R event matching for alerts and reporting; these feeds do not provide a hosted-quarantine release action. LimaCharlie's mailbox folder quarantine remains described in [remediation](remediation.md). + +## Mailbox scope limits + +Recipient-bearing observations follow the connection's mailbox scope and exclusions. +For a restricted scope, an audit record without a recipient is omitted until it can be +linked to an in-scope mailbox; the administrative actor is never used as that link. +Only an unrestricted tenant connection can include recipient-less audit history. + +Visibility requires a resolved mailbox scope. An unresolved `include_groups` scope +reports `error` instead of claiming coverage from the organization-wide mailbox index. +Use explicit `include_addresses` until group membership has been resolved. Ambiguous +provider handles preserve the recipient observation without inventing a mailbox ID. From 60cab08480934a85f8c9c88bce9115ebc4ae8ba9 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 00:34:24 +0000 Subject: [PATCH 3/6] Clarify provider recipient scope and historical catch-up coverage --- docs/email-security/provider-quarantine.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/email-security/provider-quarantine.md b/docs/email-security/provider-quarantine.md index 1cf03bced..d60eb37d2 100644 --- a/docs/email-security/provider-quarantine.md +++ b/docs/email-security/provider-quarantine.md @@ -70,15 +70,17 @@ The corresponding read routes are `GET /mailsec/{oid}/provider-quarantine` and `EMAIL_PROVIDER_QUARANTINE` carries `provider_status`, `trace_id`, `recipient_address`, `received_at`, `observed_at`, `connection` and `event_id`. -Sender, subject and Internet Message-ID are included when available. +Sender, subject and Internet Message-ID are included when available within fixed +metadata limits; long subjects are clipped and oversized optional fields omitted. `EMAIL_RELEASE_REQUEST` carries `audit_id`, `requested_at`, `observed_at`, `connection` and `event_id`, plus provider-supplied recipient, actor and `network_message_id` when available. Actual releases and denials stay in the activity history; they do not produce new request events. A resolved recipient carries `mailbox: {id, address, upn?}`, using the stable -provider handle and normalized primary SMTP address. An unresolved recipient or -an audit record without a recipient uses the connection sensor. The audit actor +provider handle and normalized primary SMTP address. An ambiguous known recipient or +an audit record without a recipient uses the connection sensor. External or unknown +recipients without a tenant mailbox record are omitted. The audit actor can be an administrator and is never guessed to be the recipient. Microsoft audit NetworkMessageId and message trace Internet Message-ID are @@ -98,6 +100,8 @@ Recipient-bearing observations follow the connection's mailbox scope and exclusi For a restricted scope, an audit record without a recipient is omitted until it can be linked to an in-scope mailbox; the administrative actor is never used as that link. Only an unrestricted tenant connection can include recipient-less audit history. +Catch-up across historical polling windows stays pending until it reaches the current +polling interval; a finished old window does not claim current coverage. Visibility requires a resolved mailbox scope. An unresolved `include_groups` scope reports `error` instead of claiming coverage from the organization-wide mailbox index. From 7d1cb7af71ef71451423897495cb274f9072bb15 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 01:50:14 +0000 Subject: [PATCH 4/6] Explain optional Microsoft quarantine, outcomes and fallback --- docs/email-security/provider-quarantine.md | 65 ++++++++++++++++++- .../provider-setup/microsoft-365.md | 22 +++++++ docs/email-security/remediation.md | 6 ++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/docs/email-security/provider-quarantine.md b/docs/email-security/provider-quarantine.md index d60eb37d2..4e9dbc447 100644 --- a/docs/email-security/provider-quarantine.md +++ b/docs/email-security/provider-quarantine.md @@ -91,8 +91,7 @@ identify a message in the LimaCharlie message index. Events may be retried after interrupted delivery. Deduplicate automated work using `event_id`. Use ordinary D&R event matching for alerts and reporting; these -feeds do not provide a hosted-quarantine release action. LimaCharlie's mailbox -folder quarantine remains described in [remediation](remediation.md). +feeds describe provider activity. To restore mail held by the optional Microsoft target, use the indexed message’s restore action described below. ## Mailbox scope limits @@ -107,3 +106,65 @@ Visibility requires a resolved mailbox scope. An unresolved `include_groups` sco reports `error` instead of claiming coverage from the organization-wide mailbox index. Use explicit `include_addresses` until group membership has been resolved. Ambiguous provider handles preserve the recipient observation without inventing a mailbox ID. + + +## Optional Microsoft quarantine target (beta) + +**Beta — requires Defender for Office 365 Plan 2; validated with early customers.** +The default remains the LC hidden folder. Opt in through **Email Security → Policy → +Quarantine target**, or save this flat `mailsec_policy` record: + +```json +{"policy_type": "quarantine", "target": "microsoft"} +``` + +Set `target` to `lc_folder` to return to the default. This selects the destination; +it does not enable enforcement or override the organization’s alert-only mode. +Google Workspace continues using its existing provider-specific quarantine. + +Microsoft requires the optional Graph application permission +`SecurityAnalyzedMessage.ReadWrite.All` and admin consent. Consent alone does not +prove a Plan 2 licence. Coverage has an independent `microsoft_quarantine` capability: +`not_granted`, `not_evaluated` (write capability not established), `not_licensed`, +`unsupported`, `available` (a writer request was accepted), or `stale`. +Successful reads of analyzed email records never establish licensed write capability. + +The same `quarantine_message` action now returns `result: pending` when queued for +Microsoft. The collector resolves the exact Internet Message-ID and recipient to a +unique analyzed-email/network-message identity. Missing or ambiguous identities are +never guessed. The message drawer and `mailsec message get` include up to twenty +`provider_remediations`; `EMAIL_ACTION` includes `provider_remediation` with: + +| Status | Meaning | +|---|---| +| `pending` | The durable request is queued or its placement is still being checked | +| `succeeded` | Microsoft’s documented latest delivery reports the requested placement | +| `unsupported` | Microsoft permission, licensing, identity or operation support prevented this request; inspect the separately recorded LC fallback | +| `failed` | Placement or fallback failed, or completion could not be confirmed before the twenty-minute deadline | + +A Microsoft `202` acknowledges acceptance. It does **not** prove completion. +Its `tracking_url` is a human Action Center portal link, never a Graph status API. +LimaCharlie polls the documented analyzed-email placement instead of inventing an +operation-status endpoint. `succeeded` means **requested placement observed**, not +proof that a particular Microsoft bulk operation caused that placement. See the +[Microsoft remediation API](https://learn.microsoft.com/en-us/graph/api/security-analyzedemail-remediate?view=graph-rest-1.0). + +A definitive permission/licence/unsupported refusal falls back automatically to the +LC hidden folder. The outcome records both the Microsoft reason and +`fallback: {target: lc_folder, result: ok|skipped|failed}`. An LC success does not turn +the Microsoft status into success. Unknown submission failures and accepted requests +whose completion remains unconfirmed do not trigger another competing placement. + +Restore follows the recorded quarantine target. Microsoft-held mail uses +`moveToInbox` and waits for observed `inbox_folder`; LC fallback restores the original +folder. A restore queues behind an outstanding Microsoft quarantine; other placement +changes are refused while it is pending. Jobs survive collector restarts, use +per-message replica guards, and never resubmit an uncertain accepted request. +Microsoft exposes no documented cancellation API: after an unconfirmed deadline, +check the Action Center before another manual placement change. A later observed +quarantine can still be restored through the indexed message. + +Terminal and pending action events have stable `event_id` values for deduplicating +at-least-once delivery. Bulk jobs keep pending members open rather than treating +acceptance as completion. Provider remediation audit metadata follows the evidence +retention policy; it contains identities and outcomes, not message bodies. diff --git a/docs/email-security/provider-setup/microsoft-365.md b/docs/email-security/provider-setup/microsoft-365.md index 5e8fa6ef4..686bac333 100644 --- a/docs/email-security/provider-setup/microsoft-365.md +++ b/docs/email-security/provider-setup/microsoft-365.md @@ -35,6 +35,7 @@ consent. | Grant | Unlocks | Without it | |---|---|---| | **ExchangeMessageTrace.Read.All** (Microsoft Graph) | Provider quarantine, spam filtering and delivery-failure visibility | Message-trace coverage reports `not_granted`; ingestion continues | +| **SecurityAnalyzedMessage.ReadWrite.All** (Microsoft Graph, optional beta) | Hosted quarantine and move-to-Inbox restore; requires Defender for Office 365 Plan 2 | Microsoft target reports `not_granted` and quarantine records its LC-folder fallback | | **ActivityFeed.Read** (Office 365 Management APIs) | Hosted-quarantine release requests and release/denial history from Audit.General | Release-activity coverage reports `not_granted`; ingestion continues | | **Mail.Send** | Reporter auto-replies — the templated acknowledgement sent to a person who reported a message | Reporter replies are **refused by name**, reported as an explicitly unavailable capability rather than silently skipped. Everything else is unaffected. Check `mail_send` reports `skipped`. | @@ -287,3 +288,24 @@ limacharlie mailsec connection test m365-prod --oid $OID --output yaml | Some mailboxes never become `protected` | An Exchange application access policy denies the app for those mailboxes | Widen the policy, or exclude those mailboxes in the record's `scope` so coverage reflects a decision rather than a refusal | | Attachment analysis looks incomplete | Defender **Safe Attachments in Dynamic Delivery mode** detaches the attachment from the delivered message | Use Block mode if you want attachments analyzed post-delivery | | Everything fails at `credential` after months of working | Client secrets expire | Re-mint before expiry and update the secret record; nothing else changes | + + +### Optional hosted quarantine permission + +The Microsoft target is off by default and separate from visibility. Grant the Graph +**application** permission `SecurityAnalyzedMessage.ReadWrite.All` and admin consent +only when opting into the [Microsoft quarantine beta](../provider-quarantine.md#optional-microsoft-quarantine-target-beta). +It requires Defender for Office 365 Plan 2; consent and successful analyzed-email +reads cannot prove licensing. A licensing or permission refusal records the LC-folder +fallback rather than silently claiming Microsoft quarantine succeeded. + +For Azure CLI users, run this separately with the app registration's `APP_ID` and +Graph resource ID from the setup above, then grant admin consent: + +```bash +HOSTED_ROLE=$(az ad sp show --id "$GRAPH" \ + --query "appRoles[?value=='SecurityAnalyzedMessage.ReadWrite.All'].id | [0]" -o tsv) +az ad app permission add --id "$APP_ID" --api "$GRAPH" \ + --api-permissions "$HOSTED_ROLE=Role" +az ad app permission admin-consent --id "$APP_ID" +``` diff --git a/docs/email-security/remediation.md b/docs/email-security/remediation.md index 53dfc0f4e..811ee0e47 100644 --- a/docs/email-security/remediation.md +++ b/docs/email-security/remediation.md @@ -14,6 +14,12 @@ cannot finish inside a request.** Up to 500 provider writes paced against Microsoft 365 and Google throttling do not fit in one call, so execute returns a handle and the work proceeds in the background. +With the optional [Microsoft quarantine target](provider-quarantine.md#optional-microsoft-quarantine-target-beta), +per-message `pending` means accepted for background placement tracking. The bulk job +stays running while these members are pending; a bounded wait that cannot settle them +reports `interrupted`, never complete. Re-sending the same confirmation reads their +durable outcomes without repeating the Microsoft submission. + ## The four steps | Step | Route | Permission | | From f5c9be7ee95fa6f76073cf39c407dbaa177923de Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 02:00:13 +0000 Subject: [PATCH 5/6] Fix hosted quarantine documentation spacing --- docs/email-security/provider-quarantine.md | 1 - docs/email-security/provider-setup/microsoft-365.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/email-security/provider-quarantine.md b/docs/email-security/provider-quarantine.md index 4e9dbc447..1c505c945 100644 --- a/docs/email-security/provider-quarantine.md +++ b/docs/email-security/provider-quarantine.md @@ -107,7 +107,6 @@ reports `error` instead of claiming coverage from the organization-wide mailbox Use explicit `include_addresses` until group membership has been resolved. Ambiguous provider handles preserve the recipient observation without inventing a mailbox ID. - ## Optional Microsoft quarantine target (beta) **Beta — requires Defender for Office 365 Plan 2; validated with early customers.** diff --git a/docs/email-security/provider-setup/microsoft-365.md b/docs/email-security/provider-setup/microsoft-365.md index 686bac333..65476abec 100644 --- a/docs/email-security/provider-setup/microsoft-365.md +++ b/docs/email-security/provider-setup/microsoft-365.md @@ -289,7 +289,6 @@ limacharlie mailsec connection test m365-prod --oid $OID --output yaml | Attachment analysis looks incomplete | Defender **Safe Attachments in Dynamic Delivery mode** detaches the attachment from the delivered message | Use Block mode if you want attachments analyzed post-delivery | | Everything fails at `credential` after months of working | Client secrets expire | Re-mint before expiry and update the secret record; nothing else changes | - ### Optional hosted quarantine permission The Microsoft target is off by default and separate from visibility. Grant the Graph From f362e3b9531dea0fc54ff5dfb5a25fcf2d5f1ade Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 02:19:42 +0000 Subject: [PATCH 6/6] Describe bounded queue and fallback execution budgets --- docs/email-security/provider-quarantine.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/email-security/provider-quarantine.md b/docs/email-security/provider-quarantine.md index 1c505c945..5833b0083 100644 --- a/docs/email-security/provider-quarantine.md +++ b/docs/email-security/provider-quarantine.md @@ -139,7 +139,12 @@ never guessed. The message drawer and `mailsec message get` include up to twenty | `pending` | The durable request is queued or its placement is still being checked | | `succeeded` | Microsoft’s documented latest delivery reports the requested placement | | `unsupported` | Microsoft permission, licensing, identity or operation support prevented this request; inspect the separately recorded LC fallback | -| `failed` | Placement or fallback failed, or completion could not be confirmed before the twenty-minute deadline | +| `failed` | Placement or fallback failed, or completion could not be confirmed before its bounded stage deadline | + +Requests that wait more than thirty minutes expire before submission. The +execution budget is twenty minutes from the first owner claim; a known provider +refusal starts a separate twenty-minute LC fallback budget. Neither budget renews +on retry. A restore waiting behind quarantine gets its own execution budget. A Microsoft `202` acknowledges acceptance. It does **not** prove completion. Its `tracking_url` is a human Action Center portal link, never a Graph status API.