Modules/MDOP2.Web.psm1

Set-StrictMode -Version Latest

# Local web UI on Pode. Pode runs on its own runspace and thread; every Microsoft 365 call stays on the main thread,
# which owns the sign-in, the session secret and the Exchange Online connection. Routes only validate input and queue
# jobs in a shared object that the main thread works through.

$script:Server = $null
$script:TraceStatuses = 'Delivered', 'Expanded', 'Failed', 'FilteredAsSpam', 'GettingStatus', 'Pending', 'Quarantined'
$script:Actions = 'MoveToInbox', 'MoveToArchive', 'MoveToJunk', 'MoveToDeletedItems', 'SoftDelete'
$script:StaticFiles = @{
    '/'        = @{ File = 'index.html'; Type = 'text/html; charset=utf-8' }
    '/app.js'  = @{ File = 'app.js'; Type = 'text/javascript; charset=utf-8' }
    '/app.css' = @{ File = 'app.css'; Type = 'text/css; charset=utf-8' }
    '/detail.js' = @{ File = 'detail.js'; Type = 'text/javascript; charset=utf-8' }
    '/history.js' = @{ File = 'history.js'; Type = 'text/javascript; charset=utf-8' }
    '/fonts/Inter.ttf' = @{ File = 'fonts\Inter.ttf'; Type = 'font/ttf'; Binary = $true }
    '/fonts/RobotoSlab.ttf' = @{ File = 'fonts\RobotoSlab.ttf'; Type = 'font/ttf'; Binary = $true }
    # The logo files live in the Assets folder next to Web
    '/img/logo.png' = @{ File = '..\Assets\logo.png'; Type = 'image/png'; Binary = $true }
    '/img/logo-dark.png' = @{ File = '..\Assets\logo-dark.png'; Type = 'image/png'; Binary = $true }
    '/img/wordmark.png' = @{ File = '..\Assets\Krekel81.png'; Type = 'image/png'; Binary = $true }
    '/img/wordmark-dark.png' = @{ File = '..\Assets\Krekel81-dark.png'; Type = 'image/png'; Binary = $true }
}

function New-MDOP2RandomToken {
    <#
    .SYNOPSIS
    Returns a URL-safe random token from the OS random number generator.
    #>

    [CmdletBinding()]
    param([ValidateRange(16, 64)][int]$Bytes = 32)
    $b = [Security.Cryptography.RandomNumberGenerator]::GetBytes($Bytes)
    [Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function Test-MDOP2SecretEqual {
    <#
    .SYNOPSIS
    Compares two secrets in constant time. Null or empty never matches.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Actual, [AllowNull()][AllowEmptyString()][string]$Expected)
    if ([string]::IsNullOrEmpty($Actual) -or [string]::IsNullOrEmpty($Expected)) { return $false }
    $a = [Text.Encoding]::UTF8.GetBytes($Actual)
    $e = [Text.Encoding]::UTF8.GetBytes($Expected)
    [Security.Cryptography.CryptographicOperations]::FixedTimeEquals($a, $e)
}

function Test-MDOP2WebRequest {
    <#
    .SYNOPSIS
    Decides whether a request may reach a route. Checks the Host header (DNS rebinding), the session token (first visit
    by URL, then an HttpOnly SameSite=Strict cookie) and, for anything that changes state, Origin, a CSRF header and a JSON body.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Method,
        [Parameter(Mandatory)][string]$Path,
        [AllowNull()][AllowEmptyString()][string]$HostHeader,
        [AllowNull()][AllowEmptyString()][string]$Origin,
        [AllowNull()][AllowEmptyString()][string]$ContentType,
        [AllowNull()][AllowEmptyString()][string]$Cookie,
        [AllowNull()][AllowEmptyString()][string]$CsrfHeader,
        [AllowNull()][AllowEmptyString()][string]$QueryToken,
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][string]$Token,
        [Parameter(Mandatory)][string]$CsrfToken
    )
    $deny = { param($status, $reason) [pscustomobject]@{ Allow = $false; Status = $status; Reason = $reason; SetCookie = $false; Redirect = $null } }
    $origin0 = "http://127.0.0.1:$Port"
    if ($HostHeader -ne "127.0.0.1:$Port") { return & $deny 421 'Unexpected Host header' }
    $m = $Method.ToUpperInvariant()

    if ($m -eq 'GET' -and $Path -eq '/' -and $QueryToken) {
        if (Test-MDOP2SecretEqual -Actual $QueryToken -Expected $Token) {
            return [pscustomobject]@{ Allow = $false; Status = 303; Reason = 'Signed in'; SetCookie = $true; Redirect = '/' }
        }
        return & $deny 403 'Invalid session link'
    }
    if (-not (Test-MDOP2SecretEqual -Actual $Cookie -Expected $Token)) {
        return & $deny 403 'Open the link printed in the console to use this session'
    }
    if ($m -notin 'GET', 'HEAD') {
        if ($Origin -ne $origin0) { return & $deny 403 'Cross-origin request blocked' }
        if (-not (Test-MDOP2SecretEqual -Actual $CsrfHeader -Expected $CsrfToken)) { return & $deny 403 'Missing or invalid CSRF token' }
        if (-not $ContentType -or -not $ContentType.StartsWith('application/json', [StringComparison]::OrdinalIgnoreCase)) {
            return & $deny 415 'Requests must be JSON'
        }
    }
    [pscustomobject]@{ Allow = $true; Status = 200; Reason = $null; SetCookie = $false; Redirect = $null }
}

