11 July 2026 5 min read

Access Exchange Online with PowerShell: Replace EWS with Microsoft Graph

EWS ends in Exchange Online on October 1, 2026. Learn how to register an app, authenticate a PowerShell script using a certificate, restrict access to individual mailboxes, and process messages and attachments through Microsoft Graph.

Microsoft will retire Exchange Web Services (EWS) in Exchange Online on October 1, 2026. Scripts that retrieve messages or attachments from a mailbox must therefore switch to Microsoft Graph.

The example in this article runs without user sign-in: it downloads ZIP attachments from a mailbox, extracts them, moves processed messages, and then sends a report. It requires an app registration, a certificate, and two Graph permissions. Values such as example.com, tenant ID, and app ID are placeholders.

1. Required PowerShell modules

Three Microsoft Graph SDK modules are sufficient; you do not need the entire Microsoft.Graph meta-module:

Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Mail, Microsoft.Graph.Users.Actions -Scope AllUsers
Explanation of options
OptionEffect
Microsoft.Graph.Authentication, Microsoft.Graph.Mail, Microsoft.Graph.Users.ActionsThe three required submodules as a positional argument to -Name: authentication, mail cmdlets, and actions such as Send-MgUserMail
-Scope AllUsersInstalls the modules system-wide under Program Files; required so they are also available to the Scheduled Task service account configured later (requires administrator privileges)

2. App registration in Entra ID

An unattended script signs in as an application with its own permissions. Create a new registration in the Entra Admin Center under App registrations and assign these two permissions under API permissions → Microsoft Graph → Application permissions:

  • Mail.ReadWrite: read emails and move them after processing

  • Mail.Send: send the report email

Then grant admin consent and note the tenant ID and Application (Client) ID.

3. Certificate instead of client secret

An app-only sign-in works with a client secret or certificate. For scheduled tasks, a certificate is the better choice: the private key remains in the certificate store, and the script contains no password. Create the certificate on the server that will run the script and export only the public portion:

$cert = New-SelfSignedCertificate -Subject "CN=eCall-Graph" `
    -CertStoreLocation "Cert:\LocalMachine\My" `
    -KeyExportPolicy NonExportable -KeySpec Signature `
    -KeyLength 2048 -NotAfter (Get-Date).AddYears(2)

Export-Certificate -Cert $cert -FilePath .\eCall-Graph.cer
$cert.Thumbprint   # -> im Skript als Thumbprint verwenden
Explanation of options
OptionEffect
New-SelfSignedCertificate -SubjectCertificate subject name; used only for recognition in the certificate store
-CertStoreLocation "Cert:\LocalMachine\My"Stores the certificate in the computer store rather than the user store; it is therefore available independently of the signed-in user
-KeyExportPolicy NonExportablePrevents export of the private key; it never leaves the server
-KeySpec SignatureCreates a signing key; the app sign-in uses it to sign the token request assertion
-KeyLength 2048RSA key length of 2048 bits
-NotAfter (Get-Date).AddYears(2)Expiration in two years; the certificate must then be renewed and uploaded again
Export-Certificate -CertCertificate object to export
-FilePathTarget file; as a .cer, it contains only the public portion

Upload the exported .cer file under Certificates & secrets in the app registration. The Scheduled Task account needs read permission for the private key (certlm.msc → certificate → All Tasks → Manage Private Keys).

4. Restrict access to individual mailboxes

Application Permissions initially apply to all mailboxes in the tenant. Therefore, restrict the app with an Application Access Policy to a mail-enabled security group. Run this step once in Exchange Online PowerShell:

New-ApplicationAccessPolicy -AppId "<App-ID>" `
    -PolicyScopeGroupId "graph-mailboxes@example.com" `
    -AccessRight RestrictAccess `
    -Description "eCall Graph: nur Log-Postfach"

# Check effectiveness
Test-ApplicationAccessPolicy -AppId "<App-ID>" -Identity "ecall-logs@example.com"
Explanation of options
OptionEffect
New-ApplicationAccessPolicy -AppIdApplication (Client) ID of the app registration to which the policy applies
-PolicyScopeGroupIdMail-enabled security group whose members define the scope
-AccessRight RestrictAccessRestricts the app to the group’s mailboxes; the alternative DenyAccess would block exactly those mailboxes
-DescriptionFree text for documenting the policy
Test-ApplicationAccessPolicy -IdentityChecks whether the app may access a specific mailbox (AccessCheckResult: Granted or Denied)

5. Establish a connection

Authentication uses the tenant ID, app ID, and certificate thumbprint, with no user interaction at all:

$TenantId   = "00000000-0000-0000-0000-000000000000"
$ClientId   = "00000000-0000-0000-0000-000000000000"
$Thumbprint = "0000000000000000000000000000000000000000"
$Mailbox    = "ecall-logs@example.com"

Import-Module Microsoft.Graph.Authentication, Microsoft.Graph.Mail, Microsoft.Graph.Users.Actions
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId `
    -CertificateThumbprint $Thumbprint -NoWelcome
Explanation of options
OptionEffect
Connect-MgGraph -TenantIdTenant to which the app signs in
-ClientIdApplication (Client) ID of the app registration
-CertificateThumbprintSelects the sign-in certificate from the local certificate store by its thumbprint; combining -ClientId and a certificate provides an app-only sign-in without a user
-NoWelcomeSuppresses the welcome message after sign-in; useful for script output and logs

6. Read messages and download ZIP attachments

The script can now go through the inbox, save and extract ZIP attachments, and move processed messages to “Deleted Items.” The download uses the /$value endpoint and Invoke-MgGraphRequest -OutputFilePath. This writes the raw content directly to a file without keeping a large attachment entirely in memory:

Add-Type -AssemblyName System.IO.Compression.FileSystem
$Zielordner = "D:\Import\{0:yyyyMMdd_HHmmss}" -f (Get-Date)

$messages = Get-MgUserMessage -UserId $Mailbox -Top 100 `
    -Property id, subject, hasAttachments

