Microsoft Password Expiration Notification Emails
Microsoft Removes 365 Email Notifications
There was previously a field named "Days before a user is notified about expiration". You are not going crazy, this used to be an option until Microsoft removed it.

Solution 1: Microsoft Password Expiration Email Notifications with PowerShell
The Password Expiry Email Notification PowerShell Script is commonly used. The script can be loaded onto a domain controller and used with CSS to style the email.
The emails can look very professional with trial and error and a bit of web tinkering, what engineer doesn't like a bit of web design 😛
There are versions of the script which use "new-StoredCredential" which creates a value in credential manager on the domain controller running the script. These can be easily removed by Windows updates and occasionally windows reboot, which can make them frustrating to manage. You are stuck either re-publishing the value manually to credential manager or scripting it and showing the password in plain text.
The link above uses a hashed text file and the New-Object with -typename System.Management.Automation.PSCredential. Although this resolves the problem of plain text passwords, it's still not an ideal solution.
1. When an admin/engineer leaves the business, you are stuck trying to re-hash the password and re-configure the script to accept the new text file.
2. Microsoft continue to push users away from legacy auth. Although they have once again delayed their September 2022 deadline to January 2023, they will make this change eventually. Yes I know SMTP is not part of the change (as long as you have existing SMTP traffic), but it will be changed eventually and they have made their concerns public about SMTP legacy auth.
3. More work when you move to a cloud only AAD
Solution 2: Microsoft Password Expiration Email Notifications with PowerAutomate
Articles like this have explained this, however the date format didn't work for me. After manipulating it many times, the solution ended up being quite simple.
1. Create a recurring timer:

2. For testing, you can use search terms from a Search for Users (V2) which will allow you enter names of test users. *obviously test thoroughly before releasing to prod.

3. Using a loop, iterate through each of the values you got from the Search for Users (V2) {Note: Once you are done testing, change the checkbox to Is Search Term Required: No}. Each returned value is the full schema data for a user including name, ID, phone number, country etc. Select only User Id

4. Log into Azure and create a new App Registration, with a suitable name. Select the prefered account type when you are creating the app registration. Accounts in this organizational directory only is usually preferred.

5. Once created, go to API Permissions on the left, add/confirm you have:
User.Read
User.Read.All
If you are missing permissions, click Add a Permission > Microsoft Graph > Application Permission > search for User.Read and click Add Permission. Also do this for User.Read.All. Once complete, click Grant admin consent for <domain name>, you will need administrative permissions for this.

6. Go back to your Flow and use the HTTP Get action to call the Microsoft Graph API. The summary of details is below:
Method: GET
URI: https://graph.microsoft.com/beta/users/UserPrincipalName?$select=lastPasswordChangeDateTime
Authentication: OAUTH
Authority: https://login.microsoft.com
Tenant: Enter tenant ID from Azure AD Portal.
Audience: https://graph.microsoft.com
Client ID: Get this value from the App Registration > Overview section in the Azure Portal
Secret: Go to the App Registration > Certificates and Secrets > + New Client Secret > give it a name and expiration date > copy the value. The value will be unreadable when you leave the page. If you lose it, just re-create it and delete the old.

7. Create a Compose action and use the following as an input body('GetLastPasswordChange')?['lastPasswordChangeDateTime']
You will notice the GetLastPasswordChange is the name of the HTTP GET. The lastPasswordChangeDateTime is the name of the schema attribute from the user.

8. Use a second Compose action and enter the following expression
div(sub(ticks(formatDateTime(utcNow(),'yyyy-MM-ddTHH:mmZ')),ticks(outputs('DateRawFormat'))),864000000000)
This will give you the difference in days between the current time and the last password change date.

9. Depending on your password policy, create a condition for the amount of days needed. You could make this more sophisticated with integration to AAD Groups. If you use Fine Grained Password Policies (FGPP), you could also use on-prem AAD Connect synced groups so users in different groups get different notifications.

10. If Teams notifications are also needed, this is a simple integration.
Hope this helps some people!