Monday, October 26, 2026

How to Restore Deleted Emails in Exchange Online (Microsoft 365): The Complete Admin Guide

From the user's own "Recover deleted items" button to PowerShell bulk restores, and how to find out who deleted what, and when.

"My old emails are gone." Every Microsoft 365 admin hears this sooner or later. The good news is that in Exchange Online, a deleted email is rarely gone for good. Before it is permanently purged, it passes through several safety nets, and with the right tools you can bring it back. You can also prove how it disappeared.

This guide walks through the full process:

  1. Understand where deleted emails actually go
  2. Check whether the mail is really deleted (or just hidden or moved)
  3. Find out who deleted it, when, and from which app
  4. Restore it: self-service, then PowerShell for bulk and targeted restores
  5. Prevent it from happening again

1. The lifecycle of a deleted email

Understanding this flow is what turns "it's gone" into "it's recoverable".

Deleted email lifecycle in Exchange Online
Figure 1: Where a deleted email goes in Exchange Online, and the audit operation logged at each step.
StageWho can see itHow long it staysAudit operation
Deleted ItemsThe userUntil emptied, or removed by a retention tagMoveToDeletedItems
Recoverable Items\DeletionsThe user (via "Recover deleted items") and admins14 days by default, configurable up to 30 (RetainDeletedItemsFor)SoftDelete
Recoverable Items\PurgesAdmins onlyRemoved at the end of the retention period, unless the mailbox is on holdHardDelete
Key insight: If the mailbox is on Litigation Hold or covered by a Microsoft Purview retention policy, deleted items can stay recoverable for years, well beyond 14–30 days. Never remove or change a hold until the restore is finished. Removing it can start the purge clock.

2. Confirm the mail is really deleted

A lot of "missing email" reports are not deletions at all. Rule these out first. It takes five minutes.

CheckWhy it matters
Open Outlook on the web (outlook.office.com) and look for the mailIf it's visible on the web, the problem is the desktop client (cache, filters, collapsed date groups), not the server
Clear filters (Unread, Flagged), switch Focused/Other, and expand collapsed "Older" groupsVery common cause of "only recent mail is visible"
Search all folders for a known old messageInbox rules or Sweep may have moved it, not deleted it
Check the Online Archive (In-Place Archive)A "Move to Archive" retention tag silently moves older mail there

Server-side check with PowerShell

The quickest way to see where items actually are is to compare folder item counts and oldest-item dates:

Connect-ExchangeOnline -UserPrincipalName admin@contoso.com
$upn = "user@contoso.com"
# Item count and oldest item per folder (includes Recoverable Items)
Get-MailboxFolderStatistics $upn -IncludeOldestAndNewestItems |
    Where-Object { $_.ItemsInFolder -gt 0 } |
    Select-Object FolderPath, ItemsInFolder, OldestItemReceivedDate |
    Sort-Object ItemsInFolder -Descending | Format-Table -AutoSize
# Archive, retention policy and holds
Get-Mailbox $upn | Select-Object ArchiveStatus, RetentionPolicy, LitigationHoldEnabled, InPlaceHolds, RetainDeletedItemsFor
Sample folder statistics output
Figure 2: The tell-tale signs. The Inbox oldest date is very recent, Deleted Items is empty, and Recoverable Items\Deletions holds years of mail.
How to read it: If the Inbox's oldest date is very recent, Deleted Items is empty, and /Deletions holds a lot of items going back years, the mail was deleted and then Deleted Items was emptied. It is recoverable.
Run the same command again the next day. If the Inbox's oldest date moves forward, something is deleting mail on a rolling schedule (Sweep, a client rule, a retention policy). If it stays fixed, it was a one-time bulk delete.

3. Find out who deleted the mail, and when

Before you restore anything, find the cause. If a phone app or a client-side rule is deleting mail, it will delete the restored mail again.

Option A: Microsoft Purview audit search (portal)

  1. Go to purview.microsoft.com → Audit → Search.
  2. Set the date range (Audit Standard keeps 180 days by default).
  3. In Activities – operation names, enter: MoveToDeletedItems,SoftDelete,HardDelete
  4. Add the affected user and click Search.
  5. Open any record and check ClientInfoString, LogonType, Folder.Path and AffectedItems.
Generic audit search results
Figure 3: A generic audit search. Pairs of "Moved to Deleted Items" followed by "Deleted from Deleted Items" show a delete-then-empty pattern.

Option B: PowerShell summary (per day, folder and app)

The portal shows one event at a time. For a large investigation, this script summarizes the last 180 days by date, folder, client and logon type, keeping only the summary in memory:

