Build Course Expiry Reminder Emails
This replaces the earlier reminder sketch with a safer end-to-end build: locked reminder log, test-only routing by default, duplicate-send protection, escalation rules, rich email body, and a deep link that opens the exact course in Training Tracker.
TestMode is false and LiveEmailsEnabled is true, every email goes to your test inbox. The guide deliberately uses two switches, a far-future schedule start, and a locked reminder log so a half-built flow cannot blast 120 staff by accident.
1. Business Rule
Use a staged compliance cadence rather than one reminder. This is close to how LMS/compliance platforms usually handle renewals: early warning, final warning, overdue chase, then manager/admin escalation.
| When | Send to | Reminder type |
|---|---|---|
| 30 days before expiry | Staff member | DUE_30 |
| 7 days before expiry | Staff member | DUE_7 |
| Expiry day | Staff member | EXPIRED_TODAY |
| 7, 14, 21, 28 days overdue | Staff member. Add line manager from 14 days overdue. | OVERDUE_WEEKLY |
| 30 days overdue | Staff member, line manager, and admin/shared mailbox | OVERDUE_30 |
| Every 7 days after 30 days overdue | Staff member, line manager, and admin/shared mailbox | OVERDUE_ESCALATED |
2. Locked Log Design
Create a new SharePoint list called TT ReminderLog. This list can be completely locked down to the few people who administer Training Tracker.
TT ReminderLog because they never open, read, or write this list directly. The account used by the flow's SharePoint connection must have permission to create/read rows in TT ReminderLog. If the flow is built using your account, your account must be one of the permitted people.
| Column internal name | Type | Required? | Notes |
|---|---|---|---|
Title | Single line text | Yes | Use the reminder key. |
ReminderKey | Single line text | Yes | Set Enforce unique values to Yes if SharePoint allows it. |
PersonEmail | Single line text | Yes | Lowercase staff email. |
PersonName | Single line text | No | Display name at send time. |
CourseID | Number | Yes | TT Courses.ID. |
CourseName | Single line text | Yes | Course title at send time. |
TrainingRecordID | Number | No | TT TrainingRecords.ID. |
ExpiryDate | Date only | Yes | The date the course expires. |
ReminderType | Choice or single line text | Yes | Use the values in section 1. |
OverdueDays | Number | No | Zero before/on expiry, positive after expiry. |
SentOn | Date and time | Yes | Use utcNow(). |
SentTo | Single line text | Yes | The address actually used in the To field. |
WouldHaveSentTo | Single line text | Yes | The real staff address, even in test mode. |
CcTo | Single line text | No | Manager/admin escalation recipients actually used. |
TestMode | Yes/No | Yes | True while testing. |
LiveEmailsEnabled | Yes/No | Yes | Second safety switch. |
FlowRunName | Single line text | No | Optional run identifier for troubleshooting. |
3. Add Deep Linking To The App
The email button will pass target=course, courseId, recordId, and personEmail. The app must read those parameters and open the same course context that a normal gallery click would have opened.
3.1 App.OnStart Addition
Add this to the end of the existing App.OnStart. Formula bar paste: no leading equals sign.
Set(varDeepLinkTarget, Lower(Param("target")));
Set(varDeepLinkCourseID, If(IsBlank(Param("courseId")), Blank(), Value(Param("courseId"))));
Set(varDeepLinkRecordID, If(IsBlank(Param("recordId")), Blank(), Value(Param("recordId"))));
Set(varDeepLinkPersonEmail, Lower(Param("personEmail")));
Set(varPendingCourseDeepLink, varDeepLinkTarget = "course" && !IsBlank(varDeepLinkCourseID))
3.2 App.StartScreen
If your normal start screen is still scrStart, set App.StartScreen to this. It sends reminder links into the normal user training route; the timer below does the final course navigation.
If(
Lower(Param("target")) = "course" && !IsBlank(Param("courseId")),
scrMyTraining,
scrStart
)
3.3 Add Hidden Timer To scrMyTraining
Insert a Timer control on scrMyTraining, rename it tmrOpenReminderCourse, and set these properties.
| Property | Value |
|---|---|
Duration | 500 |
AutoStart | varPendingCourseDeepLink |
Start | varPendingCourseDeepLink |
Repeat | false |
Visible | false |
Set tmrOpenReminderCourse.OnTimerEnd to this complete formula. It deliberately ignores personEmail unless the current user is an admin, so a normal user cannot forge a link to someone else's record.
If(
varPendingCourseDeepLink,
Set(varPendingCourseDeepLink, false);
Refresh('TT Courses');
Refresh('TT TrainingRecords');
ClearCollect(colCourses, 'TT Courses');
ClearCollect(colAllRecords, 'TT TrainingRecords');
With(
{
targetEmail: If(
varIsAdmin && !IsBlank(varDeepLinkPersonEmail),
varDeepLinkPersonEmail,
varMyEmail
),
C: LookUp('TT Courses', ID = varDeepLinkCourseID)
},
If(
IsBlank(C.ID),
Notify("This reminder link could not find the course.", NotificationType.Error),
With(
{
P: LookUp('TT Personnel', Lower(Email) = targetEmail),
R: LookUp('TT TrainingRecords', Lower(PersonEmail) = targetEmail && CourseID = C.ID)
},
If(
IsBlank(R.ID),
Notify("This reminder link could not find the training record.", NotificationType.Error),
Set(varViewingForSelf, targetEmail = varMyEmail);
Set(varViewingPerson, If(targetEmail = varMyEmail, Blank(), P));
Set(
varCourse,
{
CourseID: C.ID,
CourseName: C.Title,
CourseURL: C.CourseURL,
CourseHost: C.CourseHost,
CourseDescription: C.CourseDescription,
RecurMonths: C.RecurrenceMonths,
RecurText: Switch(
C.RecurrenceMonths,
0, "One-off",
12, "Every year",
24, "Every 2 years",
"Every " & C.RecurrenceMonths & " months"
),
RecordID: R.ID,
DateDone: R.DateCompleted,
UploadedBy: R.UploadedByEmail,
ExpiresOn: If(
IsBlank(R.DateCompleted) || C.RecurrenceMonths = 0,
Blank(),
DateAdd(R.DateCompleted, C.RecurrenceMonths, "Months")
),
TrainingStatus: If(
IsBlank(R.DateCompleted), "Not Started",
C.RecurrenceMonths = 0, "Complete",
DateAdd(R.DateCompleted, C.RecurrenceMonths, "Months") < Today(), "Expired",
DateAdd(R.DateCompleted, C.RecurrenceMonths, "Months") <= DateAdd(Today(), 60, "Days"), "Expiring Soon",
"In Date"
)
}
);
Set(varRecord, R);
Navigate(scrCourseDetail, ScreenTransition.Cover)
)
)
)
)
)
4. Build The Flow Safely
Create a scheduled cloud flow called TT - Course Expiry Reminders. Set it to run daily, but set the first automatic start date far in the future while building, for example 2099-01-01 07:00. This lets you use manual tests without any automatic scheduled run happening while the app is unfinished.
Reminder due condition. An earlier draft used that pattern, but Power Automate can reject the flow because Send an email (V2) becomes nested too deeply. If you already built it, move everything from Reminder due - True / If yes up into Record exists - True / If yes, then delete the empty Reminder due condition. The final condition is now Ready to send.
Recurrence
├── TestMode
├── LiveEmailsEnabled
├── TestModeRecipient
├── AdminEscalationMailbox
├── AppDeepLinkBase
├── Get Active People
├── Get Active Courses
├── Get Training Records
└── Apply to each Person
└── Apply to each Course
├── Filter array
└── Record exists
├── True / If yes
│ ├── Expiry Date
│ ├── Expiry Date Short
│ ├── Today Short
│ ├── Overdue Days
│ ├── Reminder Type
│ ├── Deep Link
│ ├── Reminder Key
│ ├── Email To
│ ├── Email CC
│ ├── Email Subject
│ ├── Get Existing Reminder Log
│ └── Ready to send
│ ├── True / If yes
│ │ ├── Send an email (V2)
│ │ └── Create item in TT ReminderLog
│ └── False / If no - leave empty
└── False / If no - leave empty
Flow Shape - Build It In This Nesting Order
Keep this map open while building step 4. The indentation is the important bit. If Power Automate drops an action outside the branch shown here, drag it back before continuing.
Recurrence
├── TestMode
├── LiveEmailsEnabled
├── TestModeRecipient
├── AdminEscalationMailbox
├── AppDeepLinkBase
├── Get Active People
├── Get Active Courses
├── Get Training Records
└── Apply to each Person
└── Apply to each Course
├── Filter array
└── Record exists
├── True / If yes
│ ├── Expiry Date
│ ├── Expiry Date Short
│ ├── Today Short
│ ├── Overdue Days
│ ├── Reminder Type
│ ├── Deep Link
│ ├── Reminder Key
│ ├── Email To
│ ├── Email CC
│ ├── Email Subject
│ ├── Get Existing Reminder Log
│ └── Ready to send
│ ├── True / If yes
│ │ ├── Send an email (V2)
│ │ └── Create item in TT ReminderLog
│ └── False / If no - leave empty
└── False / If no - leave empty
Rule of thumb: only the safety variables and the three Get items actions sit at the top level. Everything after that is inside Person, then Course, then the relevant True branch.
4.1 First Actions: Safety Variables
Recurrence └── Safety variables go here
Add these actions immediately after the recurrence trigger, in this order.
| Action | Name | Type | Value |
|---|---|---|---|
| Initialize variable | TestMode | Boolean | true |
| Initialize variable | LiveEmailsEnabled | Boolean | false |
| Initialize variable | TestModeRecipient | String | Your email address. |
| Initialize variable | AdminEscalationMailbox | String | Your shared/admin mailbox. |
| Initialize variable | AppDeepLinkBase | String | Your app play URL ending before the custom parameters. |
Example app base: https://apps.powerapps.com/play/YOUR-APP-ID?tenantId=YOUR-TENANT-ID. Your reminder link will append &target=course&courseId=....
4.2 Pull The Data
Recurrence ├── Safety variables └── Get items actions go here
Add these SharePoint Get items actions. Use the same SharePoint connection account that has permission to the locked reminder log.
| Action name | List | Filter Query |
|---|---|---|
Get Active People | TT Personnel | Active eq true |
Get Active Courses | TT Courses | Status/Value eq 'Active' and RecurrenceMonths gt 0 |
Get Training Records | TT TrainingRecords | Leave blank. |
4.3 Loop People And Applicable Courses
Recurrence
└── Apply to each Person
└── Apply to each Course
- Add Apply to each over value from
Get Active People. Rename itApply to each Person. - Inside it, add another Apply to each. Use this expression for its input. Rename it
Apply to each Course.
filter(
body('Get_Active_Courses')?['value'],
and(
equals(item()?['Status']?['Value'], 'Active'),
greater(item()?['RecurrenceMonths'], 0),
contains(string(item()?['ApplicableTo']), items('Apply_to_each_Person')?['StaffType']?['Value']),
not(
and(
equals(items('Apply_to_each_Person')?['RemoteWorker'], true),
equals(item()?['ExcludeRemoteWorkers'], true)
)
)
)
)
4.4 Find The Matching Training Record
Apply to each Person
└── Apply to each Course
├── Filter array
└── Record exists
Inside the course loop, add Filter array. Use value from Get Training Records as the source. Use advanced mode and paste this.
@and(
equals(toLower(item()?['PersonEmail']), toLower(items('Apply_to_each_Person')?['Email'])),
equals(item()?['CourseID'], items('Apply_to_each_Course')?['ID'])
)
Now add a condition immediately underneath this Filter array. This condition stops the flow trying to calculate an expiry date when the person has no training record for that course yet.
- Click the small + directly underneath the Filter array action.
- Choose Add an action.
- Search for Condition, then select the built-in Control - Condition action.
- Open the condition's three-dot menu and rename it to
Record exists. Do not use punctuation in Power Automate action names. - Click the left value box, then choose the Expression tab in the dynamic-content panel.
- Paste this expression, then click Add.
length(body('Filter_array'))
- Set the middle operator dropdown to is greater than.
- Type
0into the right value box. - Everything in the next section goes inside the True / If yes branch of
Record exists. Leave the False / If no branch empty.
4.5 Compose Dates And Reminder Type
Apply to each Person
└── Apply to each Course
└── Record exists
└── True / If yes
├── Expiry Date
├── Expiry Date Short
├── Today Short
├── Overdue Days
└── Reminder Type
Stay inside Record exists - True / If yes. Add these Compose actions in order. Do not put them underneath the whole condition and do not put them in the False branch.
| Compose name | Expression |
|---|---|
Expiry Date | addToTime(first(body('Filter_array'))?['DateCompleted'], items('Apply_to_each_Course')?['RecurrenceMonths'], 'Month') |
Expiry Date Short | formatDateTime(outputs('Expiry_Date'), 'yyyy-MM-dd') |
Today Short | formatDateTime(utcNow(), 'yyyy-MM-dd') |
Overdue Days | div(sub(ticks(formatDateTime(utcNow(), 'yyyy-MM-dd')), ticks(formatDateTime(outputs('Expiry_Date'), 'yyyy-MM-dd'))), 864000000000) |
Reminder Type | Use the copied expression below. Add this Compose directly underneath Overdue Days, still inside Record exists - True / If yes. |
For the Reminder Type Compose action, paste this into the Expression tab.
if(
equals(outputs('Expiry_Date_Short'), formatDateTime(addDays(utcNow(), 30), 'yyyy-MM-dd')),
'DUE_30',
if(
equals(outputs('Expiry_Date_Short'), formatDateTime(addDays(utcNow(), 7), 'yyyy-MM-dd')),
'DUE_7',
if(
equals(outputs('Expiry_Date_Short'), outputs('Today_Short')),
'EXPIRED_TODAY',
if(
equals(outputs('Overdue_Days'), 30),
'OVERDUE_30',
if(
and(greater(outputs('Overdue_Days'), 0), equals(mod(outputs('Overdue_Days'), 7), 0)),
if(greater(outputs('Overdue_Days'), 30), 'OVERDUE_ESCALATED', 'OVERDUE_WEEKLY'),
''
)
)
)
)
)
Reminder due condition. Power Automate has a nesting limit, and that extra condition pushes Send an email (V2) too deep. The reminder-type check is handled later by the combined Ready to send condition.
4.6 Build Link, Recipients, Subject, And Duplicate Key
Record exists
└── True / If yes
├── Deep Link
├── Reminder Key
├── Email To
├── Email CC
└── Email Subject
Stay inside Record exists - True / If yes. Add these Compose actions directly underneath Reminder Type.
| Compose name | Expression |
|---|---|
Deep Link | concat(variables('AppDeepLinkBase'), '&target=course&courseId=', items('Apply_to_each_Course')?['ID'], '&recordId=', first(body('Filter_array'))?['ID'], '&personEmail=', uriComponent(items('Apply_to_each_Person')?['Email'])) |
Reminder Key | concat(toLower(items('Apply_to_each_Person')?['Email']), '|', items('Apply_to_each_Course')?['ID'], '|', outputs('Reminder_Type'), '|', outputs('Today_Short')) |
Email To | if(or(equals(variables('TestMode'), true), not(equals(variables('LiveEmailsEnabled'), true))), variables('TestModeRecipient'), items('Apply_to_each_Person')?['Email']) |
Email CC | if(or(equals(variables('TestMode'), true), not(equals(variables('LiveEmailsEnabled'), true))), '', if(greaterOrEquals(outputs('Overdue_Days'), 30), concat(items('Apply_to_each_Person')?['LineManagerEmail'], ';', variables('AdminEscalationMailbox')), if(greaterOrEquals(outputs('Overdue_Days'), 14), items('Apply_to_each_Person')?['LineManagerEmail'], ''))) |
Email Subject | concat(if(greater(outputs('Overdue_Days'), 0), 'Training overdue: ', 'Training reminder: '), items('Apply_to_each_Course')?['Title']) |
4.7 Check The Locked Log Before Sending
Record exists
└── True / If yes
├── Reminder Type
├── Deep Link
├── Reminder Key
├── Email To
├── Email CC
├── Email Subject
├── Get Existing Reminder Log
└── Ready to send
Add SharePoint - Get items directly underneath Email Subject, still inside Record exists - True / If yes. Name it Get Existing Reminder Log, list TT ReminderLog. For Filter Query, use this expression.
concat("ReminderKey eq '", outputs('Reminder_Key'), "'")
Add one final Condition named Ready to send. This replaces the old two-condition pattern and keeps the flow under Power Automate's nesting limit.
| Condition field | Set this |
|---|---|
| Left side | Use the expression below. |
| Operator | is equal to |
| Right side | true |
and(
not(equals(outputs('Reminder_Type'), '')),
equals(length(body('Get_Existing_Reminder_Log')?['value']), 0)
)
Only send the email inside Ready to send - True / If yes. Leave Ready to send - False / If no empty.
4.8 Send The Email
- Open the
Reminder duecondition. - Move these actions from
Reminder due - True / If yesup one level so they sit directly insideRecord exists - True / If yes:Deep Link,Reminder Key,Email To,Email CC,Email Subject,Get Existing Reminder Log, and the oldNot already sentcondition. - Delete the now-empty
Reminder duecondition. - Rename
Not already senttoReady to send. - Open
Ready to sendand change the left side to theReady to send left sideexpression from 4.7. - Set the operator to is equal to and the right side to
true. - Only when that saves cleanly, continue with the email action below inside
Ready to send - True / If yes.
Record exists
└── True / If yes
├── Reminder Type
├── Deep Link
├── Reminder Key
├── Email To
├── Email CC
├── Email Subject
├── Get Existing Reminder Log
└── Ready to send
└── True / If yes
└── Send an email (V2)
Ready to send
└── True / If yes
└── Send an email (V2)
Inside Ready to send - True / If yes, add Office 365 Outlook - Send an email (V2). The Cc field is not shown by default in some versions of the Power Automate designer; open Advanced parameters / Show advanced options and add Cc.
| Field | Value |
|---|---|
| To | Dynamic content: Outputs from the Email To Compose action. If using Expression instead, paste outputs('Email_To'). |
| Cc | Open Advanced parameters, add Cc, then use dynamic content: Outputs from Email CC. If using Expression instead, paste outputs('Email_CC'). |
| Subject | Dynamic content: Outputs from Email Subject. If using Expression instead, paste outputs('Email_Subject'). |
| From / Send as | Only use this if you have permission to send from the shared mailbox. If you are not sure, leave it blank and let the flow send as the connection account. |
| Body | Paste the HTML template below, then replace each placeholder using the exact mapping table under the template. |
<div style="font-family:Segoe UI,Arial,sans-serif;background:#f6f8fb;padding:24px;color:#172033;">
<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #d9e1ec;border-radius:12px;overflow:hidden;">
<div style="background:#004e42;color:#ffffff;padding:18px 22px;">
<div style="font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">Training Tracker</div>
<div style="font-size:22px;font-weight:700;margin-top:4px;">Mandatory training reminder</div>
</div>
<div style="padding:22px;">
<p style="margin:0 0 14px;">Hello <strong>PERSON NAME</strong>,</p>
<p style="margin:0 0 16px;">Your mandatory training record needs attention.</p>
<table style="border-collapse:collapse;width:100%;font-size:14px;margin:14px 0;">
<tr><td style="border:1px solid #d9e1ec;padding:8px;background:#eef3f8;font-weight:700;">Course</td><td style="border:1px solid #d9e1ec;padding:8px;">COURSE NAME</td></tr>
<tr><td style="border:1px solid #d9e1ec;padding:8px;background:#eef3f8;font-weight:700;">Expiry date</td><td style="border:1px solid #d9e1ec;padding:8px;">EXPIRY DATE</td></tr>
<tr><td style="border:1px solid #d9e1ec;padding:8px;background:#eef3f8;font-weight:700;">Status</td><td style="border:1px solid #d9e1ec;padding:8px;">REMINDER TYPE</td></tr>
</table>
<p style="margin:18px 0;">
<a href="DEEP LINK" style="display:inline-block;background:#004e42;color:#ffffff;text-decoration:none;font-weight:700;padding:12px 18px;border-radius:8px;">Open this course</a>
</p>
<p style="font-size:13px;color:#637084;margin:18px 0 0;">If you have already completed this course, open Training Tracker and upload the certificate or update the completion date.</p>
</div>
</div>
</div>
Exact Body Placeholder Replacements
After pasting the HTML into the Body field, replace the placeholder words one by one. Click in the body text, delete the placeholder, then insert the dynamic content shown here.
Current item: do not use it. Click the Expression tab instead and paste the expression from the fallback column. This is normal inside nested loops; the designer often hides the useful fields and only exposes Current item.
| Placeholder in HTML | Preferred token if it appears | Use this expression if you only see Current item |
|---|---|---|
PERSON NAME | Title from Apply to each Person | items('Apply_to_each_Person')?['Title'] |
COURSE NAME | Title from Apply to each Course | items('Apply_to_each_Course')?['Title'] |
EXPIRY DATE | Outputs from Expiry Date Short | outputs('Expiry_Date_Short') |
REMINDER TYPE | Outputs from Reminder Type | outputs('Reminder_Type') |
DEEP LINK | Outputs from Deep Link | outputs('Deep_Link') |
DEEP LINK inside the button's href value with Outputs from the Deep Link Compose action. Do not use AppDeepLinkBase, and do not use the old long expression again here. The button should still say Open this course; only the hidden link target changes.
During TestMode, the email still shows the real person/course details so you can verify exactly what would have been sent, but the actual To address remains your test inbox because Email To handles the safety routing.
4.9 Write The Reminder Log
Ready to send
└── True / If yes
├── Send an email (V2)
└── Create item in TT ReminderLog
Apply to each / For each boxes here, stop. Do not fill fields inside those boxes. Delete the auto-created loop and add the value again from the Expression tab instead. Inside Ready to send - True / If yes, there should only be two actions: Send an email (V2) and Create item.
Immediately after the email action, still inside Ready to send - True / If yes, add SharePoint - Create item against TT ReminderLog.
Use expressions for these fields. This avoids Power Automate wrapping the action in unwanted loops.
| Column | Use this expression or value |
|---|---|
Title | outputs('Reminder_Key') |
ReminderKey | outputs('Reminder_Key') |
PersonEmail | items('Apply_to_each_Person')?['Email'] |
PersonName | items('Apply_to_each_Person')?['Title'] |
CourseID | items('Apply_to_each_Course')?['ID'] |
CourseName | items('Apply_to_each_Course')?['Title'] |
TrainingRecordID | first(body('Filter_array'))?['ID'] |
ExpiryDate | outputs('Expiry_Date_Short') |
ReminderType | outputs('Reminder_Type') |
OverdueDays | outputs('Overdue_Days') |
SentOn | utcNow() |
SentTo | outputs('Email_To') |
WouldHaveSentTo | items('Apply_to_each_Person')?['Email'] |
CcTo | outputs('Email_CC') |
TestMode | variables('TestMode') |
LiveEmailsEnabled | variables('LiveEmailsEnabled') |
5. Testing Before Live
- Keep
TestMode = trueandLiveEmailsEnabled = false. - Run the flow manually. Confirm every email arrives only in your inbox.
- Open
TT ReminderLogas an admin. Confirm rows were created andSentTois your address whileWouldHaveSentToshows the real staff address. - Run the flow manually a second time on the same day. Confirm no duplicate emails are sent for the same
ReminderKey. - Click the button in a test email. As an admin, it should open the linked person's course. As a normal user, a link only opens that user's own record.
- Only after those checks pass, change
TestModetofalse. LeaveLiveEmailsEnabledasfalseand run one more manual test. Emails must still go only to your test inbox.
6. Go Live
- Confirm the training data is populated enough that reminder emails will not surprise everyone.
- Confirm
TT ReminderLogpermissions are locked down and the flow connection account can still create log rows. - Change the recurrence trigger start time from the far-future date to the real daily start time, for example tomorrow at
07:00. - Set
TestMode = false. - Set
LiveEmailsEnabled = true. - Save. Watch the first real run history and compare the number of sent emails with the number of rows created in
TT ReminderLog.
7. Sources And Rules Used
- Microsoft Learn: Power Apps deep linking with
Param()and app play URLs. - Microsoft Learn: scheduled cloud flows.
- Microsoft Learn: turn cloud flows on or off.
- Microsoft Learn: Send an email (V2), rich text, and send-as/on-behalf behaviour.
- Microsoft Learn: manage Power Automate connections used by flows.