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


Friday, October 16, 2026

Power Platform ALM with GitHub Actions: Export, Source-Control and Deploy a Cloud Flow Solution (Step by Step)

Moving Power Automate flows between environments by hand (export a zip, import a zip, fix connections, repeat) does not scale and leaves no history. In this walkthrough I build a small but complete CI/CD pipeline with GitHub Actions and the official Microsoft Power Platform Actions that:

  1. Exports a solution from a Dev environment (unmanaged + managed)
  2. Unpacks it into readable source files and commits them to GitHub
  3. Packs it as managed and imports it into a Target environment
  4. Sets environment variable values per environment using a deployment settings file

Along the way I hit three real errors. I've documented each one with the cause and the fix, because that is where most of the time goes.


What you'll build

Component Purpose
Solution CICDSampleSolution Container for everything that moves between environments
Cloud flow CICD Sample - Send Notification Manual trigger → Compose → Office 365 Outlook Send an email (V2)
Environment variable cicddemo_NotificationRecipient The email To address, different per environment
Connection reference (Office 365 Outlook) Lets the flow's connection be swapped per environment
Entra ID app registration Service principal the pipeline signs in as
GitHub workflow 1 - Export Dev → Git (unpacked source)
GitHub workflow 2 - Deploy Git → Target (managed import)
 Dev environment ──(1 Export: export + unpack + commit)──► GitHub repo (main)
                                                              │
                                                              ▼
 Target environment ◄──(2 Deploy: pack managed + import + settings file)──┘

Prerequisites

Requirement Notes
Two Power Platform environments with Dataverse Source (Dev) and Target. A Developer environment works fine as a target.
Permission to create an Entra ID app registration To create the service principal
System Administrator security role in both environments You need it yourself to grant it to the app user (see Error 2)
A GitHub account Private repository is fine

Step 1: Create a publisher and solution

In make.powerapps.com → Solutions → New solution, create a publisher with your own prefix rather than using the default publisher. The prefix becomes part of every component name (for example cicddemo_NotificationRecipient), so pick it once and keep it.

New publisher

Then create the solution itself:

Field Value
Display name CICD Sample Solution
Name CICDSampleSolution (this is what the pipeline uses)
Publisher the publisher you just created

New solution

Step 2: Add an environment variable

Inside the solution: New → More → Environment variable.

Field Value
Display name Notification Recipient
Name cicddemo_NotificationRecipient
Data type Text
Default value an email address for the Dev environment

Environment variable

Step 3: Build a simple cloud flow inside the solution

New → Automation → Cloud flow → Instant, trigger Manually trigger a flow.

Instant flow

Add three steps:

  1. Compose with a static message.
  2. Send an email (V2) (Office 365 Outlook).
  3. Set To to the Notification Recipient environment variable (Dynamic content → Environment Variables), Body to the Compose Outputs.

Flow design

Tip: if the solution does not show a connection reference after you save the flow, select the flow → Advanced → Add required objects. That pulls the connection reference into the solution. Without it, the target environment has nothing to bind a connection to.

Solution components

Step 4: Create the service principal

  1. Entra admin center → App registrations → New registration. Single tenant, no redirect URI.
  2. Certificates & secrets → New client secret. Copy the value immediately; it's shown only once.
  3. In Power Platform admin center, for each environment: Settings → Users + permissions → Application users → New app user → pick the app → root business unit → System Administrator.

App registration

Step 5: Create the GitHub repository, secrets and variables

Create a private repository and add:

Type Name Value
Secret PP_CLIENT_ID Application (client) ID
Secret PP_TENANT_ID Directory (tenant) ID
Secret PP_CLIENT_SECRET Client secret value
Variable SOURCE_ENVIRONMENT_URL https://<dev-org>.crm.dynamics.com
Variable TARGET_ENVIRONMENT_URL https://<target-org>.crm.dynamics.com

Environment URLs aren't secret, so repository variables keep them readable. Only the three identity values go into secrets.

Repository variables

Step 6: Workflow 1 — Export from Dev and commit to Git

.github/workflows/export-solution.yml

name: 1 - Export solution from Source (Dev)

# Exports the unmanaged + managed solution from the source environment,
# unpacks it into source-controlled files and commits them to main.

on:
  workflow_dispatch:
    inputs:
      solution_name:
        description: "Solution unique name"
        required: true
        default: "CICDSampleSolution"

permissions:
  contents: write

env:
  SOURCE_ENVIRONMENT_URL: ${{ vars.SOURCE_ENVIRONMENT_URL }}
  SOLUTION_NAME: ${{ github.event.inputs.solution_name }}
  SOLUTION_EXPORT_DIR: out/exported
  SOLUTION_SOURCE_FOLDER: solutions

