中文 English

7.4M Reports & 20 Hours Dark: Inside the Nov 2026 Microsoft 365 Global Outage — How Auto-Health Checks Knocked Out Exchange and Restarts Triggered Stampedes

Published: 2026-11-14 · Views --
Microsoft Microsoft Troubleshooting Troubleshooting Engineering Networking Network Architecture Architecture Security Security Pitfall Guide Automation Automation Windows 11 Ubuntu 26.04 macOS 27

Let us cut straight to the conclusion: if you opened your laptop yesterday morning to find Outlook spinning indefinitely, Teams calendars blank, Microsoft 365 Copilot unreachable, and even GoDaddy or Gmail senders receiving mysterious bounce notices—you were not hacked, your office Wi-Fi did not collapse, and you should definitely not reinstall Office or delete your local mail cache.

Beginning late on November 12, 2026 UTC (early morning November 13 across Asia-Pacific), Microsoft’s flagship productivity cloud endured one of the largest and longest sustained outages in modern enterprise SaaS history. What opened as regional Exchange Online connectivity trouble in the Americas (tracked internally as EX1550543) rapidly cascaded into the global multi-workload incident MO1550821. Across 70+ affected countries—from the U.S. House of Representatives suspending a day of legislative business and U.K. NHS clinics falling back to pen-and-paper prescriptions, to Japanese enterprises dusting off fax machines and New Zealand pausing visa processing—modern cloud-first institutions slammed into a collective brick wall as local workdays began around the globe.

Even stranger for seasoned SREs and IT administrators: why did this outage drag on for roughly 20 hours—even knocking Microsoft’s own status portal into Azure Front Door 504 Gateway Timeout errors? Why did TCP port 443 answer health pings in milliseconds while browsers hung forever? And why did restarting recovered servers repeatedly knock them back offline within minutes?

Original Overview Blueprint: November 2026 Microsoft 365 Global Outage Key Telemetry & Control-vs-Data Split

Figure 1: Original cover blueprint summarizing the Nov 12–13, 2026 Microsoft 365 outage metrics alongside independent telemetry showing Azure Edge and Entra ID staying healthy while Mailbox TLS stalled.

1. How It Started: When Even Microsoft's Own Status Page Returned 504

Every infrastructure engineer knows one uniquely sinking feeling: reaching for your cloud provider’s status dashboard to verify an upstream outage—only to watch the status page itself throw a gateway error back at you.

Between 16:17 and 17:54 UTC on November 12, 2026 (late morning to noon along the U.S. East Coast), enterprise desks across the Americas watched Outlook desktop stall on folder updates while OWA webmail and Exchange ActiveSync mobile sync went dark. Microsoft logged EX1550543, initially scoping the disruption to Exchange Online mailbox connections.

Yet within half an hour (18:07–18:26 UTC), the blast radius spilled well beyond email into global suite incident MO1550821. At the same time, admins attempting to load Microsoft’s public portal at status.cloud.microsoft were greeted intermittently by raw Azure Front Door 504 Gateway Timeout pages—leaving thousands of helpdesks blind without third-party status aggregators and Service Health text feeds.

Real Screenshot: Microsoft status.cloud.microsoft Service Degradation Details

Figure 2: Captured live screenshot of status.cloud.microsoft displaying active Service Degradation notices across Exchange Online and companion Microsoft 365 workloads.

2. Measuring the Damage: 7.4 Million Reports & a "Follow-the-Sun" Crash Wave

Routine SaaS hiccups typically spike and subside within two to four hours as bad rollouts get reverted. According to crowdsourced telemetry published by Ookla’s Downdetector, the November 12–13 Microsoft 365 incident rewrote the past year’s disruption charts on both scale and endurance.

By 16:00 UTC on November 13, Downdetector logged more than 7 million total reports—reaching 7.4 million across Microsoft 365 services. That made it roughly 2.5x larger than the next-biggest outage of the preceding 12 months (YouTube in February 2026 at 2.8 million), dwarfing Verizon (January 2026, 2.5 million) and Meta (June 2026, 1.7 million).

Even more extraordinary was duration. Measuring sustained elevated disruption (hours staying above one-fifth of the busiest hour), prior recent marathons like Cloudflare (Nov 2025) and Verizon (Jan 2026) topped out around 9 hours. Microsoft’s outage burned hot for nearly 20 hours—closely corroborated by Kentik director Doug Madory, who tracked roughly 18 hours of suppressed Microsoft cloud traffic globally.

Real Chart Screenshot: Ookla Downdetector 7.4M Outage Scale Comparison

Figure 3: Real Ookla Downdetector comparison showing the Nov 2026 Microsoft 365 incident (7.4 million reports) towering at ~2.5x above YouTube’s Feb 2026 disruption.

Why did the graph resemble multiple punishing peaks rather than a single bell curve? Look at Earth’s rotation.

Over 90 countries and territories recorded at least 1,000 incident reports. While the Americas absorbed the initial afternoon shockwave, millions in Asia-Pacific and Europe were asleep. As daylight swept westward—hitting desks at roughly 9:00 AM JST in Japan, 9:30 AM SGT in Singapore, 10:15 AM IST in India, and 8:30–9:15 AM local time across the UK and Europe—hundreds of millions of knowledge workers flipped open laptops simultaneously, unleashing consecutive morning retry storms onto strained frontend pools.

Real Chart Screenshot: Ookla Outage Duration (~20 Hours) Comparison

Figure 4: Real Ookla chart highlighting why Nov 12–13 felt unusually grueling—nearly 20 hours of sustained high-severity impact, more than double prior 9-hour major outages.

Collateral panic even dragged innocent bystanders onto outage leaderboards. Because Outlook mailboxes rejected or timed out inbound mail, senders assumed their own email providers had broken; meanwhile small businesses relying on GoDaddy resold M365 accounts inundated GoDaddy support, and locked-out Teams users stampeded alternative meeting apps:

Real Chart Screenshot: GoDaddy 61x, Zoom 52x, Gmail 46x Spillover Outage Spikes

Figure 5: Real Ookla spillover telemetry showing GoDaddy (61x), Zoom (52x), and Gmail (46x) reporting surges triggered indirectly by Microsoft 365’s blackout.

Beyond charts, Microsoft 365 underpins roughly 430 to 450 million paid commercial seats. Pulling that single digital rug forced institutions in over 70 countries back into analog fallbacks:

Real Screenshot: Wikipedia 2026 Microsoft Outage Entry Documenting Global Impact

Figure 6: Live snapshot of the Wikipedia article documenting EX1550543 / MO1550821 alongside verified healthcare, parliamentary, and enterprise fallout across continents.

3. The 24-Hour Chronology: Why Did an Email Outage Take Down 15+ Suites?

Non-specialist observers repeatedly asked the same fair question: if Exchange is “just email,” why did Microsoft Teams calendars vanish, Microsoft 365 Copilot disconnect, and even Purview DLP, Defender Safe Links, Intune, and Windows 365 Cloud PCs break simultaneously?

