| name | outlook-email-export |
| compatibility | Requires Windows with the classic Outlook desktop client and PowerShell 5.1+ for COM automation. |
| description | Export and extract emails from Outlook via COM automation in PowerShell. Covers searching by sender, recipient, CC, subject, or date range across Inbox, Sent Items, Direct, and Deleted Items folders. Exports to Markdown files with metadata tables. Handles COM elevation issues, DASL filters, recipient enumeration, deduplication, incremental exports, .msg extraction, and embedded email processing via CreateItemFromTemplate. USE FOR: export emails, extract emails, Outlook export, read emails, email to markdown, export Outlook folder, search Outlook emails, email extraction, COM automation Outlook, Outlook COM, export mailbox, Mueller emails, email archive, export calendar, Outlook calendar, .msg extraction, embedded email, CreateItemFromTemplate. DO NOT USE FOR: sending emails (use send-outlook-email skill), Microsoft Graph API, Exchange Web Services, IMAP/POP access. |
Outlook Email Export via COM Automation
Export emails from Microsoft Outlook to Markdown files using PowerShell COM automation.
When to Use
- Extract emails from a specific Outlook mailbox folder (Inbox, Sent Items, Direct, Deleted Items)
- Filter emails by sender, recipient, CC, subject keyword, or date range
- Export email metadata and body as Markdown files for documentation or analysis
- Incremental export: only export emails newer than a given cutoff date
- Build an email index (CSV/JSON) from exported Markdown files
Prerequisites
- Microsoft Outlook must be installed and running (or startable via COM)
- The target email profile must be configured in Outlook
- Works on Windows only (COM is a Windows technology)
- PowerShell 5.1 (Windows PowerShell) is recommended for COM; PowerShell 7+ has limitations (see Elevation section)
Critical: VS Code Elevation and COM Access
This is the #1 issue when running Outlook COM scripts from VS Code.
When VS Code runs elevated (as Administrator) but Outlook runs non-elevated
(normal user), COM calls across integrity levels are blocked with error:
0x80080005 CO_E_SERVER_EXEC_FAILURE.
Solution: Use a Scheduled Task
Run the export script via a Windows Scheduled Task, which executes at the
user's normal integrity level:
$scriptPath = 'D:\path\to\Export-Script.ps1'
$logFile = 'D:\path\to\export_log.txt'
# Wrapper script that captures all output
$wrapperPath = Join-Path $env:TEMP '_run_export.ps1'
$wrapperCode = "& '$scriptPath' *>&1 | Out-File -FilePath '$logFile' -Encoding UTF8"
Set-Content -Path $wrapperPath -Value $wrapperCode -Encoding UTF8
# Create and run scheduled task
$taskName = 'OutlookEmailExport'
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$wrapperPath`""
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(2)
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings | Out-Null
Start-ScheduledTask -TaskName $taskName
As a Background Task (Agent Best Practice)
MANDATORY: Never poll in a loop waiting for COM exports to finish.
This wastes tokens and produces no useful output. Instead:
- Start the scheduled task
- Start a background terminal (
isBackground: true) that monitors
- Inform the user: "Export is running, I'll check the results shortly."
- Use
get_terminal_output once or read the log file after completion
# Start export as background task
Start-ScheduledTask -TaskName $taskName
# In a BACKGROUND terminal (isBackground: true), monitor:
while ((Get-ScheduledTask -TaskName $taskName).State -eq 'Running') {
Start-Sleep -Seconds 15
}
Write-Host "Export complete."
Get-Content $logFile
Or simply wait a reasonable time and then read the log file.
Detecting completion without polling:
# Check if the powershell process from the task is still running
$proc = Get-Process powershell -ErrorAction SilentlyContinue
if (-not $proc) {
# Process finished, read log file
Get-Content $logFile
}
Connecting to Outlook
Windows PowerShell 5.1 (Preferred)
# Connect to running instance
try {
$outlook = [Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application')
} catch {
$outlook = New-Object -ComObject Outlook.Application
Start-Sleep -Seconds 3
}
$namespace = $outlook.GetNamespace('MAPI')
PowerShell 7+ (Limited)
GetActiveObject is not available in .NET Core. Use New-Object -ComObject
or [Activator]::CreateInstance([Type]::GetTypeFromProgID('Outlook.Application')).
Both require the same integrity level as Outlook (see Elevation section).
Finding the Target Mailbox Store
$targetStore = $null
foreach ($store in $namespace.Stores) {
if (($store.DisplayName -match 'jan\.schmidt' -or
$store.DisplayName -match 'Jan\.Schmidt@contoso\.com') -and
$store.DisplayName -notmatch 'Online Archive') {
$targetStore = $store
}
}
# Access standard folders from the store
$inbox = $targetStore.GetDefaultFolder(6) # olFolderInbox
$sentItems = $targetStore.GetDefaultFolder(5) # olFolderSentMail
$deleted = $targetStore.GetDefaultFolder(3) # olFolderDeletedItems
# Custom/named folders under root
$rootFolder = $targetStore.GetRootFolder()
foreach ($folder in $rootFolder.Folders) {
if ($folder.Name -eq 'Direct') { $directFolder = $folder }
}
Outlook Folder Constants
| Constant | Value | Folder |
|---|
olFolderDeletedItems | 3 | Deleted Items |
olFolderSentMail | 5 | Sent Items |
olFolderInbox | 6 | Inbox |
olFolderCalendar | 9 | Calendar |
olFolderDrafts | 16 | Drafts |
olFolderJunk | 23 | Junk Email |
Store Discovery: Always List Stores First
IMPORTANT: User-provided mailbox addresses may not match the actual store
DisplayName in Outlook. Always list all stores first to find the correct match.
foreach ($store in $namespace.Stores) {
Write-Output "Found store: $($store.DisplayName)"
}
Common mismatches:
- User says
geword@company.com but store is geworg@company.com
- User says
company but store has full email user@company.com
- Online Archive stores have separate entries — filter with
-notmatch 'Online Archive'
Use flexible matching with -match on partial patterns rather than exact string comparison.
Searching Emails
Method 1: DASL Restrict Filter (Fast, Server-Side)
Best for searching by sender email address, subject keyword, or specific fields:
# Search by sender email
$filter = "@SQL=""urn:schemas:httpmail:fromemail"" ci_phrasematch 'alex.mueller'"
$results = $folder.Items.Restrict($filter)
# Search by subject keyword
$filter = "@SQL=""urn:schemas:httpmail:subject"" ci_phrasematch 'PRL0158330'"
$results = $folder.Items.Restrict($filter)
# Search by To field
$filter = "@SQL=""urn:schemas:httpmail:to"" ci_phrasematch 'maria.weber'"
$results = $folder.Items.Restrict($filter)
# Search by CC field
$filter = "@SQL=""urn:schemas:httpmail:cc"" ci_phrasematch 'maria.weber'"
$results = $folder.Items.Restrict($filter)
Method 2: Date Restrict Filter (Fast Pre-Filter)
Best for incremental exports — pre-filter by date, then check criteria in PowerShell:
$cutoffDate = [datetime]::new(2026, 3, 1)
$dateFilter = $cutoffDate.ToString("MM/dd/yyyy HH:mm")
$filter = "[ReceivedTime] >= '$dateFilter'"
$recentItems = $folder.Items.Restrict($filter)
# Then iterate $recentItems and check sender/recipient in PowerShell
Always prefer Restrict over iterating all items. A 28,000-item folder takes
~15 minutes to iterate via COM but Restrict returns in seconds.
Method 3: Full Iteration (Slow, Last Resort)
Only use when DASL filters can't match the criteria (e.g., matching against
resolved Exchange recipients):
$totalItems = $folder.Items.Count
for ($i = $totalItems; $i -ge 1; $i--) {
$item = $folder.Items.Item($i)
if ($item.Class -ne 43) { continue } # 43 = olMail
# Check criteria...
}
Checking Sender and Recipients
Testing if a Person Is Involved
$searchPatterns = @(
'Alex Mueller',
'alex.mueller',
'amuell',
'Mueller, Alex'
)
function Test-PersonInvolved {
param($MailItem, [string[]]$Patterns)
# Check sender
foreach ($p in $Patterns) {
if ($MailItem.SenderName -match [regex]::Escape($p) -or
$MailItem.SenderEmailAddress -match [regex]::Escape($p)) {
return $true
}
}
# Check To and CC display strings
foreach ($p in $Patterns) {
if ($MailItem.To -match [regex]::Escape($p) -or
$MailItem.CC -match [regex]::Escape($p)) {
return $true
}
}
# Check resolved recipients (most reliable)
try {
for ($i = 1; $i -le $MailItem.Recipients.Count; $i++) {
$r = $MailItem.Recipients.Item($i)
foreach ($p in $Patterns) {
if ($r.Name -match [regex]::Escape($p) -or
$r.Address -match [regex]::Escape($p)) {
return $true
}
}
# Try Exchange SMTP address
try {
$smtp = $r.AddressEntry.GetExchangeUser().PrimarySmtpAddress
foreach ($p in $Patterns) {
if ($smtp -match [regex]::Escape($p)) { return $true }
}
} catch { }
}
} catch { }
return $false
}
Getting Recipient Details
function Get-RecipientDetails {
param($MailItem)
$toList = @()
$ccList = @()
try {
for ($i = 1; $i -le $MailItem.Recipients.Count; $i++) {
$r = $MailItem.Recipients.Item($i)
switch ($r.Type) {
1 { $toList += $r.Name } # olTo
2 { $ccList += $r.Name } # olCC
3 { $ccList += "$($r.Name) (BCC)" }
}
}
} catch {
if ($MailItem.To) { $toList = @($MailItem.To) }
if ($MailItem.CC) { $ccList = @($MailItem.CC) }
}
return @{
To = ($toList -join '; ')
CC = ($ccList -join '; ')
}
}
Exporting to Markdown
Standard Markdown Format
All exports in this project use this consistent format:
$dateStr = $item.ReceivedTime.ToString('yyyy-MM-dd-HH-mm')
$safeSubject = Get-SafeFileName -Name $item.Subject
$fileName = "${dateStr}_${safeSubject}.md"
$recipientInfo = Get-RecipientDetails -MailItem $item
$ccLine = ''
if ($recipientInfo.CC) {
$ccLine = "`n| **CC** | $($recipientInfo.CC) |"
}
$md = @"
# $($item.Subject)
| Field | Value |
|-------|-------|
| **Folder** | $folderLabel |
| **Date** | $($item.ReceivedTime.ToString('yyyy-MM-dd HH-mm')) |
| **From** | $($item.SenderName) |
| **To** | $($recipientInfo.To) |$ccLine
$($item.Body)
"@
[System.IO.File]::WriteAllText($filePath, $md, [System.Text.Encoding]::UTF8)
Safe Filename Generation
function Get-SafeFileName {
param([string]$Name, [int]$MaxLength = 80)
$safe = $Name -replace '[\\/:*?"<>|]', '_'
$safe = $safe -replace '\s+', ' '
if ($safe.Length -gt $MaxLength) { $safe = $safe.Substring(0, $MaxLength) }
return $safe.Trim()
}
File Organization
Emails are sorted into subdirectories:
Mueller_Emails/
├── from/ # Emails FROM the person (received)
├── to/ # Emails TO the person (sent by mailbox owner)
├── email_index.csv
└── email_index.json
Deduplication by Filename
# Build set of existing filenames
$existingFiles = @{}
Get-ChildItem -Path $fromFolder -Filter '*.md' | ForEach-Object { $existingFiles[$_.Name] = $true }
Get-ChildItem -Path $toFolder -Filter '*.md' | ForEach-Object { $existingFiles[$_.Name] = $true }
# Skip if already exported
if ($existingFiles.ContainsKey($fileName)) {
Write-Host "SKIP (exists): $fileName"
continue
}
# Handle duplicates within the same run
$counter = 1
while (Test-Path $filePath) {
$fileName = "${dateStr}_${safeSubject}_${counter}.md"
$filePath = Join-Path $targetDir $fileName
$counter++
}
Building an Email Index
After exporting, parse the Markdown files to create a structured index:
# Parse all exported emails
$emailFiles = @()
$emailFiles += Get-ChildItem "$BasePath\from\*.md" |
ForEach-Object { [PSCustomObject]@{ File = $_; Direction = 'received' } }
$emailFiles += Get-ChildItem "$BasePath\to\*.md" |
ForEach-Object { [PSCustomObject]@{ File = $_; Direction = 'sent' } }
# Extract metadata from each file's Markdown table
foreach ($item in $emailFiles) {
$content = Get-Content $item.File.FullName -Raw
# Parse subject from H1: ^# (.+)$
# Parse metadata from table: \| \*\*Field\*\* \| (.+?) \|
}
# Export as CSV + JSON
$results | Export-Csv -Path 'email_index.csv' -NoTypeInformation -Encoding UTF8
$results | ConvertTo-Json -Depth 5 | Set-Content 'email_index.json' -Encoding UTF8
See scripts/Parse-EmailIndex.ps1 for the full implementation with language
detection, thread grouping, word count, and preview text extraction.
Extracting Embedded .msg Files
Emails often contain .msg attachments (forwarded emails saved as files).
Extract them using Outlook's CreateItemFromTemplate:
function Convert-MsgToMarkdown {
param([string]$MsgPath, $Outlook)
$mdPath = $MsgPath -replace '\.msg$', '.md'
if (Test-Path $mdPath) { return 'SKIP' }
try {
$item = $Outlook.CreateItemFromTemplate($MsgPath)
if (-not $item) { return 'OPEN_FAIL' }
$subject = if ($item.Subject) { $item.Subject } else { 'no-subject' }
$from = if ($item.SenderName) { $item.SenderName } else { 'unknown' }
$to = if ($item.To) { $item.To } else { '' }
$cc = if ($item.CC) { $item.CC } else { '' }
$date = if ($item.ReceivedTime -and $item.ReceivedTime.Year -gt 2000) {
$item.ReceivedTime.ToString('yyyy-MM-dd HH:mm')
} elseif ($item.SentOn -and $item.SentOn.Year -gt 2000) {
$item.SentOn.ToString('yyyy-MM-dd HH:mm')
} else { 'unknown' }
$body = if ($item.Body) { $item.Body } else { '' }
# Build markdown using StringBuilder (avoid here-strings with pipe chars)
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine("# $subject")
[void]$sb.AppendLine()
[void]$sb.AppendLine('| Field | Value |')
[void]$sb.AppendLine('|-------|-------|')
[void]$sb.AppendLine("| **Source** | Embedded .msg |")
[void]$sb.AppendLine("| **Date** | $date |")
[void]$sb.AppendLine("| **From** | $from |")
[void]$sb.AppendLine("| **To** | $to |")
if ($cc) { [void]$sb.AppendLine("| **CC** | $cc |") }
[void]$sb.AppendLine()
[void]$sb.AppendLine($body)
# Extract nested attachments if present
if ($item.Attachments.Count -gt 0) {
$nestedDir = Join-Path ([System.IO.Path]::GetDirectoryName($MsgPath)) `
([System.IO.Path]::GetFileNameWithoutExtension($MsgPath) + '_nested')
[void]$sb.AppendLine(); [void]$sb.AppendLine('## Nested Attachments')
for ($a = 1; $a -le $item.Attachments.Count; $a++) {
$att = $item.Attachments.Item($a)
if (-not (Test-Path $nestedDir)) {
New-Item -Path $nestedDir -ItemType Directory -Force | Out-Null
}
$attPath = Join-Path $nestedDir (Get-SafeFileName -Name $att.FileName)
$att.SaveAsFile($attPath)
[void]$sb.AppendLine("- **$($att.FileName)**")
}
}
$item.Close(1) # olDiscard
[System.IO.File]::WriteAllText($mdPath, $sb.ToString(), [System.Text.Encoding]::UTF8)
return 'OK'
} catch {
return "ERR: $_"
}
}
Note: CreateItemFromTemplate requires Outlook COM and therefore
Windows PowerShell 5.1 (for GetActiveObject). The .msg file must be
accessible from the file system (not from a ZIP or memory).
Complete Export Script Template
<#
.SYNOPSIS
Exports emails involving [Person] from Outlook to Markdown.
#>
param(
[string]$OutputFolder = 'D:\Git\WorkInternals\input\emails\Person_Emails',
[datetime]$AfterDate = [datetime]::new(2026, 1, 1)
)
$ErrorActionPreference = 'Continue'
$fromFolder = Join-Path $OutputFolder 'from'
$toFolder = Join-Path $OutputFolder 'to'
@($OutputFolder, $fromFolder, $toFolder) | ForEach-Object {
if (-not (Test-Path $_)) { New-Item -Path $_ -ItemType Directory -Force | Out-Null }
}
# Dedup
$existingFiles = @{}
Get-ChildItem "$fromFolder\*.md" -ErrorAction SilentlyContinue |
ForEach-Object { $existingFiles[$_.Name] = $true }
Get-ChildItem "$toFolder\*.md" -ErrorAction SilentlyContinue |
ForEach-Object { $existingFiles[$_.Name] = $true }
# Connect
$outlook = [Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application')
$namespace = $outlook.GetNamespace('MAPI')
# Find store and folder (see patterns above)
# ...
# Use Restrict for fast filtering
$dateFilter = $AfterDate.ToString("MM/dd/yyyy HH:mm")
$filtered = $folder.Items.Restrict("[ReceivedTime] >= '$dateFilter'")
for ($i = 1; $i -le $filtered.Count; $i++) {
$item = $filtered.Item($i)
if ($item.Class -ne 43) { continue }
# Check if person is involved (see Test-PersonInvolved)
# Determine direction, build Markdown, write file
# ...
}
Existing Scripts in This Project
| Script | Purpose | Search Method |
|---|
Export-NewMuellerEmails.ps1 | Incremental Mueller emails (Inbox, Sent, Direct) | Full iteration with date filter |
Export-DeletedItemsMueller.ps1 | Mueller emails from Deleted Items | Full iteration |
Export-WeberEmailsThisWeek.ps1 | Maria Weber emails (Direct, date-restricted) | Restrict by date, then check |
Export-TeamAlertsEmails.ps1 | teamalerts@contoso.com emails | DASL Restrict (From/To/CC) |
Export-AcmeCorpEmails.ps1 | AcmeCorp keyword search | DASL Restrict |
Export-PRL0158330-Emails.ps1 | PRL0158330 subject search (Direct only) | DASL ci_phrasematch |
Export-MSVacationEmails.ps1 | MS Vacation emails from existing index | JSON index filter + optional COM |
Export-OutlookCalendar.ps1 | Calendar entries to Markdown | Calendar folder iteration |
Parse-EmailIndex.ps1 | Build CSV/JSON index from exported .md files | File system (no COM) |
Performance Tips
- Always use
Restrict or DASL filters instead of iterating all items
- Pre-filter by date when you only need recent emails — reduces 28,000 items to dozens
- Combine DASL filters for multiple criteria where possible
- Use the Scheduled Task pattern when running from elevated VS Code
- Log to a file (
*>&1 | Out-File) — COM scripts may produce output that can't be captured by the parent process
- Never iterate Sent Items just to skip — if the person's emails are in a specific folder (e.g., Direct), skip Inbox/Sent Items entirely
Known Pitfalls
Sent-Items Gap: Emails to Third Parties Missing from Person-Based Exports
When exporting emails filtered by a specific person (e.g., "Alex Mueller"),
Sent Items emails addressed to third parties are systematically missed even
if the person was originally on CC in the draft or conversation.
Example: An email sent by the mailbox owner to Maria Weber and
Stefan Koch with Alex Mueller on CC — but when the final sent
email has no CC (only To recipients), the person-based filter finds no match.
Root cause: The $item.To and $item.CC properties reflect the final
sent state. If the person was removed from CC before sending, or if the email
was a new message to different recipients in the same thread, the filter won't
match.
Detection: When an email draft exists in Results/ but no corresponding
sent email appears in the export, check Sent Items manually with a subject or
date filter:
# Search Sent Items by date range and subject keyword (bypasses person filter)
$filter = "[SentOn] >= '02/23/2026' AND [SentOn] <= '02/25/2026'"
$filtered = $sentItems.Items.Restrict($filter)
foreach ($item in $filtered) {
if ($item.Subject -match 'PRL0158331|Klarstellung') {
Write-Host "FOUND: $($item.Subject) | To: $($item.To) | CC: $($item.CC)"
}
}
Prevention: For complete coverage, consider a secondary export pass that
searches Sent Items by subject keywords or date ranges rather than
recipient names alone.
Restrict Date Format Is Locale-Dependent
The Restrict filter uses US date format (MM/dd/yyyy) regardless of system
locale when using the short property syntax ([SentOn]). For DASL syntax, use
ISO format. Always test with a known date range first.
PowerShell 7 Has No GetActiveObject
[Runtime.InteropServices.Marshal]::GetActiveObject() is .NET Framework only.
In PowerShell 7+, use New-Object -ComObject Outlook.Application or shell out
to Windows PowerShell: powershell.exe -NoProfile -Command { ... }.
Here-Strings with Pipe Characters Corrupt in Terminal
When building PowerShell scripts via the VS Code terminal using here-strings
(@"..."@ or @'...'@), markdown table syntax with | characters causes
PowerShell to interpret them as pipe operators, corrupting the script.
Solution: Use StringBuilder to construct markdown content:
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine('| Field | Value |')
[void]$sb.AppendLine('|-------|-------|')
[void]$sb.AppendLine("| **From** | $from |")
Or write the script to a file using [System.IO.File]::WriteAllText() with
a single-quoted here-string that doesn't contain embedded variables or pipes.
Scheduled Task May Not Produce Log Output
When using the Scheduled Task pattern, the task may run but produce no log
file if:
- The script has parse errors (no output captured)
$env:TEMP resolves differently in the task context
- The task runs under a different user profile
Diagnostic steps:
- Check
Get-ScheduledTaskInfo | Select LastRunResult, LastRunTime
- Use absolute paths in the wrapper script (not
$env:TEMP)
- If not elevated, skip the Scheduled Task entirely — run via
Start-Process powershell.exe -RedirectStandardOutput $logPath directly