jobs:
  export-from-source:
    runs-on: windows-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Power Platform Tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      - name: Who am I (verify service principal connection)
        uses: microsoft/powerplatform-actions/who-am-i@v1
        with:
          environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_CLIENT_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}

      - name: Publish all customizations
        uses: microsoft/powerplatform-actions/publish-solution@v1
        with:
          environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_CLIENT_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}

      - name: Export unmanaged solution
        uses: microsoft/powerplatform-actions/export-solution@v1
        with:
          environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_CLIENT_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-name: ${{ env.SOLUTION_NAME }}
          solution-output-file: ${{ env.SOLUTION_EXPORT_DIR }}/${{ env.SOLUTION_NAME }}.zip
          managed: false

      - name: Export managed solution
        uses: microsoft/powerplatform-actions/export-solution@v1
        with:
          environment-url: ${{ env.SOURCE_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_CLIENT_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-name: ${{ env.SOLUTION_NAME }}
          solution-output-file: ${{ env.SOLUTION_EXPORT_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
          managed: true

      - name: Unpack solution (Both)
        uses: microsoft/powerplatform-actions/unpack-solution@v1
        with:
          solution-file: ${{ env.SOLUTION_EXPORT_DIR }}/${{ env.SOLUTION_NAME }}.zip
          solution-folder: ${{ env.SOLUTION_SOURCE_FOLDER }}/${{ env.SOLUTION_NAME }}
          solution-type: Both
          overwrite-files: true

      - name: Commit unpacked solution to main
        shell: pwsh
        run: |
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add "${{ env.SOLUTION_SOURCE_FOLDER }}/${{ env.SOLUTION_NAME }}"
          if (git diff --cached --quiet) {
            Write-Host "No solution changes to commit."
          } else {
            git commit -m "Export $env:SOLUTION_NAME from source environment (run $env:GITHUB_RUN_NUMBER)"
            git push origin HEAD:main
          }
Step Action Why
Install Power Platform Tools actions-install@v1 Installs the Power Platform CLI on the runner. Must be first.
Who am I who-am-i@v1 Fails fast if the service principal can't reach the environment
Publish publish-solution@v1 Ensures unpublished edits are included
Export ×2 export-solution@v1 Unmanaged (for source) + managed (so unpack can capture both)
Unpack unpack-solution@v1 with solution-type: Both Turns the zip into reviewable XML/JSON files
Commit git in PowerShell Pushes the unpacked source to main

Run export

Step 7: Deployment settings file

The deployment settings file is how you give the target environment its own environment variable values (and optionally connections) without anyone clicking through import dialogs.

deployment-settings/CICDSampleSolution.COE.json

{
  "EnvironmentVariables": [
    {
      "SchemaName": "cicddemo_NotificationRecipient",
      "Value": "target-team@contoso.com"
    }
  ],
  "ConnectionReferences": [
    {
      "LogicalName": "new_sharedoffice365_xxxxx",
      "ConnectionId": "",
      "ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_office365"
    }
  ]
}

You can generate a starter version with pac solution create-settings --solution-zip <zip> --settings-file <file> and fill in the values.

Step 8: Workflow 2 — Deploy to Target as managed

.github/workflows/deploy-solution.yml

name: 2 - Deploy solution to Target (COE)

# Packs the source-controlled solution as MANAGED and imports it into the
# target environment, applying environment variable values and connection
# references from a deployment settings file.

on:
  workflow_dispatch:
    inputs:
      solution_name:
        description: "Solution unique name"
        required: true
        default: "CICDSampleSolution"
  workflow_run:
    workflows: ["1 - Export solution from Source (Dev)"]
    types: [completed]

env:
  TARGET_ENVIRONMENT_URL: ${{ vars.TARGET_ENVIRONMENT_URL }}
  SOLUTION_NAME: ${{ github.event.inputs.solution_name || 'CICDSampleSolution' }}
  SOLUTION_SOURCE_FOLDER: solutions
  BUILD_DIR: out/build

jobs:
  deploy-to-target:
    # Run on manual trigger, or after a successful export run
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    runs-on: windows-latest
    environment: COE
    steps:
      - name: Checkout repository (latest main)
        uses: actions/checkout@v4
        with:
          ref: main

      - name: Install Power Platform Tools
        uses: microsoft/powerplatform-actions/actions-install@v1

      - name: Pack managed solution
        uses: microsoft/powerplatform-actions/pack-solution@v1
        with:
          solution-folder: ${{ env.SOLUTION_SOURCE_FOLDER }}/${{ env.SOLUTION_NAME }}
          solution-file: ${{ env.BUILD_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
          solution-type: Managed

      - name: Who am I (verify target connection)
        uses: microsoft/powerplatform-actions/who-am-i@v1
        with:
          environment-url: ${{ env.TARGET_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_CLIENT_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}

      - name: Import managed solution into target
        uses: microsoft/powerplatform-actions/import-solution@v1
        with:
          environment-url: ${{ env.TARGET_ENVIRONMENT_URL }}
          app-id: ${{ secrets.PP_CLIENT_ID }}
          client-secret: ${{ secrets.PP_CLIENT_SECRET }}
          tenant-id: ${{ secrets.PP_TENANT_ID }}
          solution-file: ${{ env.BUILD_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip
          use-deployment-settings-file: true
          deployment-settings-file: deployment-settings/${{ env.SOLUTION_NAME }}.COE.json
          force-overwrite: true
          publish-changes: true
          run-asynchronously: true
          max-async-wait-time: 60

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ env.SOLUTION_NAME }}-managed
          path: ${{ env.BUILD_DIR }}/${{ env.SOLUTION_NAME }}_managed.zip

Key points:

Setting Why
pack-solution with solution-type: Managed Builds the managed zip from the files in Git, so Git is the source of truth
use-deployment-settings-file: true Applies target-specific environment variable values on import
workflow_run trigger Deploy starts automatically after a successful export (and is skipped after a failed one)
environment: COE GitHub environment, so you can add a required-reviewer approval gate later

Why not trigger Deploy on push? The export workflow pushes with the default GITHUB_TOKEN, and pushes made with that token do not trigger other workflows. workflow_run is the clean way to chain them.


Running it

Export run — all steps green:

Export success

The unpacked solution is now in Git as readable files, so every change to the flow shows up as a diff:

Unpacked source in Git

Deploy started automatically through workflow_run:

Deploy triggered

Deploy success

The managed solution in the target environment:

Managed solution in target

Components in target

Notice two things: the flow is Off, and the owner of every component is the service principal. That leads to the final, one-time step.

Step 9: Post-deployment (one time per target environment)

The managed import created the connection reference, but no connection is bound to it yet. Because of that, the flow arrives Off. Components inside a managed solution can't be edited, so open the environment's Default Solution instead:

  1. Solutions → Default Solution → Connection references, then search for the connection reference's logical name.
  2. Open it, choose your Office 365 Outlook connection (or + New connection) and Save.

Bind connection reference

  1. Back in the managed solution, select the flow and click Turn on.

Flow turned on

This is a one-time step per target environment. After later deployments, check that the connection reference is still bound and the flow is still On. If an upgrade clears the binding, re-bind it here, or move to a connection the service principal is allowed to use and put its ID in the settings file.

Step 10: Test the deployed flow

Open the flow in the target environment and select Run → Continue → Run flow. The email arrives at the address held in the target value of the environment variable, not the Dev value. That proves the deployment settings file did its job.

Run succeeded

Run steps

Here is the full history of the Deploy workflow, including the failed run that taught me Error 3 below:

Deploy runs


Troubleshooting: the three errors I hit

Error 1 — does not have permission to access the path …/environments

Who am I error

Symptom who-am-i logs authenticated successfully, then fails saying the service principal does not have permission to access the environment
Cause The app registration and secret are correct (Entra sign-in worked), but the app is not an Application user in that Dataverse environment
Fix Add it as an Application user with System Administrator in every environment the pipeline touches

Error 2 — PrivilegeDenied when creating the Application user

Privilege denied

Symptom There was a problem adding the app to this environment… PrivilegeDenied
Cause You can only assign a security role you hold yourself. Being a Global or Power Platform administrator does not automatically make you a Dataverse System Administrator in an environment, and the Default environment is the usual place this shows up.
Fix Power Platform admin center → the environment → Membership → add yourself as System Administrator, wait a minute, then create the Application user again

Error 3 — ConnectionAuthorizationFailed (403) during import

Connection authorization failed

Symptom Import fails with An unexpected error occurred. Solutions → History in the target shows error 8004801E: the caller does not have the minimum required permission … on connection … under API 'shared_office365'
Cause I put my user-owned Outlook connection ID in the deployment settings file. The import runs as the service principal, and the service principal isn't allowed to use a connection owned by someone else. Microsoft's guidance is that the connection must be owned by, or shared with, the owner of the connection reference.
Fix Leave ConnectionId empty for per-user connectors like Office 365 Outlook, and bind the connection once in the target after the first deployment (see Step 9). Connections the service principal can own, or shareable connections, can go straight into the file.

Debugging tip: when the pipeline only says unexpected error, open Solutions → History in the target environment, select the failed import and expand More details. The real error is there.


Lessons learned

Lesson
Run who-am-i first It turns a vague import failure into a clear permissions error in seconds
The service principal needs an Application user in each environment Entra sign-in success ≠ Dataverse access
You must hold System Administrator yourself to grant it Use Membership in the admin center
Imported flows are owned by the service principal and arrive Off Plan a post-deploy step for connections and activation
Per-user connectors can't be bound by the service principal Leave ConnectionId blank, bind once in the target
Check Solutions → History for the real error The CLI often reports a generic message
Avoid the Default environment as a Dev source for real projects Use a dedicated Developer or Sandbox environment

References (Microsoft Learn)

Featured Post

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, a...

Popular posts