Custom Diagram: 24-Hour Follow-the-Sun Timeline of MO1550821 and EX1550543

Figure 7: Custom chronological map combining Service Health milestones with the rolling regional morning peaks across the Americas, APAC, and Europe.

The architectural reality is simple: inside modern Microsoft 365, Exchange Online is no longer merely an email post office—it is the hidden data and policy substrate for calendars, compliance state, and user context. Verbatim Service Health logs preserved by ENow Software under MO1550821 list more than 15 degraded workloads sharing that dependency chain:

Real Screenshot: ENow MO1550821 Health Log and Status Portal 504 Error

Figure 8: ENow archive capturing both the 504 Azure Front Door error on status.cloud.microsoft and internal Service Health telemetry under MO1550821.

4. Plain-English Analogies: Understanding Self-Eviction & TLS Starvation in Two Minutes

Before diving into sockets and DNS records, you can explain the entire engineering failure to an elementary-school student using two everyday analogies—without sacrificing technical truth.

Custom Everyday Analogies Diagram: Tollbooth Self-Eviction Stampede & Billboard-vs-Scanner

Figure 9: Original two-part analogy diagram translating automated health-check self-eviction, thundering-herd restarts, 300s/10s DNS TTLs, and kernel TCP vs user-space TLS lockups.

Analogy 1: The 10-Booth Highway Toll Plaza & Restart Stampede

Imagine the busiest 10-lane highway toll plaza in the country. Ordinarily, all 10 booths run smoothly together.

One evening, maintenance crews make a routine lane-marking update, unknowingly triggering a hidden flaw left behind months earlier when the plaza was widened. The automated booth health sensor starts hallucinating failures—slamming the barrier gates shut on 7 perfectly healthy booths and pulling them out of service!

Instantly, 100% of highway traffic gets funneled into the 3 surviving booths. Those 3 booths were already busy; forced to handle more than triple normal volume—plus thousands of impatient drivers honking and re-entering the queue—the booth clerks handling ticket verification freeze from exhaustion. That matches Microsoft’s own Service Health description: automated health-check drops triggered cascading overload on remaining infrastructure until the core process handling secure connections stalled.

Then comes the nightmare twist. Engineers rush in and reboot one crashed booth cleanly. You would expect opening a fourth lane to help, right? Wrong. Miles of backed-up semi-trucks (outbound routing queues and millions of auto-retrying clients) instantly stampede through that single newly opened lane the second its arm lifts, crushing the clerk back into a frozen “bad state” within minutes! That exact “thundering herd” dynamic forced Microsoft EVP Charles Lamanna and engineering teams to halt brute-force fleet restarts, isolate outbound traffic, scale up brand-new virtual machines, and release capacity slowly in tightly validated regional batches.

Analogy 2: 5-Minute Billboards vs 10s Cones — and an Open Gate to an Unmanned Security Booth

Two mysteries baffled office IT teams all day: First, why could your desk neighbor suddenly open Outlook while your browser stayed dead? Second, why did port-443 TCP tests look 100% green when Outlook still wouldn’t load?

Think of finding your way to the toll plaza via two signs: a big highway billboard (DNS CNAME) that updates every 300 seconds (5 minutes) pointing you to the right regional complex, and small lane cones (DNS A records) that shuffle between 20–29 individual entrance booths every 10 seconds. If your laptop or office DNS cache happens to hold onto an older booth IP for a few extra seconds while your coworker refreshes onto a newly scaled VM lane, one person works while the next sits stuck.

As for green port-443 pings paired with frozen webmail: picture an entrance security guard (the operating system’s kernel TCP stack) waving cars into the courtyard in 3 to 13 milliseconds (returning a valid TCP SYN-ACK). Basic network monitors see the guard wave and happily flash green! Yet once your car pulls up to the encrypted ticket scanner (TLS ClientHello), nobody is alive inside the user-space booth to inspect your certificate—so your browser hangs indefinitely until the timer expires.

5. Deep Engineering Analysis: Official Post-Incident Clues vs Independent 11-Region Probes

Merging Microsoft’s public statements and verbatim Service Health notes with timestamped external measurements captured around T+18 hours across 11 Globalping regions and Singapore AS45102 paints a crystal-clear anatomical picture of what stayed alive and what suffocated.

Custom Technical Architecture Diagram: MO1550821 Root Cause, Control vs Data Split, and Recovery

Figure 10: Complete technical causal map contrasting healthy edge/identity layers against Exchange TLS starvation and Microsoft’s four-part stabilization strategy.

1. Control & Edge Layers Stayed Rock-Solid (Ruling Out Subsea Cuts, BGP, or AI Agents)

Major outages immediately attract speculation about cyberattacks, BGP hijacks, or rogue AI agent deployments. Microsoft EVP Charles Lamanna addressed those rumors directly:

"We are deeply sorry for the disruption this incident has caused our customers and partners... This was a technical issue not related to cyber or AI / agents. The incident was introduced by an issue during an earlier network capacity upgrade that surfaced following a recent network change... We will provide a full post-incident report once our review is complete."

Independent third-party telemetry backs up that localization completely. At the height of T+18h disruption:

Real Screenshot: GitHub Independent 11-Region Globalping Edge vs Mailbox Probe Findings

Figure 11: Public GitHub telemetry report published at T+18h proving zero edge errors while ~70%–72% of advertised mailbox frontend endpoints failed on port 443.

2. Inside the Fracture: 70% Pool Failure, 92% TLS Stall & 100x Recovery Jitter

By aligning raw socket telemetry with Microsoft’s Service Health logs, three sequential failure stages emerge with forensic precision:

Real Screenshot: TechCrunch Coverage of Microsoft Outage Becoming a Global Cloud Crisis

Figure 12: TechCrunch report detailing the transition from regional email outage to 20-hour global crisis, alongside Charles Lamanna’s thundering-herd and capacity-upgrade explanation.

To contextualize how Nov 2026 compares against September 2026 and the July 2024 CrowdStrike disaster:

Dimension Nov 2026 M365 Outage (This Event) Sept 2026 Microsoft Cloud Outage July 2024 CrowdStrike BSOD Event
Primary Trigger Latent prior network capacity upgrade bug activated by routine network change Configuration error in core identity authentication service Faulty Windows kernel driver update (Channel File 291)
Broken Layer Mailbox Data Plane & User-Space TLS Workers (Edge & Entra ID stayed healthy) Authentication Control Plane (sign-in and token issuance failures) Customer Endpoint OS Kernel (boot-loop BSODs)
Why Recovery Took Long Thundering-herd surges pushed restarted servers back into a "bad state" (~20h gradual validation + VM scaling) Config rollback propagation stabilized services faster Physical/manual Safe-Mode intervention needed across millions of offline endpoints
Right Client Action Do NOT wipe OSTs; flush stale local DNS post-recovery; wait up to 48h for queued mail Re-authenticate tokens after auth config rollback Boot to WinRE / Safe Mode locally or cycle ~15 reboots

6. Practical Mitigation & Zero-Dependency 3-OS Automation Scripts