function Get-MDOP2SecurityHeader {
    <#
    .SYNOPSIS
    Headers sent with every response. Nothing loads from outside the tool, nothing frames it, nothing is cached.
    #>

    [ordered]@{
        'Content-Security-Policy' = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
        'X-Content-Type-Options'  = 'nosniff'
        'X-Frame-Options'         = 'DENY'
        'Referrer-Policy'         = 'no-referrer'
        'Cache-Control'           = 'no-store'
        'Cross-Origin-Opener-Policy'   = 'same-origin'
        'Cross-Origin-Resource-Policy' = 'same-origin'
    }
}

function Assert-MDOP2RequestKeys {
    param([System.Collections.IDictionary]$Body, [string[]]$Allowed)
    if ($null -eq $Body) { throw 'Request body must be a JSON object.' }
    # get_Keys(): a body field named "keys" would hide the Keys property
    $unknown = @($Body.get_Keys() | Where-Object { $_ -notin $Allowed })
    if ($unknown.Count) { throw "Unknown field(s): $($unknown -join ', ')." }
}

function ConvertTo-MDOP2StringList {
    param([AllowNull()][object]$Value, [string]$Name, [int]$MaxItems, [int]$MaxLength)
    $items = foreach ($v in @($Value)) {
        if ($null -eq $v) { continue }
        if ($v -isnot [string]) { throw "$Name must be text." }
        # Free text from the filter bar: split on commas, semicolons and whitespace
        $v -split '[,;\s]+' | Where-Object { $_ }
    }
    $items = @($items)
    if ($items.Count -gt $MaxItems) { throw "$Name accepts at most $MaxItems values." }
    foreach ($i in $items) { if ($i.Length -gt $MaxLength) { throw "$Name value is too long." } }
    , $items
}

