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
| Output | Meaning | Action |
|---|
Test email sent (with TLS) | Auth + STARTTLS working | Configure app with TLS enabled |
TLS attempt failed + sent (without TLS) | Auth works; relay certificate not trusted / name mismatch | App works on port 25 without TLS; raise certificate fix with relay owner |
Send failed | Auth or network issue | Use the troubleshooting table below |
TLS Certificate Errors
| Error | Cause | Fix |
|---|
RemoteCertificateNameMismatch | Certificate 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 |
RemoteCertificateChainErrors | Issuing CA not trusted by the client, or chain incomplete/expired | Install the internal root/intermediate CA on the client, or renew the certificate |
Test Troubleshooting
| Error | Likely cause |
|---|
| Cannot reach port 25 | Network / firewall blocks outbound 25 from the client |
5.7.57 / Authentication unsuccessful | Wrong password, account disabled, or not yet replicated |
5.7.60 / SendAsDenied | From address doesn't match the account's primary SMTP address |
5.7.1 Client was not authenticated | Relay requires AUTH but credentials weren't offered (e.g. AUTH only advertised after STARTTLS) |
| Email not received | Check Junk folder and message trace on the relay / Exchange Online |
TLS Certificate on the Relay (STARTTLS)
Symptom Matrix
| Client | Error | Meaning |
|---|
PowerShell Send-MailMessage -UseSsl | RemoteCertificateNameMismatch, RemoteCertificateChainErrors | Cert not issued for the relay hostname and/or issuer not trusted |
Python smtplib.starttls() | CERTIFICATE_VERIFY_FAILED: self-signed certificate | Relay presents a self-signed cert (often the default Exchange cert) |
| Any | Fails before AUTH | TLS 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
| Result | Diagnosis |
|---|
subject = issuer | Self-signed |
SAN lacks smtp.contoso.com | Hostname mismatch |
notAfter in the past | Expired |
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
| Rule | Detail |
|---|
| Every server behind the relay name | Must present the same valid cert, otherwise failures are intermittent |
Connector Fqdn | Should match a name in the certificate SAN |
| Client trust | Distribute root + intermediate CA (PEM) with SHA-256 fingerprints |
| Avoid | Disabling 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 item | Recommendation |
|---|
| Password | Secret → env var SMTP_PASS |
| CA bundle | ConfigMap / Secret mounted as file → SMTP_CA_FILE |
| Egress | NetworkPolicy / firewall allows pod → relay on 25 |
| Source IP | Relay 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
| # | Scenario | Script |
|---|
| A | Send-only app (no mailbox) | Scenario A — Mail User |
| B | App sends and receives replies (shared mailbox) | Scenario B — Remote Shared Mailbox |
| C | Grant users access to the shared mailbox | Scenario B — Exchange Online part |
| D | Rotate service account password | Scenario C |
| E | Decommission the service account | Scenario D |
| F | Interactive 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)
| Item | Value |
|---|
| Object | Mail-enabled user |
| Inbound mail | Not delivered (no mailbox) |
| Licence | None |
$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)
| Item | Value |
|---|
| Object | Remote shared mailbox (mailbox lives in Exchange Online) |
| Inbound mail | Delivered to shared mailbox |
| Licence | Not required within shared mailbox limits |
| Prerequisite | Exchange 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*" }
| Check | Expected |
|---|
RecipientTypeDetails (EXO) | SharedMailbox |
If shown as UserMailbox | Set-Mailbox $Mailbox -Type Shared (EXO) and Set-RemoteMailbox $SamAccountName -Type Shared (on-prem) |
| Reply test | Send 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
}
| Note | Detail |
|---|
Remove-MailUser / Remove-RemoteMailbox | Also deletes the AD account |
| Safer alternative | Disable first: Disable-ADAccount, wait, then remove |
| Shared mailbox data | Export or retain before removal if needed |
No comments:
Post a Comment