Let us state one crucial engineering truth plainly: no local endpoint script can repair stalled TLS workers inside Microsoft’s Exchange datacenters. Any blog claiming a secret registry tweak or Hosts edit can bypass a global Exchange backend collapse is spreading cargo-cult misinformation.

What local triage and automation genuinely accomplish—saving IT teams dozens of wasted hours and preventing self-inflicted data loss—breaks down into three concrete steps:

  1. 60-Second Layer Isolation (Stop Destructive Troubleshooting): Prove immediately via TLS probes that Entra ID (login.microsoftonline.com) and Edge succeed while Mailbox endpoints stall on ClientHello. Looking at Microsoft Learn Q&A threads during the outage (Figure 13), frantic users repeatedly deleted Outlook profiles and wiped multi-gigabyte .ost caches—only to discover zero improvement and cause catastrophic re-download storms once service resumed.
  2. Clean Post-Recovery Handoff (Flush Sticky 300s/10s DNS + Volatile WebView Caches): Once Microsoft announces regional mitigation, OS resolvers or browser/PWA containers may cling briefly to degraded frontend IPs. Flushing OS DNS caches cleanly and backing up volatile WebView caches (while strictly leaving mail archives untouched) speeds up recovery cleanly.
  3. Protect Queued Delivery Over the 48-Hour Window: Because Microsoft extended deferred message retry timeouts to 48 hours, avoid flooding outboxes with duplicate huge attachments while queues drain.

Real Screenshot: Microsoft Learn Q&A Community Confusion and Outlook Profile Resets

Figure 13: Microsoft Q&A community reports from Nov 13—without layer separation telemetry, desperate users burned hours resetting working local clients during an upstream TLS stall.

Below are unified, zero third-party dependency scripts for Windows 11, Ubuntu 26.04, and macOS 27. Each script runs both in Interactive Human Mode and unattended AI Agent / CI Mode (–auto –json).

Privacy Guarantee: None of these scripts print full IP addresses or local hostnames; resolved pools are summarized exclusively as anonymized short SHA-256 hashes (e.g., pool-795e344a) plus record counts.

Custom Playbook Matrix: 60-Second Triage Decision Tree & 3-OS Automation Pipeline

Figure 14: 60-second incident triage decision tree paired with our zero-dependency 3-OS script architecture and enterprise resilience checklist.

1. Windows 11 Script (Zero-Dependency PowerShell + Native .NET SslStream)

Save as m365_outage_triage_windows11.ps1. Uses native .NET TcpClient + SslStream with zero external modules:

<#
.SYNOPSIS
    Microsoft 365 Outage Triage & Post-Recovery Client Helper for Windows 11 (Zero 3rd-Party Dependencies).
.DESCRIPTION
    Supports Interactive Human mode (default) and unattended AI Agent mode (-Auto -Json).
    Separates local ISP/DNS issues from upstream Mailbox TLS stalls, inspects DNS TTLs,
    safely flushes Windows DNS cache, and optionally clears volatile WebView2/OneAuth caches
    without touching OST/PST archives or printing full IPs / computer names.
#>
[CmdletBinding()]
param(
    [switch]$Auto,
    [switch]$Json,
    [switch]$FlushDns,
    [switch]$ClearClientCache,
    [string]$Domain = "",
    [int]$TimeoutMs = 5000
)

$ErrorActionPreference = "Stop"

$Targets = @(
    @{ Layer = "Edge_FrontDoor"; Host = "www.microsoft.com"; Path = "/" },
    @{ Layer = "Identity_Auth";  Host = "login.microsoftonline.com"; Path = "/common/v2.0/.well-known/openid-configuration" },
    @{ Layer = "Graph_API";      Host = "graph.microsoft.com"; Path = "/v1.0/`$metadata" },
    @{ Layer = "Teams_Core";     Host = "teams.microsoft.com"; Path = "/" },
    @{ Layer = "Mailbox_EXO";    Host = "outlook.office365.com"; Path = "/owa/healthcheck.htm" },
    @{ Layer = "Mailbox_Web";    Host = "outlook.office.com"; Path = "/mail/" },
    @{ Layer = "Service_Health"; Host = "status.cloud.microsoft"; Path = "/" }
)

function Get-MaskedPoolHash {
    param([string[]]$Addresses)
    if (-not $Addresses -or $Addresses.Count -eq 0) { return "none" }
    $sorted = ($Addresses | Sort-Object -Unique) -join ","
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($sorted)
        $hash = ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join ""
        return "pool-" + $hash.Substring(0, 8)
    } finally {
        $sha.Dispose()
    }
}

