Sending an “inactive account, will be marked dormant” email in FusionAuth
-
I want to email users after 55 days of inactivity, warn them they’ll be marked dormant in 5 days, and then mark them dormant.
Does FusionAuth do this out of the box?
-
Not as a single feature, but doable with some glue code. FusionAuth has no built-in “inactivity warning” workflow or scheduler for this.
It does give you the pieces: a lastLoginInstant on every user, a user search API, an email send API, and user deactivation. You wire them together with a small scheduled job (cron, Lambda, GitHub Action, etc.) outside FusionAuth.
Set up a daily job that does three things:
- Finds users whose last activity was about 55 days ago and who haven’t been warned yet.
- Sends them the warning email and records that you did.
- Finds users who are 60+ days inactive and marks them dormant.
If a user logs in during the 5-day window, their lastLoginInstant updates and they drop out of the step 3 query automatically.
Use the User Search API with an Elasticsearch/OpenSearch query on lastLoginInstant (milliseconds since epoch). This needs the Elasticsearch/OpenSearch search engine. The database search engine doesn’t support range queries on this field.
POST /api/user/search { "search": { "numberOfResults": 500, "query": "{\"bool\":{\"must\":[{\"range\":{\"lastLoginInstant\":{\"lt\":1760000000000}}}],\"must_not\":[{\"term\":{\"data.dormantWarningSent\":true}}]}}" } }Compute the lt value in your job as now - 55 days (so replace
1760000000000). Users who have never logged in won’t have alastLoginInstant, so decide whether to fall back toinsertInstantfor them.You can use FusionAuth to send the email or your own system. If you choose the former, create an email template in
Customizations → Email Templates, then call theSend Email APIwith the matching user IDs:POST /api/email/send/{emailTemplateId} { "userIds": ["<id1>", "<id2>"], "requestData": { "daysInactive": 55, "daysUntilDormant": 5 } }Your template body can then use FreeMarker:
Hi ${user.firstName!"there"}, As part of our routine account security measures, we monitor accounts for inactivity. We haven't detected activity on your account in the last ${daysInactive} days, and it is scheduled to be marked dormant in ${daysUntilDormant} days unless activity is detected. Log in to keep your account active: https://login.example.comrequestData values are available at the top level of the template, so you can reuse the template if you change the thresholds later.
After a successful send, patch the user to record that they've been notified so you don't send them multiple emails:
PATCH /api/user/{userId} { "user": { "data": { "dormantWarningSent": true, "dormantWarningSentAt": 1760000000000 } } }The
must_notclause in the search query above then skips them. If the user logs in, clear the flag so they can be warned again in the future. Auser.login.successwebhook receiver that uses the API is a clean place to do that.As far as what you actually do when the user is marked dormant, you have two options:
- Deactivate the user.
DELETE /api/user/{id}(soft delete) setsactive=false, which blocks login. To reactivate, callPUT /api/user/{id}?reactivate=true. Deactivation also fires auser.deactivateevent that you can consume via webhook to notify downstream systems. - Flag only. Set
user.data.dormant = trueand handle it in your app where you might do a step-up or force an account recovery. This is gentler and keeps the account usable after a step-up.
For a true “close the account” outcome, deactivate first. Hard-delete (
?hardDelete=true) only after a retention period that satisfies your compliance requirements.Caveats:
lastLoginInstantupdates when a user authenticates. Silent token refreshes using an existing refresh token don’t necessarily count as logins, so a user with a long-lived session in your app could look inactive. If that matters, track your own activity timestamp inuser.data(updated by your app or a webhook) and query that instead. Test this against your own setup before relying on it.user.lastLoginInstantis global to the user within a tenant. Each registration also has its ownlastLoginInstant(user.registrations[].lastLoginInstant). If “inactive” should mean inactive in a specific application, query on that field and filter byapplicationId.Run the job daily and use a window, not an exact day match, so a missed run doesn’t skip anyone.
Page through search results (startRow) rather than assuming one page covers everyone.
Test with a user whose lastLoginInstant you’ve backdated in a non-production tenant.
Make sure your Send Email API key has only the endpoints it needs (search, send email, patch user, deactivate).
-
D dan marked this topic as a regular topic
-
D dan marked this topic as a question
-
D dan has marked this topic as solved