function ConvertTo-MDOP2SearchRequest {
    <#
    .SYNOPSIS
    Validates a search request from the UI and returns the date range preset plus parameters for Search-MDOP2MessageTrace.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'range', 'subject', 'subjectFilterType', 'sender', 'recipient', 'status', 'messageId', 'content', 'contentMode'
    $range = if ($Body['range']) { [string]$Body['range'] } else { '7d' }
    if ($range -notin '1d', '7d', '30d', '90d') { throw 'Range must be 1d, 7d, 30d or 90d.' }
    $p = [ordered]@{}

    $subject = $Body['subject']
    if ($null -ne $subject -and $subject -isnot [string]) { throw 'Subject must be text.' }
    if ($subject) {
        $subject = $subject.Trim()
        if ($subject.Length -gt 255) { throw 'Subject is too long (255 characters at most).' }
        if ($subject -match '[\x00-\x1F\x7F]') { throw 'Subject contains control characters.' }
        if ($subject) {
            $p.Subject = $subject
            $type = if ($Body['subjectFilterType']) { [string]$Body['subjectFilterType'] } else { 'Contains' }
            if ($type -notin 'Contains', 'StartsWith', 'EndsWith') { throw 'Subject match must be Contains, StartsWith or EndsWith.' }
            $p.SubjectFilterType = $type
        }
    }
    foreach ($name in 'sender', 'recipient') {
        $list = ConvertTo-MDOP2StringList -Value $Body[$name] -Name $name -MaxItems 50 -MaxLength 320
        if ($list.Count) {
            # Throws on anything that isn't an address or a domain
            $null = ConvertTo-MDOP2AddressFilter -Value $list
            $p[(Get-Culture).TextInfo.ToTitleCase($name)] = $list
        }
    }
    $status = @($Body['status'] | Where-Object { $null -ne $_ })
    if ($status.Count) {
        foreach ($s in $status) {
            if ($s -isnot [string] -or $s -notin $script:TraceStatuses) { throw "Unknown delivery status '$s'." }
        }
        $p.Status = [string[]]($status | Select-Object -Unique)
    }
    $mid = $Body['messageId']
    if ($null -ne $mid -and $mid -isnot [string]) { throw 'Message-ID must be text.' }
    if ($mid -and $mid.Trim()) {
        $mid = $mid.Trim()
        # Most IDs are <id@host>, but some senders (and Exchange itself) use IDs without @, such as <guid>
        if ($mid.Length -gt 998 -or $mid -notmatch '^<?[^\s<>]+>?$') { throw 'Message-ID must be one ID without spaces, such as <id@host>.' }
        if (-not $mid.StartsWith('<')) { $mid = "<$mid" }
        if (-not $mid.EndsWith('>')) { $mid = "$mid>" }
        $p.MessageId = $mid
    }
    $content = $Body['content']
    if ($null -ne $content -and $content -isnot [string]) { throw 'Content must be text.' }
    $contentMode = 'Text'
    if ($content) {
        $content = $content.Trim()
        if ($content.Length -gt 500) { throw 'Content is too long (500 characters at most).' }
        if ($content -match '[\x00-\x1F\x7F]') { throw 'Content contains control characters.' }
        if ($Body['contentMode']) { $contentMode = [string]$Body['contentMode'] }
        if ($contentMode -notin 'Text', 'Link') { throw 'Content match must be Text or Link.' }
    }
    [pscustomobject]@{ Range = $range; Parameters = $p; Content = $(if ($content) { $content } else { $null }); ContentMode = $contentMode }
}

function ConvertTo-MDOP2KeyList {
    param([AllowNull()][object]$Value)
    $keys = @($Value | Where-Object { $null -ne $_ })
    if (-not $keys.Count) { throw 'Select at least one message.' }
    if ($keys.Count -gt 500) { throw 'Select 500 messages at most.' }
    foreach ($k in $keys) {
        if ($k -isnot [string] -or -not $k -or $k.Length -gt 1000 -or $k -match '[\x00-\x1F]') { throw 'Invalid message key.' }
    }
    , [string[]]@($keys | Select-Object -Unique)
}

function ConvertTo-MDOP2PlanRequest {
    <#
    .SYNOPSIS
    Validates a request to check an action before it runs: the action and the selected messages.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'action', 'keys', 'mailboxes', 'restore'
    $action = $Body['action']
    if ($action -isnot [string] -or $action -notin $script:Actions) { throw "Action must be one of: $($script:Actions -join ', ')." }
    $mailboxes = $null
    if ($null -ne $Body['mailboxes']) {
        $list = @($Body['mailboxes'] | Where-Object { $null -ne $_ })
        if (-not $list.Count) { throw 'Select at least one recipient.' }
        if ($list.Count -gt 1000) { throw 'Select 1000 recipients at most.' }
        foreach ($m in $list) { $null = ConvertTo-MDOP2Mailbox $m }
        $mailboxes = [string[]]@($list | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)
    }
    $restore = $Body['restore']
    if ($null -ne $restore -and $restore -isnot [bool]) { throw 'Restore must be true or false.' }
    [pscustomobject]@{ Action = $action; Keys = ConvertTo-MDOP2KeyList $Body['keys']; Mailboxes = $mailboxes; Restore = $restore -eq $true }
}

