The situation
The first report was the kind that gets an incident opened fast: mail is disappearing. Users had messages they had read that morning, and now those messages were gone from the Inbox. At the same time, customers on Microsoft 365 said our client’s mail was landing in their quarantine, not their inbox.
The common thread was the client’s own website address. It was in every email signature, and on most of their outbound templates. The site sat on shared hosting, and the provider’s entire netblock had been listed. Once the domain’s reputation flipped, every message carrying the link looked like phishing to Microsoft’s URL detonation and reputation checks.
That produced two populations of affected mail:
- Mail already delivered before the verdict changed. ZAP reached back into mailboxes and moved it to quarantine.
- Mail arriving after the verdict changed. It was quarantined at delivery and never reached a mailbox.
Where ZAP puts delivered mail depends on the verdict. High confidence phish is always quarantined. A plain Phishing verdict follows the anti-spam policy’s action: Move to Junk on the default policy and policies created in PowerShell, Quarantine in the Standard and Strict presets and policies created in the Defender portal. If your policy moves phish to Junk, check Junk Email folders as well as quarantine.
Here both populations ended up in the same quarantine and are recovered the same way. The investigation still needs to tell them apart, and tell both apart from other things that can remove delivered mail.
Investigation
Rule out the usual suspects
“Mail is vanishing” usually means a malicious inbox rule or a compromised account. That’s worth ruling out in a couple of minutes before going further:
Connect-ExchangeOnline
# Rules that delete, move or forward are the classic post-compromise signal
Get-InboxRule -Mailbox user@contoso.com |
Where-Object { $_.DeleteMessage -or $_.MoveToFolder -or $_.ForwardTo -or $_.RedirectTo } |
Format-List Name, Enabled, MoveToFolder, DeleteMessage, ForwardTo, RedirectTo
Nothing there, and the pattern was tenant-wide rather than one mailbox, which points at the filtering stack.
Who actually moved the mail?
Three things can pull a delivered message back out of a mailbox in Microsoft 365:
| Mechanism | What it is | Where it shows up |
|---|---|---|
| ZAP | The filtering stack changes its verdict after delivery and acts on its own | Phish ZAP / Malware ZAP in EmailPostDeliveryEvents |
| AIR | Automated investigation and response (Defender for Office 365 Plan 2) proposes actions; an admin approves them | Manual remediation, plus an investigation in the Action center |
| Manual remediation | An admin soft- or hard-deletes or moves mail from Threat Explorer | Manual remediation |
The difference matters. If AIR or an admin did it, someone made a decision and there’s a record of why. If it’s ZAP, the service changed its mind about the content, and the fix is about the verdict.
The EmailPostDeliveryEvents table answers this directly. Its ActionType column records Manual remediation, Phish ZAP or Malware ZAP, and ActionTrigger says whether an admin or a special mechanism such as ZAP triggered it. Summarize first rather than scrolling individual rows:
EmailPostDeliveryEvents
| where Timestamp > ago(7d)
| summarize Messages = dcount(NetworkMessageId), Recipients = dcount(RecipientEmailAddress)
by ActionType, ActionTrigger, ActionResult, ThreatTypes
| order by Messages desc
Phish ZAP dominated, with nothing in the Action center tied to these messages. To confirm the flagged URL was the common factor, join to EmailUrlInfo:
EmailPostDeliveryEvents
| where Timestamp > ago(7d)
| where ActionType has "ZAP"
| join kind=inner (
EmailUrlInfo
| where UrlDomain endswith "contoso.com" // the flagged site
) on NetworkMessageId
| summarize Messages = dcount(NetworkMessageId), FirstSeen = min(Timestamp), LastSeen = max(Timestamp)
Advanced Hunting and Threat Explorer need Defender for Office 365 Plan 2, where Threat Explorer can also filter on ZAP in the Additional action column. On Plan 1, Real-time detections gives a similar view. With Exchange Online Protection alone, the Mailflow status report gives you a count for a date range.
The two clocks: 48 hours and retention
This is where most of the confusion sits. There are two different windows, and they answer different questions.
| Window | What it controls | Value |
|---|---|---|
| ZAP lookback | How far back ZAP reaches into delivered mail | Last 48 hours of delivered email |
| Quarantine retention: anti-spam verdicts (spam, phish, high confidence phish, bulk) | How long before quarantined copies are permanently deleted | 15 days in the default anti-spam policy and new custom policies; 30 days in the Standard and Strict presets; configurable 1 to 30 |
| Quarantine retention: malware | Same | 30 days, not configurable |
Get-QuarantineMessage query range | How far back you can search | Last 16 days by default, 30 days maximum |
The 48-hour lookback sets the blast radius; quarantine retention sets your deadline. Mail delivered more than 48 hours before the verdict flipped stayed in mailboxes. Everything else is in quarantine, and when its retention runs out it’s gone for good.
Check the retention actually applied to your users rather than assuming 30 days:
# Retention in days for each anti-spam policy
Get-HostedContentFilterPolicy | Format-Table Name, QuarantineRetentionPeriod
Root cause
Nothing in the tenant was misconfigured. The client’s hosting provider had a netblock on reputation blocklists, the client’s site lived in that block, and Microsoft’s URL reputation and detonation checks started returning a phishing verdict for the domain. Because the link was in every signature, every message the client sent, internally and externally, carried it. ZAP handled what was already delivered, and delivery-time filtering handled the rest.
The real fix was outside Microsoft 365. Everything in the tenant was containment and recovery.
Remediation
1. Fix the verdict before recovering the mail
Releasing mail while the URL is still flagged means new mail keeps landing in quarantine behind you. The tenant-side lever is an admin submission of the URL as a false positive:
Defender portal › Actions & submissions › Submissions › URLs › Submit to Microsoft for analysis › I’ve confirmed it’s clean › Allow this URL
That creates a URL allow entry in the Tenant Allow/Block List. By default it stays for 45 days after it was last used, and while it exists, Safe Links detonation and URL reputation checks are skipped for that URL. Microsoft’s ZAP documentation also lists admin false-positive submissions among the cases where ZAP doesn’t act on malware and high confidence phishing.
A wildcard such as https://www.contoso.com/* covers variations of the URL. Admin submissions are throttled (150 per 15 minutes, and the same item only three times in 24 hours), so one well-scoped URL submission does more than any number of per-message reports.
In parallel, the client had to work the problem upstream:
- The hosting provider had to get the netblock delisted or move the site.
- In the meantime, the client removed the website link from signatures and templates, because their customers’ tenants were quarantining it too, and nothing in our tenant changes another tenant’s verdict.
2. Scope the quarantine
Get-QuarantineMessage returns at most 1,000 results per page, so page until a short page comes back:
# Window covering the incident. Must fall within the last 30 days.
$start = (Get-Date).AddDays(-4).Date
$end = Get-Date
$q = @()
$page = 1
do {
$batch = Get-QuarantineMessage -StartReceivedDate $start.ToUniversalTime() `
-EndReceivedDate $end.ToUniversalTime() `
-ReleaseStatus NotReleased -PageSize 1000 -Page $page
$q += $batch
$page++
} while ($batch.Count -eq 1000)
# Narrow to the affected sender domain
$hits = $q | Where-Object { $_.SenderAddress -like "*@contoso.com" }
$hits.Count
$hits | Measure-Object ReceivedTime -Minimum -Maximum
# Which verdicts are we dealing with? This decides which switches apply later.
$hits | Group-Object QuarantineTypes | Select-Object Count, Name
3. Snapshot before touching anything
A release can’t be undone, so record what’s there first. The CSV is your evidence for the incident record and your reference if anything goes wrong.
$hits |
Select-Object ReceivedTime, Expires, SenderAddress,
@{n = 'Recipient'; e = { $_.RecipientAddress -join ';' }},
Subject, QuarantineTypes, Identity |
Sort-Object ReceivedTime |
Export-Csv ".\pre-release-$(Get-Date -Format yyyyMMdd-HHmm).csv" -NoTypeInformation
4. Pilot against one recipient
Release only one user’s copies and confirm they land in the inbox and stay there before going wide:
$testUser = "user@contoso.com"
# Filter by recipient on the server rather than relying on RecipientAddress in the paged results
$testIds = @(Get-QuarantineMessage -RecipientAddress $testUser `
-StartReceivedDate $start.ToUniversalTime() -EndReceivedDate $end.ToUniversalTime() `
-ReleaseStatus NotReleased |
Where-Object { $_.SenderAddress -like "*@contoso.com" } |
Select-Object -ExpandProperty Identity)
Release-QuarantineMessage -Identities $testIds -User $testUser
You don’t need to exclude the pilot user from the full run. -ReleaseToAll skips recipients the message was already released to.
5. Full release, chunked and logged
Sorting by Expires releases the messages closest to deletion first, which matters if the run gets interrupted.
$ids = @($hits | Sort-Object Expires | Select-Object -ExpandProperty Identity)
$failed = @()
$chunkSize = 100
for ($i = 0; $i -lt $ids.Count; $i += $chunkSize) {
$chunk = $ids[$i..([Math]::Min($i + $chunkSize - 1, $ids.Count - 1))]
try {
Release-QuarantineMessage -Identities $chunk -ReleaseToAll -ErrorAction Stop
Write-Host "Released $([Math]::Min($i + $chunkSize, $ids.Count)) / $($ids.Count)"
}
catch {
Write-Warning "Chunk starting at $i failed: $($_.Exception.Message)"
$failed += $chunk
}
Start-Sleep -Seconds 3 # be polite to the service
}
"Failed IDs: $($failed.Count)"
$failed | Set-Content ".\failed-ids-$(Get-Date -Format yyyyMMdd-HHmm).txt" # retry list
If your module version complains that -Identity is missing, add -Identity 000. Microsoft documents it as required alongside -Identities, with the value ignored.
Release-QuarantineMessage also has a -ReportFalsePositive switch, but Microsoft documents it as applying only to messages quarantined as spam. For phish and high confidence phish verdicts, the URL submission in step 1 is the feedback that counts.
Verification
Prove the quarantine is empty for the affected set and nothing errored:
# Expect 0 for both
Get-QuarantineMessage -StartReceivedDate $start.ToUniversalTime() -EndReceivedDate $end.ToUniversalTime() `
-ReleaseStatus NotReleased, Error |
Where-Object { $_.SenderAddress -like "*@contoso.com" } |
Measure-Object
Then prove the released mail stayed delivered. Set the release time and check for any new ZAP action on messages carrying the URL:
EmailPostDeliveryEvents
| where Timestamp > datetime(2026-01-01T00:00:00Z) // replace with the time the release finished
| where ActionType has "ZAP"
| join kind=inner (EmailUrlInfo | where UrlDomain endswith "contoso.com") on NetworkMessageId
| count
Zero is the result you want. Finally, once the provider confirms the delisting, send a test message containing the link to an external Microsoft 365 mailbox you control and confirm it reaches the inbox.
Caveats
- The allow entry is a filter bypass. For as long as it exists, URL reputation and detonation are skipped for that URL. Scope it to the domain you own, set an expiry, and remove it once reputation recovers. If the site is ever compromised for real, that entry will wave the payload through.
- Record it as an exception. For CMMC, SOC 2, HIPAA or PCI clients, the allow entry and the bulk release belong in the change record with an owner, a reason and an expiry date. An auditor asking why mail filtering was bypassed should find the answer written down.
- Bulk release trusts your filter. Scope by sender and confirm the URL with hunting before releasing. A broad date-range release can put real phishing back into inboxes.
- You can’t fix other tenants. Recipients on Microsoft 365 quarantine your mail in their tenant. Your options are getting the reputation fixed, removing the link, or asking their admins to release and submit on their side.
- Licensing shapes the investigation. ZAP runs for every Exchange Online mailbox, but
EmailPostDeliveryEventsneeds Defender for Office 365, and AIR needs Plan 2.
Key takeaways
- Identify who moved the mail before you act. ZAP, AIR and admin remediation look the same to users;
ActionTypeandActionTriggerinEmailPostDeliveryEventstell them apart. - 48 hours is the blast radius; retention is the deadline. And on the default anti-spam policy that deadline is 15 days.
- Fix the verdict, then recover the mail. One admin URL submission with an allow entry does more than a flood of per-message reports, and stops new mail from piling up behind you.
- Snapshot, pilot, chunk, verify. A CSV before, one recipient first, expiring messages first, and a hunting query after.
- The root cause may not be in the tenant at all. A shared-hosting netblock can take down a company’s mail without a single setting changing.
References
- Microsoft Learn: Zero-hour auto purge in Microsoft Defender for Office 365
- Microsoft Learn: Quarantined email messages in Microsoft 365 (quarantine retention)
- Microsoft Learn: EmailPostDeliveryEvents table in the advanced hunting schema
- Microsoft Learn: Submit messages, URLs, and attachments for analysis (admin submissions)
- Microsoft Learn: Get-QuarantineMessage
- Microsoft Learn: Release-QuarantineMessage