foreach ($msg in $messages) {
    $ordner = Join-Path $Zielordner $msg.Id
    New-Item -Path $ordner -ItemType Directory -Force | Out-Null

    $anhaenge = Get-MgUserMessageAttachment -UserId $Mailbox -MessageId $msg.Id |
        Where-Object {
            $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.fileAttachment' -and
            $_.Name -like '*.zip'
        }

    foreach ($att in $anhaenge) {
        $zip = Join-Path $ordner $att.Name
        $uri = "https://graph.microsoft.com/v1.0/users/$Mailbox/messages/$($msg.Id)/attachments/$($att.Id)/`$value"
        Invoke-MgGraphRequest -Method GET -Uri $uri -OutputFilePath $zip
        [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $ordner)
    }

    # move processed email to "Deleted Items"
    Move-MgUserMessage -UserId $Mailbox -MessageId $msg.Id -DestinationId "deleteditems" | Out-Null
}
Explanation of options
OptionEffect
Add-Type -AssemblyName System.IO.Compression.FileSystemLoads the .NET assembly with the ZipFile class for extraction
Get-MgUserMessage -UserIdMailbox whose messages are read; required for app-only sign-in
-Top 100Limits the query to a maximum of 100 messages per call
-Property id, subject, hasAttachmentsRequests only the required fields; this reduces the response size and speeds up the call
Get-MgUserMessageAttachment -MessageIdMessage whose attachments are listed
Invoke-MgGraphRequest -Method GETHTTP method for the direct call to the Graph API
-UriEndpoint called; the appended /$value returns the attachment’s raw file content instead of a JSON object
-OutputFilePathWrites the response directly to the target file without keeping the entire attachment in memory
Move-MgUserMessage -DestinationId "deleteditems"Moves the processed message to the target folder; deleteditems is the well-known folder name for “Deleted Items”

For more than 100 emails, use Get-MgUserMessage -All with paging; one batch is usually sufficient for a monthly run.

7. Send a report email through Graph

Send-MailMessage is also deprecated. Using the same app registration (permission Mail.Send), the email is sent directly through Graph, here with a file as a base64-encoded attachment:

$pfad = "D:\Reports\report.csv"
$body = @{
    message = @{
        subject = "eCall Report"
        body    = @{ contentType = "HTML"; content = "<b>Lauf erfolgreich</b>" }
        toRecipients = @(@{ emailAddress = @{ address = "empfaenger@example.com" } })
        attachments  = @(@{
            "@odata.type" = "#microsoft.graph.fileAttachment"
            name          = Split-Path $pfad -Leaf
            contentBytes  = [Convert]::ToBase64String([IO.File]::ReadAllBytes($pfad))
        })
    }
    saveToSentItems = $true
}
Send-MgUserMail -UserId "reporting@example.com" -BodyParameter $body
Explanation of options
OptionEffect
-UserIdMailbox on whose behalf the email is sent; it must be covered by the scope of the Application Access Policy
-BodyParameterThe complete message as a hashtable in the Graph schema: message with subject, body, recipients, and attachments, plus saveToSentItems for storage in “Sent Items”

8. Run unattended

As a scheduled task, the script runs without sign-in because the certificate is in the account’s store:

$action  = New-ScheduledTaskAction -Execute "powershell.exe" `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File "D:\Scripts\graph-import.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 06:00
Register-ScheduledTask -TaskName "eCall-Graph-Import" -Action $action -Trigger $trigger `
    -User "DOMAIN\svc-ecall" -Password (Read-Host "Passwort")
Explanation of options
OptionEffect
New-ScheduledTaskAction -ExecuteProgram to run, here powershell.exe
-ArgumentCommand line for the program: -NoProfile skips profile scripts, -ExecutionPolicy Bypass bypasses the script policy for this call, and -File specifies the script
New-ScheduledTaskTrigger -Daily -At 06:00Daily trigger at 6:00 AM
Register-ScheduledTask -TaskNameName of the task in Task Scheduler
-Action / -TriggerLinks the previously created action and trigger to the task
-UserAccount under which the task runs; the private key must be readable in its certificate store
-Password (Read-Host "Passwort")Prompts interactively for the account password so the task can also start without a signed-in user; this keeps it out of the script and history file

The complete example with logging and error handling is available on GitHub: pfstr/eCall-Log-Analyzer.

Sources

  1. Microsoft – “Retirement of Exchange Web Services in Exchange Online”

    Announcement and cutoff date (October 1, 2026) for the end of EWS in Exchange Online.

    https://techcommunity.microsoft.com/blog/exchange/retirement-of-exchange-web-services-in-exchange-online/3924440
  2. Microsoft Learn – “Get access without a user (App-only)”

    App-only authentication to Microsoft Graph using a certificate.

    https://learn.microsoft.com/en-us/graph/auth-v2-service
  3. Microsoft Learn – “Limiting application permissions to specific mailboxes”

    Application Access Policy for restricting the app to individual mailboxes.

    https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access

Comments

Comments are loaded from GitHub / Giscus.