function ConvertTo-MDOP2Mailbox {
    param([AllowNull()][object]$Value)
    if ($Value -isnot [string] -or $Value.Length -gt 320 -or $Value -notmatch '^[^@\s<>]+@[^@\s<>]+$') { throw 'Invalid mailbox address.' }
    $Value.ToLowerInvariant()
}

function ConvertTo-MDOP2MessageRequest {
    <#
    .SYNOPSIS
    Validates a request about one message: its key and, optionally, the mailbox to read it from.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body, [switch]$NoMailbox)
    $allowed = if ($NoMailbox) { @('key') } else { @('key', 'mailbox') }
    Assert-MDOP2RequestKeys -Body $Body -Allowed $allowed
    if ($Body['key'] -isnot [string]) { throw 'Send exactly one message key.' }
    $key = ConvertTo-MDOP2KeyList $Body['key']
    $mailbox = if ($null -ne $Body['mailbox'] -and '' -ne $Body['mailbox']) { ConvertTo-MDOP2Mailbox $Body['mailbox'] } else { $null }
    [pscustomobject]@{ Key = $key[0]; Mailbox = $mailbox }
}

function ConvertTo-MDOP2SimilarRequest {
    <#
    .SYNOPSIS
    Validates a request for mail that looks like one message: its key and how many days around it to look.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'key', 'range'
    if ($Body['key'] -isnot [string]) { throw 'Send exactly one message key.' }
    $key = ConvertTo-MDOP2KeyList $Body['key']
    $range = if ($Body['range']) { $Body['range'] } else { '7d' }
    if ($range -isnot [string] -or $range -notin '1d', '7d', '30d') { throw 'Range must be 1d, 7d or 30d.' }
    [pscustomobject]@{ Key = $key[0]; Days = [int]$range.TrimEnd('d') }
}

function ConvertTo-MDOP2ExecuteRequest {
    <#
    .SYNOPSIS
    Validates a request to run a checked plan. Dry run stays on unless dryRun is the boolean false.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'planId', 'dryRun'
    $id = $Body['planId']
    $g = [guid]::Empty
    if ($id -isnot [string] -or -not [guid]::TryParse($id, [ref]$g)) { throw 'Invalid plan ID.' }
    $dry = $Body['dryRun']
    if ($null -ne $dry -and $dry -isnot [bool]) { throw 'dryRun must be true or false.' }
    [pscustomobject]@{ PlanId = $g.ToString(); DryRun = ($dry -ne $false) }
}

function ConvertTo-MDOP2StatusRequest {
    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'keys'
    [pscustomobject]@{ Keys = ConvertTo-MDOP2KeyList $Body['keys'] }
}

function ConvertTo-MDOP2HistoryRequest {
    <#
    .SYNOPSIS
    Validates a request for the action history: the period, whether to include dry runs and whether to show only this session.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    if ($null -eq $Body) { $Body = @{} }
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'range', 'includeDryRun', 'sessionOnly'
    $range = if ($Body['range']) { $Body['range'] } else { '30d' }
    if ($range -isnot [string] -or $range -notin '1d', '7d', '30d', '90d', 'all') { throw 'Range must be 1d, 7d, 30d, 90d or all.' }
    foreach ($n in 'includeDryRun', 'sessionOnly') {
        if ($null -ne $Body[$n] -and $Body[$n] -isnot [bool]) { throw "$n must be true or false." }
    }
    [pscustomobject]@{ Range = $range; IncludeDryRun = $Body['includeDryRun'] -eq $true; SessionOnly = $Body['sessionOnly'] -eq $true }
}