function Test-EndpointLayer {
    param([hashtable]$Target, [int]$TimeoutMs)
    $hostName = $Target.Host; $layer = $Target.Layer
    $cnameChain = @(); $ttlSec = $null; $resolvedCount = 0; $poolHash = "none"
    $tcpMs = $null; $tlsMs = $null; $tlsProto = $null; $httpCode = $null; $state = "UNKNOWN"; $detail = ""

    try {
        if (Get-Command Resolve-DnsName -ErrorAction SilentlyContinue) {
            $dnsRecs = Resolve-DnsName -Name $hostName -Type A -DnsOnly -ErrorAction SilentlyContinue
            if ($dnsRecs) {
                $cnameChain = @($dnsRecs | Where-Object { $_.QueryType -eq "CNAME" } | ForEach-Object { $_.NameHost })
                $aRecs = @($dnsRecs | Where-Object { $_.QueryType -eq "A" })
                $resolvedCount = $aRecs.Count
                if ($dnsRecs.Count -gt 0) { $ttlSec = ($dnsRecs | Select-Object -ExpandProperty TTL | Measure-Object -Minimum).Minimum }
                if ($aRecs.Count -gt 0) { $poolHash = Get-MaskedPoolHash -Addresses ($aRecs | ForEach-Object { $_.IPAddress }) }
            }
        }
        if ($resolvedCount -eq 0) {
            $ips = [System.Net.Dns]::GetHostAddresses($hostName)
            $resolvedCount = $ips.Count
            $poolHash = Get-MaskedPoolHash -Addresses ($ips | ForEach-Object { $_.IPAddressToString })
        }
    } catch {
        return [ordered]@{
            layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
            resolved_record_count = 0; address_pool_hash = "none"; tcp_443_ms = $null
            tls_handshake_ms = $null; tls_version = $null; http_status = $null
            state = "DNS_FAIL"; detail = "DNS resolution failed"
        }
    }

    if ($resolvedCount -eq 0) {
        return [ordered]@{
            layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
            resolved_record_count = 0; address_pool_hash = "none"; tcp_443_ms = $null
            tls_handshake_ms = $null; tls_version = $null; http_status = $null
            state = "DNS_FAIL"; detail = "Zero A/AAAA records returned"
        }
    }

    $tcp = $null; $ssl = $null
    try {
        $tcp = New-Object System.Net.Sockets.TcpClient
        $swTcp = [System.Diagnostics.Stopwatch]::StartNew()
        $arTcp = $tcp.BeginConnect($hostName, 443, $null, $null)
        if (-not $arTcp.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
            $swTcp.Stop(); $tcp.Close()
            return [ordered]@{
                layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
                resolved_record_count = $resolvedCount; address_pool_hash = $poolHash
                tcp_443_ms = [math]::Round($swTcp.Elapsed.TotalMilliseconds, 1)
                tls_handshake_ms = $null; tls_version = $null; http_status = $null
                state = "TCP_TIMEOUT"; detail = "TCP 443 SYN timed out"
            }
        }
        $tcp.EndConnect($arTcp); $swTcp.Stop()
        $tcpMs = [math]::Round($swTcp.Elapsed.TotalMilliseconds, 1)

        $stream = $tcp.GetStream()
        $stream.ReadTimeout = $TimeoutMs; $stream.WriteTimeout = $TimeoutMs
        $ssl = New-Object System.Net.Security.SslStream($stream, $false)
        $swTls = [System.Diagnostics.Stopwatch]::StartNew()
        $authTask = $ssl.AuthenticateAsClientAsync($hostName)
        if (-not $authTask.Wait($TimeoutMs)) {
            $swTls.Stop()
            return [ordered]@{
                layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
                resolved_record_count = $resolvedCount; address_pool_hash = $poolHash
                tcp_443_ms = $tcpMs; tls_handshake_ms = [math]::Round($swTls.Elapsed.TotalMilliseconds, 1)
                tls_version = $null; http_status = $null
                state = "TLS_STALL_OR_DEGRADED"; detail = "TCP open but TLS ClientHello stalled"
            }
        }
        $swTls.Stop()
        $tlsMs = [math]::Round($swTls.Elapsed.TotalMilliseconds, 1)
        $tlsProto = $ssl.SslProtocol.ToString()

        $req = "HEAD $($Target.Path) HTTP/1.1`r`nHost: $hostName`r`nConnection: close`r`nUser-Agent: M365-Triage-Win11/1.0`r`n`r`n"
        $reqBytes = [System.Text.Encoding]::ASCII.GetBytes($req)
        $ssl.Write($reqBytes, 0, $reqBytes.Length); $ssl.Flush()
        $reader = New-Object System.IO.StreamReader($ssl)
        $statusLine = $reader.ReadLine()
        if ($statusLine -match "HTTP/\S+\s+(\d{3})") { $httpCode = [int]$Matches[1] }

        if ($httpCode -ge 500 -or $tlsMs -gt 2000) { $state = "TLS_STALL_OR_DEGRADED"; $detail = "High TLS latency or HTTP 5xx ($httpCode)" }
        else { $state = "HEALTHY"; $detail = "OK" }
    } catch {
        $state = if ($tcpMs) { "TLS_STALL_OR_DEGRADED" } else { "TCP_TIMEOUT" }
        $detail = "Connection exception during handshake/HTTP probe"
    } finally {
        if ($ssl) { try { $ssl.Dispose() } catch {} }
        if ($tcp) { try { $tcp.Dispose() } catch {} }
    }

    return [ordered]@{
        layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
        resolved_record_count = $resolvedCount; address_pool_hash = $poolHash
        tcp_443_ms = $tcpMs; tls_handshake_ms = $tlsMs; tls_version = $tlsProto
        http_status = $httpCode; state = $state; detail = $detail
    }
}

$results = @()
foreach ($t in $Targets) { $results += Test-EndpointLayer -Target $t -TimeoutMs $TimeoutMs }

$domainInfo = $null
if (-not $Auto -and -not $Domain) {
    $inputDomain = Read-Host "Optional: Enter custom mail domain for MX check (press Enter to skip)"
    if ($inputDomain) { $Domain = $inputDomain.Trim() }
}
if ($Domain) {
    try {
        $mx = Resolve-DnsName -Name $Domain -Type MX -ErrorAction SilentlyContinue | Sort-Object Preference
        $domainInfo = [ordered]@{
            domain = $Domain
            mx_hosts = @($mx | ForEach-Object { $_.NameExchange })
            mx_uses_exo = [bool]($mx | Where-Object { $_.NameExchange -like "*.mail.protection.outlook.com*" -or $_.NameExchange -like "*.mx.microsoft*" })
        }
    } catch { $domainInfo = [ordered]@{ domain = $Domain; mx_hosts = @(); mx_uses_exo = $false } }
}

$authOk = ($results | Where-Object { $_.layer -eq "Identity_Auth" }).state -eq "HEALTHY"
$edgeOk = ($results | Where-Object { $_.layer -eq "Edge_FrontDoor" }).state -eq "HEALTHY"
$exoState = ($results | Where-Object { $_.layer -eq "Mailbox_EXO" }).state
$webState = ($results | Where-Object { $_.layer -eq "Mailbox_Web" }).state
$allDnsFail = (@($results | Where-Object { $_.state -eq "DNS_FAIL" }).Count -eq $results.Count)

$signature = "NORMAL"; $exitCode = 0
if ($allDnsFail) { $signature = "LOCAL_DNS_OR_NETWORK_DOWN"; $exitCode = 3 }
elseif ($authOk -and $edgeOk -and (($exoState -ne "HEALTHY") -or ($webState -ne "HEALTHY"))) {
    $signature = "M365_MAILBOX_TLS_CASCADE"; $exitCode = 2
} elseif (@($results | Where-Object { $_.state -ne "HEALTHY" }).Count -gt 0) {
    $signature = "PARTIAL_EDGE_OR_WORKLOAD_DEGRADATION"; $exitCode = 2
}

$actions = @(); $doFlushDns = $FlushDns.IsPresent; $doClearCache = $ClearClientCache.IsPresent

if (-not $Auto) {
    Write-Host "`n=== Microsoft 365 Outage Triage (Windows 11) ===" -ForegroundColor Cyan
    $results | Select-Object layer, host, ttl_seconds, resolved_record_count, tcp_443_ms, tls_handshake_ms, http_status, state | Format-Table -AutoSize
    Write-Host "Diagnosis Signature: $signature" -ForegroundColor Yellow
    if (-not $doFlushDns) {
        $ans = Read-Host "Flush Windows DNS cache (Clear-DnsClientCache) to drop stale CNAME/A records? [y/N]"
        if ($ans -match "^[Yy]$") { $doFlushDns = $true }
    }
    if (-not $doClearCache) {
        $ans2 = Read-Host "Backup & clear volatile New Outlook WebView2 / OneAuth temp cache (preserves OST/PST mail)? [y/N]"
        if ($ans2 -match "^[Yy]$") { $doClearCache = $true }
    }
}

if ($doFlushDns) {
    try {
        if (Get-Command Clear-DnsClientCache -ErrorAction SilentlyContinue) { Clear-DnsClientCache } else { & ipconfig /flushdns | Out-Null }
        $actions += "flushed_windows_dns_cache"
    } catch { $actions += "dns_flush_failed" }
}

