Articles on: TruGrid Tools

How to set up sending automated password expiry notice to AD users.

A daily scheduled task on a domain controller (or any domain-joined server with the AD PowerShell module) emails users before their Active Directory password expires, sending through a Microsoft 365 mailbox.


Users get an email 14, 7, 3, 2 and 1 days before expiry. Disabled accounts, "Password never expires" accounts and accounts set to "User must change password at next logon" are skipped. Fine-Grained Password Policies are respected.

Step 1: Set up the Microsoft 365 sender account


  1. Create a user in Microsoft 365 to send from, for example noreply@yourdomain.com, and assign it a license that includes an Exchange Online mailbox. Give it a long, unique password.
  2. Enable SMTP AUTH for that mailbox in Exchange Online PowerShell:


    # If the module is missing: Install-Module ExchangeOnlineManagement
    Connect-ExchangeOnline
    Set-CASMailbox -Identity noreply@yourdomain.com -SmtpClientAuthenticationDisabled $false
  3. Make sure nothing blocks it: Security Defaults must be off, any Conditional Access policy blocking legacy authentication must exclude this account, and the account cannot have MFA.


Microsoft plans to disable SMTP AUTH basic authentication by default for existing tenants at the end of December 2026. Admins can re-enable it. See Sources.


Step 2: Install the script


On the server, create the folder and lock it to SYSTEM and Administrators:

New-Item -ItemType Directory -Path C:\Scripts\PwExpiry -Force | Out-Null
icacls C:\Scripts\PwExpiry /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F"


Save the script below as C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1, then edit the CONFIG block: From, FromDisplayName, SearchBase (empty = whole domain), NotifyDays, AdminRecipients (optional daily summary), CompanyName and HelpContact.


<#
.SYNOPSIS
    Emails AD users before their password expires, through a Microsoft 365 mailbox (SMTP AUTH).
    One-time setup (elevated):  .\Send-PasswordExpiryNotice.ps1 -SetSecret
    Scheduled run:              .\Send-PasswordExpiryNotice.ps1
#>
param([switch]$SetSecret)

#region ---------------- CONFIG ----------------
$Config = @{
    From             = 'noreply@contoso.com'       # M365 sender mailbox, also the SMTP username
    FromDisplayName  = 'IT Support'
    SmtpServer       = 'smtp.office365.com'
    SmtpPort         = 587

    SearchBase       = @()                         # Empty = whole domain. Else @('OU=Staff,DC=contoso,DC=com')
    NotifyDays       = @(14, 7, 3, 2, 1)           # Days before expiry on which a mail goes out
    AdminRecipients  = @()                         # Daily summary, e.g. @('it@contoso.com'). Empty = none

    CompanyName      = 'Contoso'
    HelpContact      = 'it@contoso.com'

    SecretPath       = Join-Path $PSScriptRoot 'pwnotify.secret'
    LogPath          = Join-Path $PSScriptRoot 'logs'
    LogRetentionDays = 90
}
#endregion

$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Security
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

if (-not (Test-Path $Config.LogPath)) { New-Item -ItemType Directory -Path $Config.LogPath | Out-Null }
$LogFile = Join-Path $Config.LogPath ('pwnotify_{0:yyyyMMdd}.log' -f (Get-Date))

function Write-Log {
    param([string]$Msg, [string]$Level = 'INFO')
    $line = '{0:yyyy-MM-dd HH:mm:ss} [{1}] {2}' -f (Get-Date), $Level, $Msg
    Add-Content -Path $LogFile -Value $line
    Write-Host $line
}

if ($SetSecret) {
    $sec  = Read-Host -AsSecureString "Enter SMTP password for $($Config.From)"
    $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
    try   { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
    finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
    $enc = [Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($plain), $null,
        [Security.Cryptography.DataProtectionScope]::LocalMachine)
    [IO.File]::WriteAllBytes($Config.SecretPath, $enc)
    & icacls.exe $Config.SecretPath /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null
    Write-Host "Password stored in $($Config.SecretPath) (SYSTEM + Administrators only)."
    return
}

function Send-Mail {
    param([string[]]$To, [string]$Subject, [string]$Html)
    $mail = New-Object Net.Mail.MailMessage
    $mail.From = New-Object Net.Mail.MailAddress($Config.From, $Config.FromDisplayName)
    foreach ($r in $To) { $mail.To.Add($r) }
    $mail.Subject = $Subject; $mail.Body = $Html; $mail.IsBodyHtml = $true
    $mail.BodyEncoding = [Text.Encoding]::UTF8; $mail.SubjectEncoding = [Text.Encoding]::UTF8
    $smtp = New-Object Net.Mail.SmtpClient($Config.SmtpServer, $Config.SmtpPort)
    $smtp.EnableSsl = $true
    $smtp.Credentials = New-Object Net.NetworkCredential($Config.From, $script:Password)
    try { $smtp.Send($mail) } finally { $mail.Dispose(); $smtp.Dispose() }
}

function New-UserBody {
    param($User, [datetime]$Expires, [int]$Days)
    $e    = { param($s) [Net.WebUtility]::HtmlEncode([string]$s) }
    $name = if ($User.givenName) { $User.givenName } else { $User.displayName }
    $when = if ($Days -eq 1) { 'tomorrow' } else { "in $Days days" }
@"
<html><body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">
<p>Hello $(& $e $name),</p>
<p>Your $(& $e $Config.CompanyName) network password (<b>$(& $e $User.SamAccountName)</b>) expires <b>$when</b>,
on <b>$(& $e $Expires.ToString('dddd, d MMMM yyyy HH:mm'))</b>.</p>
<p>To change it, press <b>Ctrl+Alt+Del</b> on a company computer, or <b>Ctrl+Alt+End</b> inside a remote desktop session, and choose <b>Change a password</b>.</p>
<p>Questions: $(& $e $Config.HelpContact)</p>
<p style="color:#888;font-size:12px">Automated message, do not reply.</p>
</body></html>
"@
}

#region ---------------- MAIN ----------------
$errors = 0
try {
    Import-Module ActiveDirectory
    if (-not (Test-Path $Config.SecretPath)) { throw "No stored password at $($Config.SecretPath). Run with -SetSecret first." }
    $script:Password = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect(
        [IO.File]::ReadAllBytes($Config.SecretPath), $null, [Security.Cryptography.DataProtectionScope]::LocalMachine))

    $props  = 'mail', 'displayName', 'givenName', 'pwdLastSet', 'msDS-UserPasswordExpiryTimeComputed'
    $filter = 'Enabled -eq $true -and PasswordNeverExpires -eq $false'
    $users  = if ($Config.SearchBase.Count -gt 0) {
                  foreach ($sb in $Config.SearchBase) { Get-ADUser -Filter $filter -SearchBase $sb -Properties $props }
              } else { Get-ADUser -Filter $filter -Properties $props }
    $users = @($users | Sort-Object DistinguishedName -Unique)
    Write-Log "Run start. Candidate accounts: $($users.Count)"
}
catch { Write-Log "Fatal: $($_.Exception.Message)" 'ERROR'; exit 2 }