function ConvertTo-MDOP2HistoryStatusRequest {
    <#
    .SYNOPSIS
    Validates a status check for action history rows. Only row IDs from the last history load are accepted.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    Assert-MDOP2RequestKeys -Body $Body -Allowed 'ids'
    $ids = @($Body['ids'] | Where-Object { $null -ne $_ })
    if (-not $ids.Count) { throw 'Select at least one action.' }
    if ($ids.Count -gt 5000) { throw 'Check 5000 actions at most.' }
    foreach ($i in $ids) { if ($i -isnot [string] -or $i -notmatch '^h\d{1,5}$') { throw 'Invalid history row.' } }
    [pscustomobject]@{ Ids = [string[]]@($ids | Select-Object -Unique) }
}

function ConvertTo-MDOP2MailboxesRequest {
    <#
    .SYNOPSIS
    Validates a request for the mailbox list of the recipient picker. It takes no fields.
    #>

    [CmdletBinding()]
    param([AllowNull()][System.Collections.IDictionary]$Body)
    if ($null -eq $Body) { $Body = @{} }
    Assert-MDOP2RequestKeys -Body $Body -Allowed @()
    [pscustomobject]@{}
}

function ConvertTo-MDOP2GroupView {
    <#
    .SYNOPSIS
    Shapes a message group from Group-MDOP2TraceResult for the UI.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][object]$Group)
    $status = [ordered]@{}
    foreach ($k in $Group.StatusCounts.Keys) { $status[[string]$k] = $Group.StatusCounts[$k] }
    $sender = [string]$Group.SenderAddress
    [ordered]@{
        key               = $Group.Key
        internetMessageId = $Group.InternetMessageId
        subject           = $Group.Subject
        sender            = $sender
        senderDomain      = if ($sender -match '@(.+)$') { $Matches[1].ToLowerInvariant() } else { '' }
        fromIP            = $Group.FromIP
        firstReceived     = ConvertTo-MDOP2IsoDate $Group.FirstReceived
        lastReceived      = ConvertTo-MDOP2IsoDate $Group.LastReceived
        recipientCount    = $Group.RecipientCount
        statusCounts      = $status
        recipients        = @($Group.Recipients | ForEach-Object { [ordered]@{ address = $_.Address; status = $_.Status; received = ConvertTo-MDOP2IsoDate $_.Received } })
    }
}

function New-MDOP2WebState {
    <#
    .SYNOPSIS
    Creates the object shared between the main thread and the web server.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][hashtable]$Session
    )
    [hashtable]::Synchronized(@{
        Port          = $Port
        Token         = New-MDOP2RandomToken
        CsrfToken     = New-MDOP2RandomToken
        Session       = $Session
        # Read on the main thread: CSV exports use the admin's list separator so Excel splits the columns
        ListSeparator = (Get-Culture).TextInfo.ListSeparator
        Queue         = [Collections.Concurrent.ConcurrentQueue[object]]::new()
        Jobs          = [hashtable]::Synchronized(@{})
        Stop          = $false
        EndRequested  = $false
        Errors        = [Collections.Concurrent.ConcurrentQueue[string]]::new()
    })
}

function Add-MDOP2WebJob {
    <#
    .SYNOPSIS
    Queues work for the main thread and returns the job ID. Old finished jobs are dropped.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Type, [Parameter(Mandatory)][object]$Payload)
    $id = [guid]::NewGuid().ToString()
    $job = [hashtable]::Synchronized(@{
        Id = $id; Type = $Type; Payload = $Payload; State = 'Queued'; Progress = 'Waiting'; Result = $null; Error = $null
        Created = [datetime]::UtcNow
    })
    $jobs = $State.Jobs
    [Threading.Monitor]::Enter($jobs.SyncRoot)
    try {
        $old = @($jobs.Values | Where-Object { $_.State -in 'Done', 'Failed' } | Sort-Object Created)
        for ($i = 0; $i -lt $old.Count - 50; $i++) { $jobs.Remove($old[$i].Id) }
        $jobs[$id] = $job
    }
    finally { [Threading.Monitor]::Exit($jobs.SyncRoot) }
    $State.Queue.Enqueue($job)
    $id
}