$upn = "user@contoso.com"
$summary = @{}
foreach ($day in 0..179) {
    $start = (Get-Date).Date.AddDays(-180 + $day)
    $end   = $start.AddDays(1)
    $sid   = [guid]::NewGuid().ToString()
    do {
        $page = Search-UnifiedAuditLog -StartDate $start -EndDate $end -UserIds $upn `
            -Operations MoveToDeletedItems,SoftDelete,HardDelete `
            -SessionId $sid -SessionCommand ReturnLargeSet -ResultSize 5000
        foreach ($r in $page) {
            $d = $r.AuditData | ConvertFrom-Json
            $client = if ($d.ClientInfoString -match "MacOutlook") { "Outlook-Mac" } elseif ($d.ClientInfoString -match "OWA") { "OWA" } elseif ($d.ClientInfoString -match "iOS|Android") { "Mobile" } else { "Other" }
            $key = "{0}|{1}|{2}|{3}|{4}" -f $start.ToString("yyyy-MM-dd"), $r.Operations, $d.Folder.Path, $client, $d.LogonType
            if ($summary.ContainsKey($key)) {
                $summary[$key] += @($d.AffectedItems).Count
            } else {
                $summary[$key] = @($d.AffectedItems).Count
            }
        }
    } while ($page -and $page.Count -eq 5000)
    Write-Host "$($start.ToString('yyyy-MM-dd')) done"
}
$report = $summary.GetEnumerator() | ForEach-Object {
    $p = $_.Key -split "\|"
    [pscustomobject]@{ Date = $p[0]; Operation = $p[1]; Folder = $p[2]; Client = $p[3]; LogonType = $p[4]; Items = $_.Value }
}
# Biggest delete actions first
$report | Sort-Object Items -Descending | Select-Object -First 25 | Format-Table -AutoSize
FieldWhat it tells you
LogonType0 = mailbox owner, 1 = admin, 2 = delegate
ClientThe app that performed the delete: desktop Outlook, OWA, mobile, or an app using REST/Graph
FolderThe folder the items were deleted from. Filter on \Inbox to separate real mail from alert-folder cleanup.
Spike daysA single day with a huge Items value means a bulk delete. Steady daily counts mean a habit or an automated rule.
Watch out: folder moves by the mailbox owner (the Move operation) are not audited by default. If the audit log shows only a handful of Inbox deletions but the Inbox is empty, the mail may have been moved to another folder first and deleted from there.

4. Restore the emails

Option 1: Self-service in Outlook on the web (small volumes)

  1. Open outlook.office.com and select Deleted Items.
  2. Click Recover items deleted from this folder at the top of the list.
  3. Search or sort by date, select the messages, and click Restore.
Generic recover deleted items dialog
Figure 4: Generic "Recover deleted items" view in Outlook on the web. Restored items go back to their original folder.

This works well for hundreds or a few thousand items. Beyond that, use PowerShell.

Option 2: PowerShell bulk restore (targeted, admin)

Step 1: Grant the role. Get-RecoverableItems and Restore-RecoverableItems are only available with the Mailbox Import Export role, which is not assigned to anyone by default. In the Exchange admin center, go to Roles → Admin roles → Organization Management → Permissions, tick Mailbox Import Export, and save. Or run:

New-ManagementRoleAssignment -Name "MIE-Admin" -Role "Mailbox Import Export" -User "admin@contoso.com"
Tip: Exchange Online only loads cmdlets your roles allow. If you get "The term 'Get-RecoverableItems' is not recognized", the role hasn't taken effect yet. Wait 30–60 minutes, then open a new PowerShell window and reconnect. Confirm with Get-Command Get-RecoverableItems, Restore-RecoverableItems.

Step 2: Restore only what you need. A mailbox's Recoverable Items folder can hold a huge number of items, mostly notification noise. Restoring everything would flood the user's mailbox. Instead, filter by:

  • -LastParentFolderID: the folder the item was deleted from (for example, Inbox only)
  • -FilterStartTime / -FilterEndTime: these match the item's LastModifiedTime, which is roughly when it was deleted
  • -FilterItemType IPM.Note: email messages only

Step 3: Get the Inbox ID in the right format. This is the part most guides skip. Get-MailboxFolderStatistics returns a base64 FolderId, but -LastParentFolderID expects a 44-character hex string (the store GUID plus the folder counter). You can convert one to the other:

$upn = "user@contoso.com"
$inboxFid = (Get-MailboxFolderStatistics $upn -FolderScope Inbox | Select-Object -First 1).FolderId
$bytes    = [Convert]::FromBase64String($inboxFid)
$inboxId  = ($bytes[23..44] | ForEach-Object { $_.ToString("X2") }) -join ""
$inboxId   # 44 hex characters
Sanity check: Every folder in the same mailbox shares the first 32 hex characters (the store GUID). Only the last 12 (the folder counter) differ. Run the same conversion for another folder. If the two IDs share the prefix and have different endings, your conversion is right.

Step 4: Preview first, in small date windows. One query across a large Recoverable Items folder often fails with a server-side timeout. Query one folder and one day at a time, with retries:

