> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://help.trugrid.com/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# 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](#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`.

```json
<#
.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.