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


No comments:

Post a Comment

Featured Post

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) <# ...

Popular posts