function New-MDOP2QueuedJob {
    <#
    .SYNOPSIS
    Validates a request body for a job type and queues it. Returns the HTTP status and response body.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$State,
        [Parameter(Mandatory)][ValidateSet('Search', 'Plan', 'Execute', 'Status', 'Detail', 'Download', 'Timeline', 'SenderHistory', 'Similar', 'History', 'HistoryStatus', 'Mailboxes')][string]$Type,
        [AllowNull()][object]$Body
    )
    try {
        if ($null -ne $Body -and $Body -isnot [System.Collections.IDictionary]) { throw 'Request body must be a JSON object.' }
        $payload = switch ($Type) {
            'Search' { ConvertTo-MDOP2SearchRequest -Body $Body }
            'Plan' { ConvertTo-MDOP2PlanRequest -Body $Body }
            'Execute' { ConvertTo-MDOP2ExecuteRequest -Body $Body }
            'Status' { ConvertTo-MDOP2StatusRequest -Body $Body }
            { $_ -in 'Detail', 'Download' } { ConvertTo-MDOP2MessageRequest -Body $Body }
            { $_ -in 'Timeline', 'SenderHistory' } { ConvertTo-MDOP2MessageRequest -Body $Body -NoMailbox }
            'Similar' { ConvertTo-MDOP2SimilarRequest -Body $Body }
            'History' { ConvertTo-MDOP2HistoryRequest -Body $Body }
            'HistoryStatus' { ConvertTo-MDOP2HistoryStatusRequest -Body $Body }
            'Mailboxes' { ConvertTo-MDOP2MailboxesRequest -Body $Body }
        }
    }
    catch { return [pscustomobject]@{ Status = 400; Value = @{ error = $_.Exception.Message } } }
    [pscustomobject]@{ Status = 202; Value = @{ jobId = (Add-MDOP2WebJob -State $State -Type $Type -Payload $payload) } }
}

function Start-MDOP2WebServer {
    <#
    .SYNOPSIS
    Starts Pode on 127.0.0.1 on its own thread and waits until it accepts connections. Returns the shared state.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Session,
        [Parameter(Mandatory)][string]$WebRoot,
        [int]$TimeoutSeconds = 60
    )
    if ($script:Server) { throw 'The web server is already running.' }
    if (-not (Get-Module -ListAvailable -Name Pode)) { throw "Module 'Pode' is not installed. Run: Install-Module Pode -Scope CurrentUser" }
    $state = New-MDOP2WebState -Port (Get-MDOP2FreePort) -Session $Session
    $modules = @(
        (Join-Path $PSScriptRoot 'MDOP2.Common.psm1')
        (Join-Path $PSScriptRoot 'MDOP2.Web.psm1')
    )
    $rs = [runspacefactory]::CreateRunspace([initialsessionstate]::CreateDefault2())
    $rs.Open()
    $ps = [powershell]::Create()
    $ps.Runspace = $rs
    $null = $ps.AddScript({
        param($State, $Modules, $WebRoot)
        try {
            Import-Module Pode -ErrorAction Stop
            foreach ($m in $Modules) { Import-Module $m -ErrorAction Stop }
            Invoke-MDOP2WebServer -State $State -WebRoot $WebRoot
        }
        catch { $State.Errors.Enqueue("$($_.Exception.Message)`n$($_.ScriptStackTrace)") }
    }).AddArgument($state).AddArgument($modules).AddArgument((Resolve-Path $WebRoot).Path)
    $handle = $ps.BeginInvoke()
    $script:Server = @{ PowerShell = $ps; Runspace = $rs; Handle = $handle; State = $state }

    $sw = [Diagnostics.Stopwatch]::StartNew()
    while ($true) {
        if ($handle.IsCompleted) {
            $e = $null; $null = $state.Errors.TryDequeue([ref]$e)
            Stop-MDOP2WebServer
            throw "The web server did not start: $e"
        }
        $c = [Net.Sockets.TcpClient]::new()
        try { $c.Connect([Net.IPAddress]::Loopback, $state.Port); break }
        catch { if ($sw.Elapsed.TotalSeconds -gt $TimeoutSeconds) { Stop-MDOP2WebServer; throw 'The web server did not start in time.' } }
        finally { $c.Dispose() }
        Start-Sleep -Milliseconds 250
    }
    $state
}

function Test-MDOP2WebServerRunning {
    [OutputType([bool])]
    param()
    [bool]($script:Server -and -not $script:Server.Handle.IsCompleted)
}

