Training Tracker only Reminder emails Locked log safe

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.

Safety rule: until both 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.

WhenSend toReminder type
30 days before expiryStaff memberDUE_30
7 days before expiryStaff memberDUE_7
Expiry dayStaff memberEXPIRED_TODAY
7, 14, 21, 28 days overdueStaff member. Add line manager from 14 days overdue.OVERDUE_WEEKLY
30 days overdueStaff member, line manager, and admin/shared mailboxOVERDUE_30
Every 7 days after 30 days overdueStaff member, line manager, and admin/shared mailboxOVERDUE_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.

Yes, a locked log can still work. A scheduled Power Automate flow runs using its configured SharePoint connection. Normal staff do not need access to 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 nameTypeRequired?Notes
TitleSingle line textYesUse the reminder key.
ReminderKeySingle line textYesSet Enforce unique values to Yes if SharePoint allows it.
PersonEmailSingle line textYesLowercase staff email.
PersonNameSingle line textNoDisplay name at send time.
CourseIDNumberYesTT Courses.ID.
CourseNameSingle line textYesCourse title at send time.
TrainingRecordIDNumberNoTT TrainingRecords.ID.
ExpiryDateDate onlyYesThe date the course expires.
ReminderTypeChoice or single line textYesUse the values in section 1.
OverdueDaysNumberNoZero before/on expiry, positive after expiry.
SentOnDate and timeYesUse utcNow().
SentToSingle line textYesThe address actually used in the To field.
WouldHaveSentToSingle line textYesThe real staff address, even in test mode.
CcToSingle line textNoManager/admin escalation recipients actually used.
TestModeYes/NoYesTrue while testing.
LiveEmailsEnabledYes/NoYesSecond safety switch.
FlowRunNameSingle line textNoOptional 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.

Power Fx - App.OnStart addition

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.

Power Fx - App.StartScreen

3.3 Add Hidden Timer To scrMyTraining

Insert a Timer control on scrMyTraining, rename it tmrOpenReminderCourse, and set these properties.

PropertyValue
Duration500
AutoStartvarPendingCourseDeepLink
StartvarPendingCourseDeepLink
Repeatfalse
Visiblefalse

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.

Power Fx - tmrOpenReminderCourse.OnTimerEnd

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.

Nesting limit fix: do not create a separate 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.
Current Flow Position Full map
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

Where these actions go
Recurrence
└── Safety variables go here

Add these actions immediately after the recurrence trigger, in this order.

ActionNameTypeValue
Initialize variableTestModeBooleantrue
Initialize variableLiveEmailsEnabledBooleanfalse
Initialize variableTestModeRecipientStringYour email address.
Initialize variableAdminEscalationMailboxStringYour shared/admin mailbox.
Initialize variableAppDeepLinkBaseStringYour 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

Where these actions go
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 nameListFilter Query
Get Active PeopleTT PersonnelActive eq true
Get Active CoursesTT CoursesStatus/Value eq 'Active' and RecurrenceMonths gt 0
Get Training RecordsTT TrainingRecordsLeave blank.

4.3 Loop People And Applicable Courses

Where these actions go
Recurrence
└── Apply to each Person
    └── Apply to each Course
  1. Add Apply to each over value from Get Active People. Rename it Apply to each Person.
  2. Inside it, add another Apply to each. Use this expression for its input. Rename it Apply to each Course.
Power Automate expression - course loop input
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

Where these actions go
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.

Power Automate expression - Filter array advanced mode
@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.

  1. Click the small + directly underneath the Filter array action.
  2. Choose Add an action.
  3. Search for Condition, then select the built-in Control - Condition action.
  4. Open the condition's three-dot menu and rename it to Record exists. Do not use punctuation in Power Automate action names.
  5. Click the left value box, then choose the Expression tab in the dynamic-content panel.
  6. Paste this expression, then click Add.
Power Automate expression - Record exists left side
length(body('Filter_array'))
  1. Set the middle operator dropdown to is greater than.
  2. Type 0 into the right value box.
  3. 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

Where these actions go
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 nameExpression
Expiry DateaddToTime(first(body('Filter_array'))?['DateCompleted'], items('Apply_to_each_Course')?['RecurrenceMonths'], 'Month')
Expiry Date ShortformatDateTime(outputs('Expiry_Date'), 'yyyy-MM-dd')
Today ShortformatDateTime(utcNow(), 'yyyy-MM-dd')
Overdue Daysdiv(sub(ticks(formatDateTime(utcNow(), 'yyyy-MM-dd')), ticks(formatDateTime(outputs('Expiry_Date'), 'yyyy-MM-dd'))), 864000000000)
Reminder TypeUse 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.

Power Automate expression - Reminder Type
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'),
                    ''
                )
            )
        )
    )
)
Do not add a separate 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