if ($doClearCache) {
    $stamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
    foreach ($p in @("$env:LOCALAPPDATA\Microsoft\Olk\EBWebView\Default\Service Worker\CacheStorage", "$env:LOCALAPPDATA\Microsoft\OneAuth\cache")) {
        if (Test-Path $p) {
            try { Move-Item -Path $p -Destination ($p + ".bak-" + $stamp) -Force; $actions += ("backed_up_volatile_cache:" + (Split-Path $p -Leaf)) }
            catch { $actions += ("skipped_locked_cache:" + (Split-Path $p -Leaf)) }
        }
    }
}

$payload = [ordered]@{
    schema_version = "1.0"
    timestamp_utc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
    os_platform = "Windows 11"
    diagnosis_signature = $signature
    checks = $results
    domain_mx_audit = $domainInfo
    actions_taken = $actions
    recommended_next_steps = @(
        "If Auth/Edge are HEALTHY while Mailbox_EXO shows TLS_STALL_OR_DEGRADED, do NOT reinstall Office or recreate OST profiles.",
        "Avoid hammering F5 during recovery; wait 5-10 minutes or flush stale 300s CNAME DNS cache once status recovers.",
        "Wait up to 48 hours for deferred SMTP queue delivery before declaring messages lost."
    )
}

if ($Json -or $Auto) { $payload | ConvertTo-Json -Depth 6 }
else {
    Write-Host ("Actions completed: " + ($(if ($actions.Count) { $actions -join ", " } else { "none" }))) -ForegroundColor Green
    $payload.recommended_next_steps | ForEach-Object { Write-Host ("  - " + $_) }
}
exit $exitCode

2. Ubuntu 26.04 Script (Zero-Dependency Bash + Native python3 stdlib & resolvectl)

Save as m365_outage_triage_ubuntu2604.sh and run chmod +x:

#!/usr/bin/env bash
# Microsoft 365 Outage Triage & Post-Recovery Client Helper for Ubuntu 26.04
# Zero 3rd-party dependencies (uses OS python3 stdlib & resolvectl).
set -euo pipefail

AUTO=0; JSON_OUT=0; FLUSH_DNS=0; CLEAR_CACHE=0; DOMAIN=""; TIMEOUT_SEC=5

while [[ $# -gt 0 ]]; do
  case "$1" in
    --auto) AUTO=1; JSON_OUT=1; shift ;;
    --json) JSON_OUT=1; shift ;;
    --flush-dns) FLUSH_DNS=1; shift ;;
    --clear-client-cache) CLEAR_CACHE=1; shift ;;
    --domain) DOMAIN="${2:-}"; shift 2 ;;
    --timeout) TIMEOUT_SEC="${2:-5}"; shift 2 ;;
    -h|--help)
      echo "Usage: $0 [--auto] [--json] [--flush-dns] [--clear-client-cache] [--domain example.com]"
      exit 0 ;;
    *) echo "Unknown argument: $1" >&2; exit 1 ;;
  esac
done

if [[ $AUTO -eq 0 && -z "$DOMAIN" && -t 0 ]]; then
  read -r -p "Optional: Enter custom mail domain for MX check (Enter to skip): " DOMAIN || true
fi

PROBE_JSON="$(python3 - "$TIMEOUT_SEC" "$DOMAIN" <<'PYEOF'
import hashlib, json, shutil, socket, ssl, subprocess, sys, time
from datetime import datetime, timezone

timeout = float(sys.argv[1])
domain = sys.argv[2].strip() if len(sys.argv) > 2 else ""

targets = [
    ("Edge_FrontDoor", "www.microsoft.com", "/"),
    ("Identity_Auth", "login.microsoftonline.com", "/common/v2.0/.well-known/openid-configuration"),
    ("Graph_API", "graph.microsoft.com", "/v1.0/$metadata"),
    ("Teams_Core", "teams.microsoft.com", "/"),
    ("Mailbox_EXO", "outlook.office365.com", "/owa/healthcheck.htm"),
    ("Mailbox_Web", "outlook.office.com", "/mail/"),
    ("Service_Health", "status.cloud.microsoft", "/"),
]

def get_dns_meta(host):
    cnames, ttl, ips = [], None, []
    if shutil.which("resolvectl"):
        try:
            out = subprocess.check_output(["resolvectl", "query", "--legend=no", host], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
            for line in out.splitlines():
                if "looks like" in line or "authenticated" in line: continue
                for tok in line.split():
                    if ("." in tok) and (tok.endswith("microsoft.com") or tok.endswith("cloud.microsoft")):
                        clean = tok.rstrip(".,:")
                        if clean != host and clean not in cnames: cnames.append(clean)
        except Exception: pass
    try:
        infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
        ips = sorted({item[4][0] for item in infos})
    except Exception: ips = []
    pool_hash = "pool-" + hashlib.sha256(",".join(ips).encode()).hexdigest()[:8] if ips else "none"
    return cnames, ttl, len(ips), pool_hash

checks = []
for layer, host, path in targets:
    cnames, ttl, rec_cnt, pool_hash = get_dns_meta(host)
    if rec_cnt == 0:
        checks.append({
            "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
            "resolved_record_count": 0, "address_pool_hash": "none", "tcp_443_ms": None,
            "tls_handshake_ms": None, "tls_version": None, "http_status": None,
            "state": "DNS_FAIL", "detail": "Zero A/AAAA records returned"
        })
        continue

    tcp_ms = tls_ms = tls_ver = http_status = None
    sock = ssock = None
    try:
        t0 = time.monotonic()
        sock = socket.create_connection((host, 443), timeout=timeout)
        tcp_ms = round((time.monotonic() - t0) * 1000.0, 1)
        ctx = ssl.create_default_context()
        t1 = time.monotonic()
        ssock = ctx.wrap_socket(sock, server_hostname=host)
        tls_ms = round((time.monotonic() - t1) * 1000.0, 1)
        tls_ver = ssock.version()
        req = f"HEAD {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: M365-Triage-Ubuntu2604/1.0\r\n\r\n"
        ssock.settimeout(timeout); ssock.sendall(req.encode("ascii"))
        head = ssock.recv(256).decode("latin1", errors="ignore")
        parts = (head.splitlines()[0] if head else "").split()
        if len(parts) >= 2 and parts[1].isdigit(): http_status = int(parts[1])
        if (http_status and http_status >= 500) or (tls_ms and tls_ms > 2000.0):
            state, detail = "TLS_STALL_OR_DEGRADED", f"High TLS latency or HTTP {http_status}"
        else:
            state, detail = "HEALTHY", "OK"
    except ssl.SSLWantReadError:
        state, detail = "TLS_STALL_OR_DEGRADED", "TLS ClientHello stalled"
    except TimeoutError:
        state = "TCP_TIMEOUT" if tcp_ms is None else "TLS_STALL_OR_DEGRADED"
        detail = "TCP 443 SYN timed out" if tcp_ms is None else "TCP SYN-ACK succeeded; TLS handshake stalled"
    except Exception as exc:
        state = "TLS_STALL_OR_DEGRADED" if tcp_ms is not None else "TCP_TIMEOUT"
        detail = f"Socket error: {type(exc).__name__}"
    finally:
        for s in (ssock, sock):
            if s:
                try: s.close()
                except Exception: pass

    checks.append({
        "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
        "resolved_record_count": rec_cnt, "address_pool_hash": pool_hash,
        "tcp_443_ms": tcp_ms, "tls_handshake_ms": tls_ms, "tls_version": tls_ver,
        "http_status": http_status, "state": state, "detail": detail
    })

domain_audit = None
if domain and shutil.which("resolvectl"):
    try:
        out = subprocess.check_output(["resolvectl", "query", "--type=MX", "--legend=no", domain], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
        mx_hosts = [ln.strip().split()[-1].rstrip(".") for ln in out.splitlines() if ln.strip()]
        domain_audit = {"domain": domain, "mx_hosts": mx_hosts, "mx_uses_exo": any("outlook.com" in m or "mx.microsoft" in m for m in mx_hosts)}
    except Exception: domain_audit = {"domain": domain, "mx_hosts": [], "mx_uses_exo": False}

by_layer = {c["layer"]: c["state"] for c in checks}
if all(c["state"] == "DNS_FAIL" for c in checks): sig, code = "LOCAL_DNS_OR_NETWORK_DOWN", 3
elif by_layer.get("Identity_Auth") == "HEALTHY" and by_layer.get("Edge_FrontDoor") == "HEALTHY" and (by_layer.get("Mailbox_EXO") != "HEALTHY" or by_layer.get("Mailbox_Web") != "HEALTHY"):
    sig, code = "M365_MAILBOX_TLS_CASCADE", 2
elif any(c["state"] != "HEALTHY" for c in checks): sig, code = "PARTIAL_EDGE_OR_WORKLOAD_DEGRADATION", 2
else: sig, code = "NORMAL", 0

print(json.dumps({
    "schema_version": "1.0", "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "os_platform": "Ubuntu 26.04", "diagnosis_signature": sig, "exit_code": code,
    "checks": checks, "domain_mx_audit": domain_audit
}))
PYEOF
)"

if [[ $AUTO -eq 0 && -t 0 ]]; then
  echo -e "\n=== Microsoft 365 Outage Triage (Ubuntu 26.04) ==="
  python3 - "$PROBE_JSON" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1])