function Get-MDOP2WebServerError {
    if (-not $script:Server) { return }
    $e = $null
    while ($script:Server.State.Errors.TryDequeue([ref]$e)) { $e }
}

function Stop-MDOP2WebServer {
    <#
    .SYNOPSIS
    Asks Pode to close (a timer in the server checks the flag every second) and disposes the runspace.
    #>

    [CmdletBinding()]
    param([int]$TimeoutSeconds = 15)
    if (-not $script:Server) { return }
    $s = $script:Server
    $script:Server = $null
    $s.State.Stop = $true
    $sw = [Diagnostics.Stopwatch]::StartNew()
    while (-not $s.Handle.IsCompleted -and $sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) { Start-Sleep -Milliseconds 200 }
    if (-not $s.Handle.IsCompleted) { try { $s.PowerShell.Stop() } catch { Write-Verbose "Stop: $($_.Exception.Message)" } }
    $s.PowerShell.Dispose()
    $s.Runspace.Dispose()
}

function Invoke-MDOP2WebServer {
    <#
    .SYNOPSIS
    Runs inside the web runspace. Blocks until the shared Stop flag is set.
    #>

    param([hashtable]$State, [string]$WebRoot)
    Start-PodeServer -Quiet -DisableTermination -DisableConsoleInput -Threads 4 -RootPath $WebRoot -ScriptBlock {
        Add-PodeEndpoint -Address 127.0.0.1 -Port $State.Port -Protocol Http
        $null = Set-PodeState -Name MDOP2 -Value $State
        $null = Set-PodeState -Name MDOP2Root -Value $WebRoot

        Add-PodeTimer -Name 'mdop2-stop' -Interval 1 -ScriptBlock {
            if ((Get-PodeState -Name MDOP2).Stop) { Close-PodeServer }
        }

        Add-PodeMiddleware -Name 'mdop2-guard' -ScriptBlock {
            $s = Get-PodeState -Name MDOP2
            foreach ($h in (Get-MDOP2SecurityHeader).GetEnumerator()) { Set-PodeHeader -Name $h.Key -Value $h.Value }
            $cookie = Get-PodeCookie -Name 'mdop2_session'
            $d = Test-MDOP2WebRequest -Method $WebEvent.Method -Path $WebEvent.Path `
                -HostHeader (Get-PodeHeader -Name 'Host') -Origin (Get-PodeHeader -Name 'Origin') `
                -ContentType (Get-PodeHeader -Name 'Content-Type') -Cookie $(if ($cookie) { $cookie.Value }) `
                -CsrfHeader (Get-PodeHeader -Name 'X-MDOP2-CSRF') -QueryToken $WebEvent.Query['t'] `
                -Port $s.Port -Token $s.Token -CsrfToken $s.CsrfToken
            if ($d.Allow) { return $true }
            if ($d.SetCookie) {
                Set-PodeHeader -Name 'Set-Cookie' -Value "mdop2_session=$($s.Token); Path=/; HttpOnly; SameSite=Strict"
                Set-PodeHeader -Name 'Location' -Value $d.Redirect
                Set-PodeResponseStatus -Code 303 -NoErrorPage
                return $false
            }
            Write-PodeJsonResponse -StatusCode $d.Status -Value @{ error = $d.Reason }
            return $false
        }

        foreach ($path in (Get-MDOP2StaticFile).Keys) {
            Add-PodeRoute -Method Get -Path $path -ScriptBlock {
                $f = (Get-MDOP2StaticFile)[$WebEvent.Path]
                $full = Join-Path (Get-PodeState -Name MDOP2Root) $f.File
                if ($f['Binary']) { Write-PodeTextResponse -Bytes ([IO.File]::ReadAllBytes($full)) -ContentType $f.Type }
                else { Write-PodeTextResponse -Value ([IO.File]::ReadAllText($full)) -ContentType $f.Type }
            }
        }

        Add-PodeRoute -Method Get -Path '/api/session' -ScriptBlock {
            $s = Get-PodeState -Name MDOP2
            Write-PodeJsonResponse -Value @{
                adminUpn = $s.Session.AdminUpn; tenantId = $s.Session.TenantId; tenantName = $s.Session.TenantName
                secretExpires = $s.Session.SecretExpires; csrf = $s.CsrfToken; listSeparator = $s.ListSeparator
            }
        }

        # Each route validates its body and queues a job for the main thread
        foreach ($type in 'Search', 'Plan', 'Execute', 'Status', 'Detail', 'Download', 'Timeline', 'SenderHistory', 'Similar', 'History', 'HistoryStatus', 'Mailboxes') {
            Add-PodeRoute -Method Post -Path "/api/$($type.ToLowerInvariant())" -ArgumentList $type -ScriptBlock {
                param($Type)
                $r = New-MDOP2QueuedJob -State (Get-PodeState -Name MDOP2) -Type $Type -Body $WebEvent.Data
                Write-PodeJsonResponse -StatusCode $r.Status -Value $r.Value
            }
        }
        Add-PodeRoute -Method Get -Path '/api/jobs/:id' -ScriptBlock {
            $job = (Get-PodeState -Name MDOP2).Jobs[[string]$WebEvent.Parameters['id']]
            if (-not $job) { Write-PodeJsonResponse -StatusCode 404 -Value @{ error = 'Unknown job.' }; return }
            Write-PodeJsonResponse -Depth 12 -Value ([ordered]@{
                id = $job.Id; type = $job.Type; state = $job.State; progress = $job.Progress; error = $job.Error; result = $job.Result
            })
        }
        # Hands a finished .eml download to the browser once, then drops the bytes from memory
        Add-PodeRoute -Method Get -Path '/api/download/:id' -ScriptBlock {
            $job = (Get-PodeState -Name MDOP2).Jobs[[string]$WebEvent.Parameters['id']]
            $file = if ($job -and $job.Type -eq 'Download' -and $job.State -eq 'Done') { $job['File'] }
            if (-not $file) { Write-PodeJsonResponse -StatusCode 404 -Value @{ error = 'This download is no longer available. Start it again.' }; return }
            $job.File = $null
            Set-PodeHeader -Name 'Content-Disposition' -Value (Get-MDOP2ContentDisposition -FileName $file.Name)
            Write-PodeTextResponse -Bytes $file.Bytes -ContentType 'message/rfc822'
        }
        Add-PodeRoute -Method Post -Path '/api/end' -ScriptBlock {
            (Get-PodeState -Name MDOP2).EndRequested = $true
            Write-PodeJsonResponse -Value @{ ended = $true }
        }
    }
}

