Microsoft 365 Expired? Rescuing a Mailbox from the Local OST into a PST
The M365 subscription lapsed, the mailbox was locked server-side, only Outlook's OST cache was left. How I got 3,844 items into a PST plus .msg files.
A Microsoft 365 subscription I'd set up for one of my own mail addresses ran out. It had started as a trial, nobody converted it, and one day Outlook couldn't connect anymore. Server side: mailbox locked. No webmail, no export from the admin center, nothing.
What was left was the local Outlook cache on my PC: an OST file. OSTs are Outlook's offline copy of an Exchange mailbox. They're not meant to be opened on their own, and there's no "File → Export" that works when the server is gone. But as long as the Outlook profile still exists, Outlook will happily show you the cached data in offline mode. That's the door.
End result: one PST with 3,844 items, a folder with every item as a separate .msg file, and a raw copy of the OST. Four mails didn't make it: S/MIME-encrypted ones, because the certificate to decrypt them wasn't on that PC.
TL;DR: First copy the OST somewhere safe and don't touch the Outlook profile. Then drive Outlook via COM from PowerShell: save every item as .msg (works offline), create a new PST, and move items into it via OpenSharedItem + Move. Folder-level CopyTo fails for mail folders because Outlook tries to reach Exchange. Do it before the subscription runs out if you possibly can.
Step 0: copy the OST, keep the profile
Close Outlook and copy the OST before you experiment with anything. It lives here by default:
Get-ChildItem "$env:LOCALAPPDATA\Microsoft\Outlook\*.ost" | Select-Object Name, Length, LastWriteTime
Copy-Item "$env:LOCALAPPDATA\Microsoft\Outlook\*.ost" D:\mail-rescue\raw\
Then: don't delete the Outlook profile, don't "repair" the account, don't sign in again with different credentials. The OST is bound to its profile. If Outlook decides to resync or rebuild the cache against a server that refuses you, you can end up with an empty cache. The raw copy is your insurance, but it's much easier to work with an OST that Outlook still opens.
Start Outlook. It will complain it can't connect. Fine. Your folders and mails should still be visible. Switch to Work Offline so it stops trying.
Step 1: everything as .msg
The most robust export is one file per item. MailItem.SaveAs with the .msg format works fully offline, because it only reads from the local cache:
$ol = New-Object -ComObject Outlook.Application
$ns = $ol.GetNamespace("MAPI")
$olMSGUnicode = 9
function Export-Folder($folder, $path) {
New-Item -ItemType Directory -Force -Path $path | Out-Null
$i = 0
foreach ($item in @($folder.Items)) {
$i++
$name = ($item.Subject -replace '[\\/:*?"<>|]', '_')
if ($name.Length -gt 80) { $name = $name.Substring(0, 80) }
try { $item.SaveAs((Join-Path $path ("{0:D5}_{1}.msg" -f $i, $name)), $olMSGUnicode) }
catch { Write-Warning "$($folder.FolderPath) #$i : $($_.Exception.Message)" }
}
foreach ($sub in @($folder.Folders)) {
Export-Folder $sub (Join-Path $path ($sub.Name -replace '[\\/:*?"<>|]', '_'))
}
}
$root = $ns.DefaultStore.GetRootFolder()
Export-Folder $root "D:\mail-rescue\msg"
Number prefixes because subjects collide all the time ("Re: Invoice", "Fwd:", empty subjects). Keep the warnings; every warning is an item you need to look at.
That .msg folder ended up with 3,845 files, one more than the PST. Keep reading for why.
Step 2: a PST to put it all in
A PST is the portable format: any Outlook can open it, and it doesn't care about Exchange. Create one and attach it to the profile:
$pst = "D:\mail-rescue\mailbox-final.pst"
$ns.AddStoreEx($pst, 2) # 2 = olStoreUnicode
$pstStore = $ns.Stores | Where-Object { $_.FilePath -eq $pst }
$pstRoot = $pstStore.GetRootFolder()
Gotcha: Folder.CopyTo fails for mail folders
The obvious move is $folder.CopyTo($pstRoot) per top-level folder. For mail folders this failed with an error about the logon to Exchange failing. Outlook apparently wants to talk to the server for that operation, and the server isn't talking back.
For the calendar folder, CopyTo worked fine. Don't ask me why the difference. I just took what worked.
Gotcha: OpenSharedItem + Move for mail, but not for appointments
What worked for mail: take the .msg files from step 1, open each one with OpenSharedItem, and move it into the matching PST folder:
function Import-Msgs($srcPath, $pstFolder) {
foreach ($f in Get-ChildItem $srcPath -Filter *.msg) {
try {
$item = $ns.OpenSharedItem($f.FullName)
$null = $item.Move($pstFolder)
} catch { Write-Warning "$($f.Name): $($_.Exception.Message)" }
}
foreach ($d in Get-ChildItem $srcPath -Directory) {
$sub = try { $pstFolder.Folders.Item($d.Name) } catch { $pstFolder.Folders.Add($d.Name) }
Import-Msgs $d.FullName $sub
}
}
This route didn't work for appointments, which is why the calendar went via CopyTo instead. Two routes, one per item type. That's also where the off-by-one comes from: one meeting request from the deleted items folder exists only as a .msg, not in the PST. Not worth fighting over; it's archived either way.
Gotcha: "This item is no longer digitally signed"
Signed mails (S/MIME signatures, not encryption) triggered a modal dialog during the import, warning that the item would no longer be digitally signed. A modal dialog in a script that processes thousands of items means the script just stops and waits.
SendKeys didn't reach the dialog. What worked was posting the "Yes" button command directly to the dialog window:
Add-Type @'
using System; using System.Runtime.InteropServices;
public static class W {
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c, string t);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
}
'@
$WM_COMMAND = 0x0111; $IDYES = 6
# run this in a second PowerShell while the import runs; title is localized, check yours
while ($true) {
$h = [W]::FindWindow("#32770", "Microsoft Outlook")
if ($h -ne [IntPtr]::Zero) { [W]::PostMessage($h, $WM_COMMAND, [IntPtr]$IDYES, [IntPtr]::Zero) | Out-Null }
Start-Sleep -Milliseconds 300
}
#32770 is the standard Windows dialog class. Match on the title too, or you'll be clicking "Yes" on dialogs you never read. Kill the watcher when the import is done.
Gotcha: S/MIME-encrypted mail is gone without the cert
Four mails were S/MIME encrypted. Outlook can only decrypt those with the private key of the certificate they were encrypted for. That key wasn't on this PC, so those four items can't be opened, and they're not in the export as readable mail.
If you use S/MIME: make sure the certificate with private key (a .pfx) is backed up somewhere other than the mailbox it protects, and import it on the machine you're rescuing from before you start.
Gotcha: PowerShell 5.1 and umlauts in folder names
My folder names had umlauts. My script was saved as UTF-8 without BOM. Windows PowerShell 5.1 reads BOM-less scripts in the system's ANSI codepage, so the folder names in my script didn't match the real ones and lookups silently failed. Save scripts as UTF-8 with BOM for 5.1, or use PowerShell 7, which defaults to UTF-8.
Gotcha: Defender vs. third-party exporters
I also looked at XstReader, an open-source OST/PST reader with a command-line exporter. Defender blocked its XstExport.exe on my machine. Rather than poke holes into Defender on the box holding the mail archive, I stuck with Outlook's own COM interface. Slower, uglier, but nothing to install.
Step 3: detach and verify
$ns.RemoveStore($pstRoot)
Then open the PST in a fresh Outlook profile (or on another machine) and spot-check: item counts per folder against the .msg folder, a few attachments, the calendar, contacts. My PST came out at around 600 MB.
Keep all three artifacts: the PST to work with, the .msg folder as a format-independent fallback, the raw OST in case you ever need a forensic tool.
Do this before the subscription expires
Microsoft 365 doesn't delete data the second a subscription ends. There's a staged process of expired → disabled → deleted, during which admins may still have some access, and the exact durations depend on the subscription type (trials differ from paid plans). Look up the current documentation for your plan instead of trusting a number from a blog post. In my case the mailbox was already locked server-side by the time I noticed, so the local OST was the only thing left.
The sane order is the boring one:
- Put a calendar reminder on the subscription's end date, two weeks early.
- Export a PST (Outlook: File → Open & Export → Import/Export → Export to a file) while the account still works. Everything is clean and supported then.
- Back up any S/MIME certificates with private keys.
- Store the export somewhere that isn't the PC. I put mine on a storage box mapped as a network drive, with a separate off-site copy via restic.
I've had a whole server vanish under me this year; lessons from that provider outage are the same lessons, just bigger. And if you're scripting Windows chores like this, my PowerShell cheatsheet covers the basics.