fmt = "{:<16} {:<26} {:<5} {:<8} {:<8} {:<5} {}"
print(fmt.format("LAYER", "HOST", "RECS", "TCP(ms)", "TLS(ms)", "HTTP", "STATE"))
print("-" * 88)
for c in data["checks"]:
    print(fmt.format(c["layer"], c["host"], str(c["resolved_record_count"]),
        str(c["tcp_443_ms"] if c["tcp_443_ms"] is not None else "-"),
        str(c["tls_handshake_ms"] if c["tls_handshake_ms"] is not None else "-"),
        str(c["http_status"] if c["http_status"] is not None else "-"), c["state"]))
print(f"\nDiagnosis Signature: {data['diagnosis_signature']}")
PYEOF
  if [[ $FLUSH_DNS -eq 0 ]]; then
    read -r -p "Flush systemd-resolved DNS cache (resolvectl flush-caches)? [y/N]: " ans_dns || true
    [[ "$ans_dns" =~ ^[Yy]$ ]] && FLUSH_DNS=1
  fi
  if [[ $CLEAR_CACHE -eq 0 ]]; then
    read -r -p "Backup & clear volatile browser/PWA Outlook/Teams ServiceWorker cache? [y/N]: " ans_cache || true
    [[ "$ans_cache" =~ ^[Yy]$ ]] && CLEAR_CACHE=1
  fi
fi

ACTIONS=()
if [[ $FLUSH_DNS -eq 1 ]] && command -v resolvectl >/dev/null 2>&1; then
  if resolvectl flush-caches 2>/dev/null || sudo -n resolvectl flush-caches 2>/dev/null; then
    ACTIONS+=("flushed_systemd_resolved_cache")
  else
    ACTIONS+=("dns_flush_requires_sudo")
  fi
fi

if [[ $CLEAR_CACHE -eq 1 ]]; then
  STAMP="$(date +%Y%m%d-%H%M%S)"
  for dir in "$HOME/.config/Microsoft/Microsoft Teams/Service Worker/CacheStorage" "$HOME/.config/google-chrome/Default/Service Worker/CacheStorage" "$HOME/.cache/microsoft-edge/Default/Cache"; do
    [[ -e "$dir" ]] && mv "$dir" "${dir}.bak-${STAMP}" 2>/dev/null && ACTIONS+=("backed_up_volatile_cache:$(basename "$dir")") || true
  done
fi

FINAL_OUT="$(python3 - "$PROBE_JSON" "${ACTIONS[*]:-}" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1]); code = data.pop("exit_code", 0)
data["actions_taken"] = [a for a in sys.argv[2].split() if a]
data["recommended_next_steps"] = [
    "If Identity_Auth and Edge_FrontDoor are HEALTHY while Mailbox_EXO shows TLS_STALL_OR_DEGRADED, do NOT churn local mail configs.",
    "Avoid rapid manual retries during recovery; flush stale 300s CNAME caches once Microsoft mitigates.",
    "Expect up to 48 hours for deferred SMTP queues to clear completely."
]
print(json.dumps({"code": code, "payload": data}))
PYEOF
)"

EXIT_CODE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["code"])' "$FINAL_OUT")"
if [[ $JSON_OUT -eq 1 ]]; then
  python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["payload"], indent=2))' "$FINAL_OUT"
else
  echo "Actions completed: ${ACTIONS[*]:-none}"
fi
exit "$EXIT_CODE"

3. macOS 27 Script (Zero-Dependency Zsh + dig + dscacheutil)

Save as m365_outage_triage_macos27.zsh (tested directly in live interactive PTY and –auto –json modes on macOS):

#!/usr/bin/env zsh
# Microsoft 365 Outage Triage & Post-Recovery Client Helper for macOS 27
# Zero 3rd-party dependencies (native macOS dig / python3 / dscacheutil).
set -euo pipefail

AUTO=0; JSON_OUT=0; FLUSH_DNS=0; CLEAR_CACHE=0; DOMAIN=""; TIMEOUT_SEC=5

while [[ $# -gt 0 ]]; do
  case "$1" in
    --auto) AUTO=1; JSON_OUT=1; shift ;;
    --json) JSON_OUT=1; shift ;;
    --flush-dns) FLUSH_DNS=1; shift ;;
    --clear-client-cache) CLEAR_CACHE=1; shift ;;
    --domain) DOMAIN="${2:-}"; shift 2 ;;
    --timeout) TIMEOUT_SEC="${2:-5}"; shift 2 ;;
    -h|--help)
      echo "Usage: $0 [--auto] [--json] [--flush-dns] [--clear-client-cache] [--domain example.com]"
      exit 0 ;;
    *) echo "Unknown argument: $1" >&2; exit 1 ;;
  esac