Where these actions go
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 nameExpression
Deep Linkconcat(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 Keyconcat(toLower(items('Apply_to_each_Person')?['Email']), '|', items('Apply_to_each_Course')?['ID'], '|', outputs('Reminder_Type'), '|', outputs('Today_Short'))
Email Toif(or(equals(variables('TestMode'), true), not(equals(variables('LiveEmailsEnabled'), true))), variables('TestModeRecipient'), items('Apply_to_each_Person')?['Email'])
Email CCif(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 Subjectconcat(if(greater(outputs('Overdue_Days'), 0), 'Training overdue: ', 'Training reminder: '), items('Apply_to_each_Course')?['Title'])

4.7 Check The Locked Log Before Sending

Where this action goes
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.

Power Automate expression - Reminder log filter
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 fieldSet this
Left sideUse the expression below.
Operatoris equal to
Right sidetrue
Power Automate expression - Ready to send left side
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

Catch-up checkpoint if you built the earlier nested version:
  1. Open the Reminder due condition.
  2. Move these actions from Reminder due - True / If yes up one level so they sit directly inside Record exists - True / If yes: Deep Link, Reminder Key, Email To, Email CC, Email Subject, Get Existing Reminder Log, and the old Not already sent condition.
  3. Delete the now-empty Reminder due condition.
  4. Rename Not already sent to Ready to send.
  5. Open Ready to send and change the left side to the Ready to send left side expression from 4.7.
  6. Set the operator to is equal to and the right side to true.
  7. 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)
Where this action goes
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.

FieldValue
ToDynamic content: Outputs from the Email To Compose action. If using Expression instead, paste outputs('Email_To').
CcOpen Advanced parameters, add Cc, then use dynamic content: Outputs from Email CC. If using Expression instead, paste outputs('Email_CC').
SubjectDynamic content: Outputs from Email Subject. If using Expression instead, paste outputs('Email_Subject').
From / Send asOnly 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.
BodyPaste the HTML template below, then replace each placeholder using the exact mapping table under the template.
HTML email body 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.

If Power Automate only shows 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 HTMLPreferred token if it appearsUse this expression if you only see Current item
PERSON NAMETitle from Apply to each Personitems('Apply_to_each_Person')?['Title']
COURSE NAMETitle from Apply to each Courseitems('Apply_to_each_Course')?['Title']
EXPIRY DATEOutputs from Expiry Date Shortoutputs('Expiry_Date_Short')
REMINDER TYPEOutputs from Reminder Typeoutputs('Reminder_Type')
DEEP LINKOutputs from Deep Linkoutputs('Deep_Link')
Important: replace 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

Where this action goes
Ready to send
└── True / If yes
    ├── Send an email (V2)
    └── Create item in TT ReminderLog
If Power Automate creates any extra 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.

ColumnUse this expression or value
Titleoutputs('Reminder_Key')
ReminderKeyoutputs('Reminder_Key')
PersonEmailitems('Apply_to_each_Person')?['Email']
PersonNameitems('Apply_to_each_Person')?['Title']
CourseIDitems('Apply_to_each_Course')?['ID']
CourseNameitems('Apply_to_each_Course')?['Title']
TrainingRecordIDfirst(body('Filter_array'))?['ID']
ExpiryDateoutputs('Expiry_Date_Short')
ReminderTypeoutputs('Reminder_Type')
OverdueDaysoutputs('Overdue_Days')
SentOnutcNow()
SentTooutputs('Email_To')
WouldHaveSentToitems('Apply_to_each_Person')?['Email']
CcTooutputs('Email_CC')
TestModevariables('TestMode')
LiveEmailsEnabledvariables('LiveEmailsEnabled')

5. Testing Before Live

  1. Keep TestMode = true and LiveEmailsEnabled = false.
  2. Run the flow manually. Confirm every email arrives only in your inbox.
  3. Open TT ReminderLog as an admin. Confirm rows were created and SentTo is your address while WouldHaveSentTo shows the real staff address.
  4. Run the flow manually a second time on the same day. Confirm no duplicate emails are sent for the same ReminderKey.
  5. 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.
  6. Only after those checks pass, change TestMode to false. Leave LiveEmailsEnabled as false and run one more manual test. Emails must still go only to your test inbox.

6. Go Live

  1. Confirm the training data is populated enough that reminder emails will not surprise everyone.
  2. Confirm TT ReminderLog permissions are locked down and the flow connection account can still create log rows.
  3. Change the recurrence trigger start time from the far-future date to the real daily start time, for example tomorrow at 07:00.
  4. Set TestMode = false.
  5. Set LiveEmailsEnabled = true.
  6. 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

← Back to the main Training Tracker guide
Copied