function Get-MDOP2StaticFile { $script:StaticFiles }

function Get-MDOP2ContentDisposition {
    <#
    .SYNOPSIS
    Content-Disposition for a download: always an attachment, with an ASCII name and the RFC 5987 UTF-8 name.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string]$FileName)
    $ascii = ($FileName -replace '[^\x20-\x7E]', '_') -replace '["\\;]', '_'
    "attachment; filename=`"$ascii`"; filename*=UTF-8''$([uri]::EscapeDataString($FileName))"
}

Export-ModuleMember -Function New-MDOP2RandomToken, Test-MDOP2SecretEqual, Test-MDOP2WebRequest, Get-MDOP2SecurityHeader,
    ConvertTo-MDOP2SearchRequest, ConvertTo-MDOP2PlanRequest, ConvertTo-MDOP2ExecuteRequest, ConvertTo-MDOP2StatusRequest,
    ConvertTo-MDOP2MessageRequest, ConvertTo-MDOP2SimilarRequest, ConvertTo-MDOP2HistoryRequest, ConvertTo-MDOP2HistoryStatusRequest, ConvertTo-MDOP2MailboxesRequest, Get-MDOP2ContentDisposition, ConvertTo-MDOP2GroupView, New-MDOP2WebState, Add-MDOP2WebJob, New-MDOP2QueuedJob, Start-MDOP2WebServer, Stop-MDOP2WebServer,
    Test-MDOP2WebServerRunning, Get-MDOP2WebServerError, Invoke-MDOP2WebServer, Get-MDOP2StaticFile