done

if [[ $AUTO -eq 0 && -z "$DOMAIN" && -t 0 ]]; then
  printf "Optional: Enter custom mail domain for MX check (Enter to skip): "
  read -r DOMAIN || true
fi

PROBE_JSON="$(python3 - "$TIMEOUT_SEC" "$DOMAIN" <<'PYEOF'
import hashlib, json, shutil, socket, ssl, subprocess, sys, time
from datetime import datetime, timezone

timeout = float(sys.argv[1])
domain = sys.argv[2].strip() if len(sys.argv) > 2 else ""

targets = [
    ("Edge_FrontDoor", "www.microsoft.com", "/"),
    ("Identity_Auth", "login.microsoftonline.com", "/common/v2.0/.well-known/openid-configuration"),
    ("Graph_API", "graph.microsoft.com", "/v1.0/$metadata"),
    ("Teams_Core", "teams.microsoft.com", "/"),
    ("Mailbox_EXO", "outlook.office365.com", "/owa/healthcheck.htm"),
    ("Mailbox_Web", "outlook.office.com", "/mail/"),
    ("Service_Health", "status.cloud.microsoft", "/"),
]

def get_dns_meta(host):
    cnames, ttls = [], []
    if shutil.which("dig"):
        try:
            out = subprocess.check_output(["dig", "+noall", "+answer", host, "A"], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
            for line in out.splitlines():
                cols = line.split()
                if len(cols) >= 5:
                    try: ttls.append(int(cols[1]))
                    except ValueError: pass
                    val = cols[4].rstrip(".")
                    if cols[3] == "CNAME" and val not in cnames: cnames.append(val)
        except Exception: pass
    try:
        infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
        ips = sorted({item[4][0] for item in infos})
    except Exception: ips = []
    pool_hash = "pool-" + hashlib.sha256(",".join(ips).encode()).hexdigest()[:8] if ips else "none"
    return cnames, (min(ttls) if ttls else None), len(ips), pool_hash

checks = []
for layer, host, path in targets:
    cnames, ttl, rec_cnt, pool_hash = get_dns_meta(host)
    if rec_cnt == 0:
        checks.append({
            "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
            "resolved_record_count": 0, "address_pool_hash": "none", "tcp_443_ms": None,
            "tls_handshake_ms": None, "tls_version": None, "http_status": None,
            "state": "DNS_FAIL", "detail": "Zero A/AAAA records returned"
        })
        continue

    tcp_ms = tls_ms = tls_ver = http_status = None
    sock = ssock = None
    try:
        t0 = time.monotonic()
        sock = socket.create_connection((host, 443), timeout=timeout)
        tcp_ms = round((time.monotonic() - t0) * 1000.0, 1)
        ctx = ssl.create_default_context()
        t1 = time.monotonic()
        ssock = ctx.wrap_socket(sock, server_hostname=host)
        tls_ms = round((time.monotonic() - t1) * 1000.0, 1)
        tls_ver = ssock.version()
        req = f"HEAD {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: M365-Triage-macOS27/1.0\r\n\r\n"
        ssock.settimeout(timeout); ssock.sendall(req.encode("ascii"))
        head = ssock.recv(256).decode("latin1", errors="ignore")
        parts = (head.splitlines()[0] if head else "").split()
        if len(parts) >= 2 and parts[1].isdigit(): http_status = int(parts[1])
        if (http_status and http_status >= 500) or (tls_ms and tls_ms > 2000.0):
            state, detail = "TLS_STALL_OR_DEGRADED", f"High TLS latency or HTTP {http_status}"
        else:
            state, detail = "HEALTHY", "OK"
    except ssl.SSLWantReadError:
        state, detail = "TLS_STALL_OR_DEGRADED", "TLS ClientHello stalled"
    except TimeoutError:
        state = "TCP_TIMEOUT" if tcp_ms is None else "TLS_STALL_OR_DEGRADED"
        detail = "TCP 443 SYN timed out" if tcp_ms is None else "TCP SYN-ACK succeeded; TLS handshake stalled"
    except Exception as exc:
        state = "TLS_STALL_OR_DEGRADED" if tcp_ms is not None else "TCP_TIMEOUT"
        detail = f"Socket error: {type(exc).__name__}"
    finally:
        for s in (ssock, sock):
            if s:
                try: s.close()
                except Exception: pass

    checks.append({
        "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
        "resolved_record_count": rec_cnt, "address_pool_hash": pool_hash,
        "tcp_443_ms": tcp_ms, "tls_handshake_ms": tls_ms, "tls_version": tls_ver,
        "http_status": http_status, "state": state, "detail": detail
    })

domain_audit = None
if domain and shutil.which("dig"):
    try:
        out = subprocess.check_output(["dig", "+short", domain, "MX"], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
        mx_hosts = [ln.split()[-1].rstrip(".") for ln in out.splitlines() if ln.strip()]
        domain_audit = {"domain": domain, "mx_hosts": mx_hosts, "mx_uses_exo": any("outlook.com" in m or "mx.microsoft" in m for m in mx_hosts)}
    except Exception: domain_audit = {"domain": domain, "mx_hosts": [], "mx_uses_exo": False}

by_layer = {c["layer"]: c["state"] for c in checks}
if all(c["state"] == "DNS_FAIL" for c in checks): sig, code = "LOCAL_DNS_OR_NETWORK_DOWN", 3
elif by_layer.get("Identity_Auth") == "HEALTHY" and by_layer.get("Edge_FrontDoor") == "HEALTHY" and (by_layer.get("Mailbox_EXO") != "HEALTHY" or by_layer.get("Mailbox_Web") != "HEALTHY"):
    sig, code = "M365_MAILBOX_TLS_CASCADE", 2
elif any(c["state"] != "HEALTHY" for c in checks): sig, code = "PARTIAL_EDGE_OR_WORKLOAD_DEGRADATION", 2
else: sig, code = "NORMAL", 0

print(json.dumps({
    "schema_version": "1.0", "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "os_platform": "macOS 27", "diagnosis_signature": sig, "exit_code": code,
    "checks": checks, "domain_mx_audit": domain_audit
}))
PYEOF
)"

if [[ $AUTO -eq 0 && -t 0 ]]; then
  echo -e "\n=== Microsoft 365 Outage Triage (macOS 27) ==="
  python3 - "$PROBE_JSON" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1])
fmt = "{:<16} {:<26} {:<5} {:<5} {:<8} {:<8} {:<5} {}"
print(fmt.format("LAYER", "HOST", "TTL", "RECS", "TCP(ms)", "TLS(ms)", "HTTP", "STATE"))
print("-" * 92)
for c in data["checks"]:
    print(fmt.format(c["layer"], c["host"],
        str(c["ttl_seconds"] if c["ttl_seconds"] is not None else "-"),
        str(c["resolved_record_count"]),
        str(c["tcp_443_ms"] if c["tcp_443_ms"] is not None else "-"),
        str(c["tls_handshake_ms"] if c["tls_handshake_ms"] is not None else "-"),
        str(c["http_status"] if c["http_status"] is not None else "-"), c["state"]))