$start   = (Get-Date).Date.AddDays(-60)
$end     = Get-Date
$preview = [System.Collections.Generic.List[object]]::new()
foreach ($src in "DeletedItems", "RecoverableItems") {
    $s = $start
    while ($s -lt $end) {
        $e  = $s.AddDays(1)
        $ok = $false
        foreach ($try in 1..3) {
            try {
                $r = Get-RecoverableItems -Identity $upn -SourceFolder $src -FilterItemType IPM.Note `
                    -LastParentFolderID $inboxId -FilterStartTime $s -FilterEndTime $e -ResultSize 1000 -ErrorAction Stop
                if ($r) { $r | ForEach-Object { $preview.Add($_) } }
                $ok = $true
                break
            } catch {
                Start-Sleep -Seconds 20
            }
        }
        if (-not $ok) {
            Write-Host "$src $($s.ToString('yyyy-MM-dd')) FAILED after 3 tries" -ForegroundColor Red
        }
        $s = $e
    }
}
$preview.Count
$preview | Group-Object LastParentFolderID | Select-Object Count, Name   # should be ONE row = $inboxId
$preview | Select-Object Subject, LastModifiedTime | Export-Csv .\restore-preview.csv -NoTypeInformation

Step 5: Restore, using the same windows. Items go back to their original folder:

foreach ($src in "DeletedItems", "RecoverableItems") {
    $s = $start
    while ($s -lt $end) {
        $e = $s.AddDays(1)
        try {
            Restore-RecoverableItems -Identity $upn -SourceFolder $src -FilterItemType IPM.Note `
                -LastParentFolderID $inboxId -FilterStartTime $s -FilterEndTime $e -ResultSize 1000 -NoOutput -ErrorAction Stop
            Write-Host "$src $($s.ToString('yyyy-MM-dd')) restored"
        } catch {
            Write-Host "$src $($s.ToString('yyyy-MM-dd')) FAILED - $($_.Exception.Message)" -ForegroundColor Red
        }
        $s = $e
    }
}

Step 6: Verify.

Get-MailboxFolderStatistics $upn -FolderScope Inbox -IncludeOldestAndNewestItems |
    Select-Object FolderPath, ItemsInFolder, OldestItemReceivedDate -First 1
PowerShell 7 gotcha: If every call fails with "[System.Net.Http.HttpResponseMessage] does not contain a method named 'GetResponseHeader'", that message comes from the module's own error handler. It is hiding the real server error. Update the module (Install-Module ExchangeOnlineManagement -Force), check $Error[0].Exception | Format-List * -Force for the underlying message, and use smaller date windows. If it still fails, run the same commands in Windows PowerShell 5.1.

Option 3: Purview eDiscovery (last resort)

If neither option works, create an eDiscovery search scoped to the mailbox (it includes Recoverable Items), export the results to PST, and import the PST back. It's slower and heavier, but it doesn't depend on the restore cmdlets.

5. Choosing the right method

ScenarioBest method
A few emails, deleted recentlyThe user restores them in Outlook on the web
Thousands of emails, one folder, a known date rangeRestore-RecoverableItems with -LastParentFolderID and date windows
Restore into a separate folder for reviewRestore-RecoverableItems -RestoreTargetFolder "/Recovered" (Exchange Online only)
Past the retention period, no holdNot recoverable from Exchange. Check backups if your organization has a third-party backup.
Legal or compliance export neededPurview eDiscovery search and export

6. Prevent it from happening again

ActionWhy
Set RetainDeletedItemsFor to 30 daysDoubles the default safety window: Set-Mailbox user@contoso.com -RetainDeletedItemsFor 30
Review client-side rules and SweepRules created in desktop or mobile Outlook, and Sweep, may not appear in Get-InboxRule
Disable "Empty Deleted Items on exit" for heavy deletersStops instant emptying of Deleted Items
Route alert and notification email to a dedicated folder or shared mailboxUsers won't mass-delete from the folders where their real mail lives
Enable an Online Archive with a sensible "Move to Archive" tagKeeps the primary mailbox lean, so users don't feel the need to bulk delete
Use a retention policy for critical mailboxesDeleted items stay recoverable for as long as the policy requires

Key takeaways

  • Deleted mail goes Deleted Items → Recoverable Items\Deletions → Purges. With a hold, it stays recoverable much longer.
  • Always investigate before restoring. The audit log tells you who, when, from which folder, and with which app.
  • Restore selectively. Filter by the original folder and date, and preview before you restore.
  • Convert the base64 FolderId to the hex LastParentFolderID to target one folder precisely.
  • Query in small date windows to avoid server-side timeouts on large mailboxes.
  • Don't touch holds until the restore is complete.

References

Saturday, October 24, 2026

How to Enable and Manage the Copilot Icon in SharePoint Online: A Complete Admin Guide

Copilot in SharePoint shows up as an agent icon in the SharePoint global header. From there, users can ask questions, summarise content, and create pages, lists and files using natural language. This guide covers every control that decides whether the icon appears, what it opens, and who manages it, at the tenant, site and user levels.

The Control Layers at a Glance

LayerControlWho manages itToolEffect on the icon
UserMicrosoft 365 Copilot license (M365_COPILOT_SHAREPOINT service plan)License adminM365 admin center / Graph PowerShellNo license, no icon
TenantKnowledgeAgentScope + selected sites listSharePoint adminPowerShell onlyDecides which sites get Copilot (honoured until 1 Nov 2026)
SiteRestricted Content Discovery (RCD)SharePoint admin (can be delegated)SharePoint admin center / PowerShellRCD On = Copilot and AI actions hidden on that site
SiteSite AI settingsSite ownerSite → Settings → Site AIChooses the agent behind the icon and can hide Copilot for visitors

Rule of thumb: the icon appears only when every layer allows it. A user-level "yes" can't override a site-level "no".

Prerequisites

RequirementDetail
LicenseAn active Microsoft 365 Copilot license. Everyday Copilot in SharePoint capabilities are included, and users don't need a separate opt-in.
Admin roleSharePoint Administrator for tenant and RCD settings. Site owner for Site AI settings.
ModuleSharePoint Online Management Shell 16.0.26615.12013 or later
CloudNot currently supported in GCC, GCC High, DoD, air-gapped clouds, or Microsoft 365 operated by 21Vianet
Advanced work (optional)Usage-based billing with Copilot Credits, set up through a spending policy in the M365 admin center

Step 1 – Assign and Verify the License

Assign the Microsoft 365 Copilot license in the M365 admin center, then confirm the SharePoint service plan is active:

Connect-MgGraph -Scopes "User.Read.All" -NoWelcome
$upn = "user@contoso.com"
Get-MgUserLicenseDetail -UserId $upn | ForEach-Object {
    $_.ServicePlans | Where-Object { $_.ServicePlanId -eq "0aedf20c-091d-420b-aadf-30c042609612" }
} | Select-Object ServicePlanName, ProvisioningStatus

Expected result: M365_COPILOT_SHAREPOINT → Success.

Step 2 – Enable Copilot at the Tenant Level (PowerShell)

There's no admin-center UI for this control. Connect from Windows PowerShell 5.1:

Connect-SPOService -Url https://contoso-admin.sharepoint.com
Get-SPOTenant | Select-Object KnowledgeAgentScope, KnowledgeAgentSelectedSitesList
Scope valueMeaningBest for
NoSites (default)Off on every siteTenants not ready to roll out
IncludeSelectedSitesOn only for listed sites (allow-list)Pilots and phased rollouts
ExcludeSelectedSitesOn everywhere except listed sites (block-list)Broad rollout with a few sensitive sites held back
AllSitesOn everywhereFull rollout

Option A – Enable on All Sites

Set-SPOTenant -KnowledgeAgentScope AllSites
Get-SPOTenant | Select-Object KnowledgeAgentScope

Option B – Pilot on Selected Sites (Allow-List)

Set-SPOTenant -KnowledgeAgentScope IncludeSelectedSites

# Initial list
Set-SPOTenant -KnowledgeAgentSelectedSitesList @(
    "https://contoso.sharepoint.com/sites/Engineering",
    "https://contoso.sharepoint.com/sites/HRPortal"
)

# Add more sites later - always use Append
Set-SPOTenant -KnowledgeAgentSelectedSitesList @("https://contoso.sharepoint.com/sites/Marketing") `
              -KnowledgeAgentSelectedSitesListOperation Append

Option C – Enable Everywhere Except Sensitive Sites (Block-List)

Set-SPOTenant -KnowledgeAgentScope ExcludeSelectedSites
Set-SPOTenant -KnowledgeAgentSelectedSitesList @("https://contoso.sharepoint.com/sites/Finance")

Turn It Off for the Whole Tenant

Set-SPOTenant -KnowledgeAgentScope NoSites

Managing the Sites List Safely

OperationEffect
Overwrite (default)Replaces the entire list
AppendAdds sites to the existing list
RemoveRemoves sites from the existing list
Gotchas:
  • Because the default operation is Overwrite, running Set-SPOTenant -KnowledgeAgentSelectedSitesList "url" without Append wipes every other site from the list.
  • The list can't hold more than 100 site URLs.
  • The list parameter only works when the scope is IncludeSelectedSites or ExcludeSelectedSites.
  • In multi-geo tenants, run the commands in each geo.
# Always back up before changing the list
$list = (Get-SPOTenant).KnowledgeAgentSelectedSitesList
$list | Out-File .\KnowledgeAgentSites_backup.txt
$list.Count
$list -contains "https://contoso.sharepoint.com/sites/Engineering"

Step 3 – Control a Single Site with Restricted Content Discovery

RCD is a site-level switch. When it's On, Copilot in SharePoint and AI actions don't appear on the site, whatever the other settings are. Site permissions stay the same.

UI: SharePoint admin center → Sites → Active sites → select the site → Settings tab → Restrict content discovery → On/Off → Save.

# Check
Get-SPOSite -Identity https://contoso.sharepoint.com/sites/Finance | Select-Object RestrictContentOrgWideSearch

# Hide Copilot on the site
Set-SPOSite -Identity https://contoso.sharepoint.com/sites/Finance -RestrictContentOrgWideSearch $true

# Show Copilot again
Set-SPOSite -Identity https://contoso.sharepoint.com/sites/Finance -RestrictContentOrgWideSearch $false

Tips:

  • For many sites at once, use Reports → Catalog management → Actions to apply RCD to catalog groups in bulk.
  • To let site admins manage RCD themselves, run Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $true. They'll need to give a justification for each change.
  • Changes take time to propagate, especially on large sites.

Step 4 – Manage the Icon as a Site Owner (Site AI Settings)

Go to the site → Settings (gear) → Site AI.

SettingWhat it doesWhen to use it
Main agentChooses what opens from the header icon: Copilot in SharePoint or a custom site agentPoint visitors to a curated agent, such as a training or policy assistant
Hide Copilot for visitorsHides the Copilot button on site pages for visitors only. Owners and members still see it.Read-only portals and intranet landing pages
Advanced document processing / autofillShares Copilot Credits with the site for advanced autofill columns and faster processingLibraries that need metadata extraction at scale

Tip: if you choose a custom agent, keep its .agent file in a stable library and make sure visitors can read it. If the file is moved or deleted, the header icon has nothing to open.

Step 5 – Verify with a Read-Only Script

param(
    [Parameter(Mandatory)] [string]$AdminUrl,
    [Parameter(Mandatory)] [string]$SiteUrl
)

$SiteUrl = $SiteUrl.TrimEnd('/')
Connect-SPOService -Url $AdminUrl
$tenant = Get-SPOTenant
$site   = Get-SPOSite -Identity $SiteUrl
$inList = ($tenant.KnowledgeAgentSelectedSitesList | ForEach-Object { $_.TrimEnd('/') }) -contains $SiteUrl

switch ($tenant.KnowledgeAgentScope) {
    "AllSites"             { $scopeOk = $true }
    "IncludeSelectedSites" { $scopeOk = $inList }
    "ExcludeSelectedSites" { $scopeOk = -not $inList }
    default                { $scopeOk = $false }
}

if ($scopeOk -and -not $site.RestrictContentOrgWideSearch) {
    $status = "Copilot icon enabled for licensed users"
} else {
    $status = "Copilot icon hidden - review tenant scope or RCD"
}

[pscustomobject]@{
    TenantScope              = $tenant.KnowledgeAgentScope
    SiteInSelectedList       = $inList
    RestrictContentDiscovery = $site.RestrictContentOrgWideSearch
    Status                   = $status
} | Format-List

PowerShell Connection Tips

SymptomFix
NativeInterop.API threw an exception / No valid OAuth 2.0 authentication session existsRun the module in Windows PowerShell 5.1, opened from the Start menu, not from inside PowerShell 7
Module installed but "no valid module file was found"Use Save-Module -Path C:\PSMods, then import the .psd1 by its full path
A parameter cannot be found that matches 'KnowledgeAgentScope'Remove old module versions and install the latest

Important: What Changes After 1 November 2026

With Copilot in SharePoint now generally available, Microsoft is retiring the preview-era PowerShell controls (KnowledgeAgentScope, KnowledgeAgentSelectedSitesList, KnowledgeAgentSelectedSitesListOperation).

PeriodHow availability is managed
Until 1 Nov 2026Tenant PowerShell scope + RCD + Site AI settings
After 1 Nov 2026Site AI settings + Restricted Content Discovery only

Migration checklist:

  1. Export your current scope and sites list.
  2. If you use an allow-list, find the sites outside it that must stay hidden, and apply RCD to them before the cut-off.
  3. If you use a block-list, apply RCD to every site on it.
  4. Brief site owners on the Site AI panel, since they now control the header agent.
  5. Review Copilot Credits spending policies if you plan to use advanced work.

Recommended Management Model

GoalUse
Roll Copilot out to everyoneLicense + leave RCD Off
Hide Copilot on sensitive sitesRCD On (bulk through Catalog management)
Keep a portal clean for visitorsSite AI → hide Copilot for visitors
Show a curated assistantSite AI → Main agent = custom agent
Allow large-scale AI workSpending policy with Copilot Credits

References

Disclaimer: This article is for educational purposes. All tenant names, URLs and accounts are illustrative (contoso). Test changes in a non-production tenant and follow your organisation's change process.

Wednesday, October 21, 2026

Provisioning an SMTP AUTH Service Account for On-Prem Apps (Hybrid Exchange + PowerShell 7)

Provisioning an SMTP AUTH Service Account for On-Prem Apps (Hybrid Exchange + PowerShell 7)

Complete Script (PowerShell 7 compatible)

<#
Purpose : Create a mail-enabled service account for on-prem SMTP AUTH (hybrid Exchange)
Runs on : PowerShell 7.x
Note    : Replace all contoso.com placeholders before use
#>

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

#region Connect to on-prem Exchange
$ExchangeOnPremCred   = Get-Credential -Message "Exchange admin account"
$ExchangeOnPrmSession = New-PSSession -ConfigurationName Microsoft.Exchange `
                                      -ConnectionUri http://exch01.corp.contoso.com/powershell `
                                      -Credential $ExchangeOnPremCred `
                                      -Authentication Kerberos
Import-PSSession $ExchangeOnPrmSession -DisableNameChecking | Out-Null
#endregion

#region Environment settings (placeholders)
$DomainController = "dc01.corp.contoso.com"
$OU               = "OU=SMTPAuth,OU=ServiceAccounts,DC=corp,DC=contoso,DC=com"
$UpnSuffix        = "contoso.com"
$SmtpDomain       = "mail.contoso.com"
$TenantRouting    = "contoso.onmicrosoft.com"
$SmtpServer       = "smtp.contoso.com"
$RelayAccount     = "CORP\svc-smtprelay"
$RelayFrom        = "SMTP Relay Service <svc-smtprelay@mail.contoso.com>"
$Prefix           = "SVC-"
#endregion

#region Inputs
$DeviceApplicationName = Read-Host "Enter Device or Application Name"
$AppOwner              = Read-Host "Email Address of App Owner"
$MailboxEnabled        = (Read-Host "Do you need to receive emails to the sender (Y/N)").ToUpper()
$SendEmail             = (Read-Host "Send credentials email to app owner (Y/N)").ToUpper()

$Description = "SMTP account for $DeviceApplicationName - owner $AppOwner"
#endregion

#region Build names (sAMAccountName max 20 chars)
$NameConstruct = $DeviceApplicationName.Trim().Replace(" ", ".")
if ($NameConstruct.Length -gt 16) {
    $NameConstruct = $NameConstruct.Substring(0, 16)
}

$samaccountName     = "$Prefix$NameConstruct"
$UPN                = "$samaccountName@$UpnSuffix"
$PrimarySMTPAddress = "$samaccountName@$SmtpDomain"
$RRA                = "$samaccountName@$TenantRouting"

if (Get-Recipient $samaccountName -ErrorAction SilentlyContinue) {
    throw "Recipient $samaccountName already exists. Choose a different name."
}
#endregion

#region Generate complex password (PS 7 compatible)
Function GenerateStrongPassword ([Parameter(Mandatory = $true)][int]$PasswordLength) {
    $upper   = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
    $lower   = 'abcdefghijkmnopqrstuvwxyz'
    $digits  = '23456789'
    $special = '!@#$%^*()-_=+[]{}?'
    $all     = $upper + $lower + $digits + $special
    do {
        $chars = for ($i = 0; $i -lt $PasswordLength; $i++) {
            $all[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($all.Length)]
        }
        $newPassword = -join $chars
    } while (-not (($newPassword -cmatch '[A-Z]') -and
                   ($newPassword -cmatch '[a-z]') -and
                   ($newPassword -match '\d') -and
                   ($newPassword -match '[^\w]')))
    return $newPassword
}

$Password       = GenerateStrongPassword 15
$securePassword = ConvertTo-SecureString $Password -AsPlainText -Force
#endregion

#region Create account
if ($MailboxEnabled -eq "Y") {
    # Remote shared mailbox in Exchange Online
    New-RemoteMailbox -Name $samaccountName -UserPrincipalName $UPN -DisplayName $samaccountName `
                      -FirstName "SVC" -LastName $DeviceApplicationName `
                      -OnPremisesOrganizationalUnit $OU -PrimarySmtpAddress $PrimarySMTPAddress `
                      -RemoteRoutingAddress $RRA -DomainController $DomainController `
                      -Password $securePassword -ResetPasswordOnNextLogon:$false -Shared
} elseif ($MailboxEnabled -eq "N") {
    # Mail-enabled user, send-only
    New-MailUser -Name $samaccountName -UserPrincipalName $UPN -DisplayName $samaccountName `
                 -FirstName "SVC" -LastName $DeviceApplicationName `
                 -OrganizationalUnit $OU -PrimarySmtpAddress $PrimarySMTPAddress `
                 -ExternalEmailAddress $PrimarySMTPAddress -DomainController $DomainController `
                 -Password $securePassword -ResetPasswordOnNextLogon:$false
} else {
    throw "Invalid input '$MailboxEnabled'. Enter Y or N."
}

Start-Sleep -Seconds 10
Set-ADUser -Server $DomainController -Identity $samaccountName -Description $Description `
           -PasswordNeverExpires:$true -Enabled:$true
#endregion

#region Share credentials
$BodyAsHtml = @"
Hello,<br/><br/>
Please find below the details of the SMTP account for <b>$DeviceApplicationName</b>.<br/><br/>
<b>Username</b>: $UPN<br/>
<b>Password</b>: $Password<br/>
<b>SMTP Server</b>: $SmtpServer<br/>
<b>Port</b>: 25<br/>
<b>From Address</b>: $PrimarySMTPAddress<br/>
<br/>Thank you,<br/>Messaging Team
"@

if ($SendEmail -eq "Y") {
    $RelayCred = Get-Credential -Message "SMTP Relay Service Password" -UserName $RelayAccount
    Send-MailMessage -From $RelayFrom -To $AppOwner -Subject "SMTP Account Details for $samaccountName" `
                     -BodyAsHtml $BodyAsHtml -SmtpServer $SmtpServer -Credential $RelayCred `
                     -WarningAction SilentlyContinue
    Write-Host "Credentials email sent to $AppOwner" -ForegroundColor Green
} else {
    Write-Host "Email skipped. Share the password with $AppOwner through a secure channel." -ForegroundColor Yellow
}
#endregion

#region Verify and clean up
Get-Recipient $samaccountName | Format-List Name, RecipientTypeDetails, PrimarySmtpAddress
Get-ADUser $samaccountName -Server $DomainController -Properties Description, PasswordNeverExpires |
    Format-List Name, Enabled, Description, PasswordNeverExpires

Remove-PSSession $ExchangeOnPrmSession
#endregion

<#
Next steps
- N path : App owner configures SMTP settings and sends a test email
- Y path : Wait for directory sync, confirm SharedMailbox in Exchange Online,
           grant app owner FullAccess, then send a test email
#>

Test Script

# Send a test email using the new SMTP AUTH service account
$SmtpServer = "smtp.contoso.com"
$Port       = 25
$From       = "SVC-Sample.App@mail.contoso.com"
$To         = "admin@contoso.com"
$UserName   = "SVC-Sample.App@contoso.com"

# 1. Check the SMTP server is reachable
$Test = Test-NetConnection -ComputerName $SmtpServer -Port $Port -WarningAction SilentlyContinue
if (-not $Test.TcpTestSucceeded) {
    Write-Host "Cannot reach $SmtpServer on port $Port. Run this from a network that can reach it." -ForegroundColor Red
    return
}

# 2. Enter the service account password
$Cred = Get-Credential -UserName $UserName -Message "Enter password for $UserName"

$MailParams = @{
    From       = $From
    To         = $To
    Subject    = "SMTP Test - $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
    Body       = "Test email sent from $env:COMPUTERNAME using SMTP AUTH account $UserName."
    SmtpServer = $SmtpServer
    Port       = $Port
    Credential = $Cred
}

# 3. Try STARTTLS first, then fall back to plain SMTP AUTH
try {
    Send-MailMessage @MailParams -UseSsl -WarningAction SilentlyContinue -ErrorAction Stop
    Write-Host "Test email sent (with TLS) to $To" -ForegroundColor Green
} catch {
    Write-Host "TLS attempt failed: $($_.Exception.Message)" -ForegroundColor Yellow
    try {
        Send-MailMessage @MailParams -WarningAction SilentlyContinue -ErrorAction Stop
        Write-Host "Test email sent (without TLS) to $To" -ForegroundColor Green
    } catch {
        Write-Host "Send failed: $($_.Exception.Message)" -ForegroundColor Red
    }
}

Sample Output

TLS attempt failed: The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch, RemoteCertificateChainErrors
Test email sent (without TLS) to admin@contoso.com

Reading the Result

OutputMeaningAction
Test email sent (with TLS)Auth + STARTTLS workingConfigure app with TLS enabled
TLS attempt failed + sent (without TLS)Auth works; relay certificate not trusted / name mismatchApp works on port 25 without TLS; raise certificate fix with relay owner
Send failedAuth or network issueUse the troubleshooting table below

TLS Certificate Errors

ErrorCauseFix
RemoteCertificateNameMismatchCertificate subject/SAN doesn't include the hostname used (e.g. smtp is a DNS alias)Add the alias to the certificate SAN, or connect using the name on the certificate
RemoteCertificateChainErrorsIssuing CA not trusted by the client, or chain incomplete/expiredInstall the internal root/intermediate CA on the client, or renew the certificate

Test Troubleshooting

ErrorLikely cause
Cannot reach port 25Network / firewall blocks outbound 25 from the client
5.7.57 / Authentication unsuccessfulWrong password, account disabled, or not yet replicated
5.7.60 / SendAsDeniedFrom address doesn't match the account's primary SMTP address
5.7.1 Client was not authenticatedRelay requires AUTH but credentials weren't offered (e.g. AUTH only advertised after STARTTLS)
Email not receivedCheck Junk folder and message trace on the relay / Exchange Online

TLS Certificate on the Relay (STARTTLS)

Symptom Matrix

ClientErrorMeaning
PowerShell Send-MailMessage -UseSslRemoteCertificateNameMismatch, RemoteCertificateChainErrorsCert not issued for the relay hostname and/or issuer not trusted
Python smtplib.starttls()CERTIFICATE_VERIFY_FAILED: self-signed certificateRelay presents a self-signed cert (often the default Exchange cert)
AnyFails before AUTHTLS negotiation stops; no password sent

Step 1 — Inspect the Presented Certificate (Linux / WSL)

openssl s_client -connect smtp.contoso.com:25 -starttls smtp -servername smtp.contoso.com -showcerts </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -ext subjectAltName -dates -fingerprint -sha256
ResultDiagnosis
subject = issuerSelf-signed
SAN lacks smtp.contoso.comHostname mismatch
notAfter in the pastExpired

Step 2 — Inspect Exchange Certificates and Connectors

$Server = "exch01"

Get-ExchangeCertificate -Server $Server |
    Format-List Thumbprint, Subject, Issuer, CertificateDomains, Services, IsSelfSigned, NotAfter

Get-ReceiveConnector -Server $Server |
    Format-List Identity, Bindings, Fqdn, TlsCertificateName, AuthMechanism, PermissionGroups

Step 3 — Bind a CA-Issued Certificate to SMTP

$Server     = "exch01"
$Thumbprint = "<CA-issued cert thumbprint with SAN smtp.contoso.com>"
$Connector  = "$Server\Client Frontend $Server"

Enable-ExchangeCertificate -Server $Server -Thumbprint $Thumbprint -Services SMTP

$Cert = Get-ExchangeCertificate -Server $Server -Thumbprint $Thumbprint
Set-ReceiveConnector $Connector -TlsCertificateName "<I>$($Cert.Issuer)<S>$($Cert.Subject)" -Fqdn "smtp.contoso.com"

Get-ReceiveConnector $Connector | Format-List Identity, Fqdn, TlsCertificateName
RuleDetail
Every server behind the relay nameMust present the same valid cert, otherwise failures are intermittent
Connector FqdnShould match a name in the certificate SAN
Client trustDistribute root + intermediate CA (PEM) with SHA-256 fingerprints
AvoidDisabling certificate verification in clients

Python Client Test (Linux / Kubernetes)

#!/usr/bin/env python3
"""Send a test email via SMTP AUTH + STARTTLS with full certificate verification."""
import os
import ssl
import smtplib
from email.message import EmailMessage

SMTP_HOST = os.getenv("SMTP_HOST", "smtp.contoso.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
SMTP_USER = os.getenv("SMTP_USER", "SVC-Sample.App@contoso.com")
SMTP_PASS = os.environ["SMTP_PASS"]                      # from secret, never hard-coded
MAIL_FROM = os.getenv("MAIL_FROM", "SVC-Sample.App@mail.contoso.com")
MAIL_TO   = os.getenv("MAIL_TO", "admin@contoso.com")
CA_FILE   = os.getenv("SMTP_CA_FILE")                    # optional corporate CA bundle (PEM)

context = ssl.create_default_context(cafile=CA_FILE) if CA_FILE else ssl.create_default_context()

msg = EmailMessage()
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
msg["Subject"] = "SMTP test from Python"
msg.set_content("Test email using SMTP AUTH over STARTTLS.")

with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp:
    smtp.set_debuglevel(1)
    smtp.ehlo()
    smtp.starttls(context=context)
    smtp.ehlo()
    smtp.login(SMTP_USER, SMTP_PASS)
    smtp.send_message(msg)
    print("Test email sent")
Kubernetes itemRecommendation
PasswordSecret → env var SMTP_PASS
CA bundleConfigMap / Secret mounted as file → SMTP_CA_FILE
EgressNetworkPolicy / firewall allows pod → relay on 25
Source IPRelay may restrict by IP; confirm node / NAT egress IP is allowed

Security Notes

  • Store service account passwords in a vault
  • Prefer a secure channel over plain-text email for sharing credentials
  • PasswordNeverExpires → schedule periodic manual rotation
  • Restrict relay usage to the app server IP where possible
  • Limit the service account to send as its own address only
  • Review stale SVC- accounts periodically

Scenario Coverage

#ScenarioScript
ASend-only app (no mailbox)Scenario A — Mail User
BApp sends and receives replies (shared mailbox)Scenario B — Remote Shared Mailbox
CGrant users access to the shared mailboxScenario B — Exchange Online part
DRotate service account passwordScenario C
EDecommission the service accountScenario D
FInteractive all-in-one (A or B)Complete Script

Common Header (used by Scenarios A–D)

$ExchangeOnPremCred   = Get-Credential -Message "Exchange admin account"
$ExchangeOnPrmSession = New-PSSession -ConfigurationName Microsoft.Exchange `
                                      -ConnectionUri http://exch01.corp.contoso.com/powershell `
                                      -Credential $ExchangeOnPremCred -Authentication Kerberos
Import-PSSession $ExchangeOnPrmSession -DisableNameChecking | Out-Null

$DomainController = "dc01.corp.contoso.com"
$OU               = "OU=SMTPAuth,OU=ServiceAccounts,DC=corp,DC=contoso,DC=com"
$SamAccountName   = "SVC-Sample.App"
$AppName          = "Sample App"
$UPN              = "$SamAccountName@contoso.com"
$PrimarySmtp      = "$SamAccountName@mail.contoso.com"
$RemoteRouting    = "$SamAccountName@contoso.onmicrosoft.com"

Function New-StrongPassword ([int]$Length = 15) {
    $all = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^*()-_=+[]{}?'
    do {
        $p = -join (1..$Length | ForEach-Object { $all[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($all.Length)] })
    } while (-not (($p -cmatch '[A-Z]') -and ($p -cmatch '[a-z]') -and ($p -match '\d') -and ($p -match '[^\w]')))
    return $p
}

Scenario A — Without Mailbox (Mail User, send-only)

ItemValue
ObjectMail-enabled user
Inbound mailNot delivered (no mailbox)
LicenceNone
$Password       = New-StrongPassword 15
$SecurePassword = ConvertTo-SecureString $Password -AsPlainText -Force

New-MailUser -Name $SamAccountName -UserPrincipalName $UPN -DisplayName $SamAccountName `
             -FirstName "SVC" -LastName $AppName -OrganizationalUnit $OU `
             -PrimarySmtpAddress $PrimarySmtp -ExternalEmailAddress $PrimarySmtp `
             -DomainController $DomainController -Password $SecurePassword `
             -ResetPasswordOnNextLogon:$false

Start-Sleep -Seconds 10
Set-ADUser -Server $DomainController -Identity $SamAccountName `
           -Description "SMTP account for $AppName (send-only)" `
           -PasswordNeverExpires:$true -Enabled:$true

Get-MailUser $SamAccountName | Format-List Name, RecipientTypeDetails, PrimarySmtpAddress, ExternalEmailAddress

Scenario B — With Mailbox (Remote Shared Mailbox, send + receive)

ItemValue
ObjectRemote shared mailbox (mailbox lives in Exchange Online)
Inbound mailDelivered to shared mailbox
LicenceNot required within shared mailbox limits
PrerequisiteExchange Server version that supports New-RemoteMailbox -Shared

Part 1 — On-prem Exchange

$Password       = New-StrongPassword 15
$SecurePassword = ConvertTo-SecureString $Password -AsPlainText -Force

New-RemoteMailbox -Name $SamAccountName -UserPrincipalName $UPN -DisplayName $SamAccountName `
                  -FirstName "SVC" -LastName $AppName -OnPremisesOrganizationalUnit $OU `
                  -PrimarySmtpAddress $PrimarySmtp -RemoteRoutingAddress $RemoteRouting `
                  -DomainController $DomainController -Password $SecurePassword `
                  -ResetPasswordOnNextLogon:$false -Shared

Start-Sleep -Seconds 10
Set-ADUser -Server $DomainController -Identity $SamAccountName `
           -Description "SMTP account for $AppName (shared mailbox)" `
           -PasswordNeverExpires:$true -Enabled:$true

Get-RemoteMailbox $SamAccountName | Format-List Name, RecipientTypeDetails, PrimarySmtpAddress, RemoteRoutingAddress

Part 2 — Exchange Online (after directory sync)

Connect-ExchangeOnline

$Mailbox  = "SVC-Sample.App@mail.contoso.com"
$Owners   = @("appowner@contoso.com")

Get-Mailbox $Mailbox | Format-List DisplayName, RecipientTypeDetails, PrimarySmtpAddress

foreach ($Owner in $Owners) {
    Add-MailboxPermission   -Identity $Mailbox -User $Owner -AccessRights FullAccess -InheritanceType All -AutoMapping $true
    Add-RecipientPermission -Identity $Mailbox -Trustee $Owner -AccessRights SendAs -Confirm:$false
}

Get-MailboxPermission   $Mailbox | Where-Object { $_.User -notlike "NT AUTHORITY*" }
Get-RecipientPermission $Mailbox | Where-Object { $_.Trustee -notlike "NT AUTHORITY*" }
CheckExpected
RecipientTypeDetails (EXO)SharedMailbox
If shown as UserMailboxSet-Mailbox $Mailbox -Type Shared (EXO) and Set-RemoteMailbox $SamAccountName -Type Shared (on-prem)
Reply testSend an email to the address and confirm it lands in the shared mailbox

Scenario C — Rotate Password

$Password       = New-StrongPassword 15
$SecurePassword = ConvertTo-SecureString $Password -AsPlainText -Force

Set-ADAccountPassword -Server $DomainController -Identity $SamAccountName -Reset -NewPassword $SecurePassword
Set-ADUser -Server $DomainController -Identity $SamAccountName -Description "SMTP account for $AppName - rotated $(Get-Date -Format 'yyyy-MM-dd')"

Write-Host "Password rotated. Share it securely and update the app secret." -ForegroundColor Green

Scenario D — Decommission

$Recipient = Get-Recipient $SamAccountName -ErrorAction SilentlyContinue

if ($Recipient.RecipientTypeDetails -match "RemoteSharedMailbox|RemoteUserMailbox") {
    Remove-RemoteMailbox -Identity $SamAccountName -DomainController $DomainController -Confirm:$true
} elseif ($Recipient.RecipientTypeDetails -eq "MailUser") {
    Remove-MailUser -Identity $SamAccountName -DomainController $DomainController -Confirm:$true
} else {
    Write-Host "No recipient found for $SamAccountName" -ForegroundColor Yellow
}
NoteDetail
Remove-MailUser / Remove-RemoteMailboxAlso deletes the AD account
Safer alternativeDisable first: Disable-ADAccount, wait, then remove
Shared mailbox dataExport or retain before removal if needed


Featured Post

How to Restore Deleted Emails in Exchange Online (Microsoft 365): The Complete Admin Guide

From the user's own "Recover deleted items" button to PowerShell bulk restores, and how to find out who deleted what, and whe...

Popular posts