$today   = (Get-Date).Date
$summary = New-Object System.Collections.Generic.List[object]

foreach ($u in $users) {
    $raw = $u.'msDS-UserPasswordExpiryTimeComputed'
    if (-not $raw -or $raw -eq 0 -or $raw -eq [Int64]::MaxValue -or $u.pwdLastSet -eq 0) { continue }

    $expires = [DateTime]::FromFileTime($raw)
    $days    = ($expires.Date - $today).Days
    if ($days -lt 0) { $summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = 'Already expired' }); continue }
    if ($Config.NotifyDays -notcontains $days) { continue }

    if (-not $u.mail) {
        Write-Log "$($u.SamAccountName): expires in $days d, no mail attribute" 'WARN'
        $summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = 'No mail attribute' }); continue
    }

    $subject = if ($days -eq 1) { 'Your password expires tomorrow' } else { "Your password expires in $days days" }
    try {
        Send-Mail -To $u.mail -Subject $subject -Html (New-UserBody -User $u -Expires $expires -Days $days)
        Write-Log "SENT: $($u.SamAccountName) -> $($u.mail) days=$days"
        $summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = 'Sent' })
        Start-Sleep -Seconds 2
    }
    catch {
        $errors++
        Write-Log "FAILED: $($u.SamAccountName) -> $($u.mail) : $($_.Exception.Message)" 'ERROR'
        $summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = "FAILED: $($_.Exception.Message)" })
    }
}

if ($Config.AdminRecipients.Count -gt 0 -and $summary.Count -gt 0) {
    $rows = ($summary | Sort-Object Days | ForEach-Object {
        '<tr><td>{0}</td><td>{1:yyyy-MM-dd HH:mm}</td><td>{2}</td><td>{3}</td></tr>' -f $_.User, $_.Expires, $_.Days, [Net.WebUtility]::HtmlEncode($_.Result)
    }) -join "`n"
    $html = "<html><body style='font-family:Segoe UI,Arial,sans-serif;font-size:13px'><p>Password expiry run on $env:COMPUTERNAME. Errors: $errors</p><table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse'><tr><th>User</th><th>Expires</th><th>Days</th><th>Result</th></tr>$rows</table></body></html>"
    try   { Send-Mail -To $Config.AdminRecipients -Subject "Password expiry report: $errors errors" -Html $html }
    catch { $errors++; Write-Log "Admin summary FAILED: $($_.Exception.Message)" 'ERROR' }
}

Get-ChildItem $Config.LogPath -Filter 'pwnotify_*.log' |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$Config.LogRetentionDays) } | Remove-Item -Force

Write-Log "Run end. Errors: $errors"
if ($errors -gt 0) { exit 1 } else { exit 0 }
#endregion


Step 3: Store the password


In an elevated PowerShell window on the same server, run once and enter the sender mailbox password when prompted:

Unblock-File C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1
C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1 -SetSecret

The password is encrypted to pwnotify.secret and only decrypts on this server. Run -SetSecret again whenever the password changes.


Step 4: Schedule it


In the same elevated window (change 08:00 if needed):

$action    = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 08:00
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 1)

Register-ScheduledTask -TaskName 'AD Password Expiry Notifier' -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force

Run it on one server only, or users get every email twice.


Step 5: Confirm it works


Start-ScheduledTask -TaskName 'AD Password Expiry Notifier'
Get-ScheduledTaskInfo -TaskName 'AD Password Expiry Notifier' | Select-Object LastRunTime, LastTaskResult
Get-Content "C:\Scripts\PwExpiry\logs\pwnotify_$(Get-Date -Format yyyyMMdd).log"

LastTaskResult

Meaning

0

All emails sent

1

At least one email failed; see FAILED lines in the log

2

Stopped before sending: AD module missing, AD query failed, or no stored password (Step 3)


An SMTP authentication failure in the log means one of the Step 1 items is not in place.

Updated on: 15/10/2026

Was this article helpful?

Share your feedback

Cancel

Thank you!