print(f"\nDiagnosis Signature: {data['diagnosis_signature']}")
PYEOF
  if [[ $FLUSH_DNS -eq 0 ]]; then
    printf "Flush macOS DNS cache (dscacheutil + mDNSResponder)? [y/N]: "
    read -r ans_dns || true
    [[ "$ans_dns" =~ ^[Yy]$ ]] && FLUSH_DNS=1
  fi
  if [[ $CLEAR_CACHE -eq 0 ]]; then
    printf "Backup & clear volatile Outlook/Teams WebView cache (preserves local database/mail)? [y/N]: "
    read -r ans_cache || true
    [[ "$ans_cache" =~ ^[Yy]$ ]] && CLEAR_CACHE=1
  fi
fi

ACTIONS=()
if [[ $FLUSH_DNS -eq 1 ]]; then
  dscacheutil -flushcache 2>/dev/null || true
  if sudo -n killall -HUP mDNSResponder 2>/dev/null; then
    ACTIONS+=("flushed_macos_dns_and_mdnsresponder")
  else
    ACTIONS+=("flushed_dscacheutil_only_mdnsresponder_needs_sudo")
  fi
fi

if [[ $CLEAR_CACHE -eq 1 ]]; then
  STAMP="$(date +%Y%m%d-%H%M%S)"
  for dir in "$HOME/Library/Containers/com.microsoft.Outlook/Data/Library/Caches/WebKit" "$HOME/Library/Containers/com.microsoft.teams2/Data/Library/Caches"; do
    [[ -e "$dir" ]] && mv "$dir" "${dir}.bak-${STAMP}" 2>/dev/null && ACTIONS+=("backed_up_volatile_cache:$(basename "$dir")") || true
  done
fi

FINAL_OUT="$(python3 - "$PROBE_JSON" "${ACTIONS[*]:-}" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1]); code = data.pop("exit_code", 0)
data["actions_taken"] = [a for a in sys.argv[2].split() if a]
data["recommended_next_steps"] = [
    "If Identity_Auth and Edge_FrontDoor are HEALTHY while Mailbox_EXO shows TLS_STALL_OR_DEGRADED, do NOT delete Outlook local profile.",
    "Do not spam reconnect/reload during Microsoft's rolling restarts; flush stale 300s CNAME caches once service recovers.",
    "Allow up to 48 hours for delayed SMTP queues to drain cleanly."
]
print(json.dumps({"code": code, "payload": data}))
PYEOF
)"

EXIT_CODE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["code"])' "$FINAL_OUT")"
if [[ $JSON_OUT -eq 1 ]]; then
  python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["payload"], indent=2))' "$FINAL_OUT"
else
  echo "Actions completed: ${ACTIONS[*]:-none}"
fi
exit "$EXIT_CODE"

Real Terminal Execution Screenshot: Interactive Human & Automated AI Agent JSON Output on macOS 27

Figure 15: Live terminal capture executing m365_outage_triage_macos27.zsh interactively (left) and in –auto –json AI Agent mode (right) with zero raw IPs or computer names leaked.

7. Engineering Resilience Before the Next Cloud Blackout Hits

Every historic SaaS failure risks fading into collective amnesia 48 hours after service returns. Four concrete architecture habits will separate prepared engineering teams from everyone else next time:

  1. Move Incident Paging Completely Out-of-Band: Never route PagerDuty alerts, incident bridges, or executive war rooms solely over Microsoft 365 SMTP or Teams calendars. If Exchange freezes, your own alerting pipeline goes silent right when it matters most.
  2. Maintain Lightweight Cold Offline Schedules: Hospitals, logistics hubs, and support rotations should snapshot the next 48 hours of schedules and escalation phone trees offline nightly.
  3. Upgrade Synthetic Probes Beyond Bare TCP Port Checks: As demonstrated in Singapore and across 11 globalping regions, kernel TCP handshakes stay green while user-space TLS hangs. Ensure uptime checks complete full TLS 1.3 negotiation plus HTTP response validation.
  4. Provision a Pre-Authorized Backup Transactional Relay: Keep a secondary non-Microsoft SMTP relay authorized inside SPF/DKIM records so customer alerts and password resets never bottleneck inside a single vendor substrate.

8. Frequently Asked Questions (Q&A)

Q1: Was the November 2026 Microsoft 365 blackout caused by cyberattackers or an AI agent gone wrong?

No. Microsoft EVP Charles Lamanna confirmed explicitly that the incident was "a technical issue not related to cyber or AI / agents." It stemmed from a latent issue introduced during an earlier network capacity upgrade that surfaced following a routine November 12 network change.

Q2: Why did my colleague regain Outlook hours before my laptop stopped throwing errors?

Two factors create staggered client recovery: first, Microsoft rolled recovery out cautiously in small regional increments alongside newly scaled VM capacity to prevent thundering-herd collapses; second, sticky client/router DNS caches can cling to stale 10-second A records behind the 300-second CNAME chain. Running a clean local DNS flush post-mitigation clears that lag quickly.

Q3: Did inbound emails sent during the ~20-hour blackout vanish permanently?

Most legitimate SMTP mail queues retry automatically, and Microsoft explicitly extended its delayed email retry timeout to 48 hours so backlogged messages wouldn't drop prematurely. Wait the full 48-hour window before treating delayed correspondence as lost, and ask critical external senders who received hard bounce notifications to re-send once clear.

Q4: Why could Teams still send chat messages occasionally while calendars, meetings, and Copilot failed?

Real-time Teams chat and media edges run relatively independent signaling pipelines (measured healthy in probes alongside login.microsoftonline.com and Graph API), whereas Teams calendars, meeting scheduling, Information Barriers, and Copilot context queries depend directly on Exchange's mailbox substrate and secure-connection frontend pool.

Q5: What is the single worst mistake IT teams made during the height of the outage?

Directing users to recreate local Outlook profiles, delete cached .ost files, or deploy aggressive auto-retry scripts. That both destroyed local offline access and amplified the post-recovery thundering-herd surge when servers came back online.

Q6: Why did Gmail, Zoom, and GoDaddy spike 45x–61x on Downdetector at the exact same time?

GoDaddy bundles and resells Microsoft 365 hosted email for SMBs, Zoom absorbed a sudden flood of displaced Teams meetings, and Gmail users saw outgoing messages bounce from Outlook addresses—leading thousands of confused senders to report Gmail as broken.

Q7: Microsoft promised a full Post-Incident Review (PIR). What engineering fixes should we watch for?

Look closely at three future safeguards: bounded circuit-breakers inside automated health-checkers (so a probe bug cannot mass-evict 70%+ of capacity simultaneously), backpressure isolation inside Exchange's TLS worker pool, and gateway rate-shaping during restart phases to eliminate "stuck in a bad state" deadlocks permanently.

Comments

Sign in with GitHub to comment. Chinese and English versions share the same discussion. Discuss on GitHub

Views --