Repository: CSCfi/rems
Pull request #2919 (2022-04-21 13:23:24)

Send notification emails before application expiration
relates #2906 

- change application expiration config to accept map per application state with keys `#{:delete-after :reminder-before}` using ISO-8601 durations
- create new command `:application.command/send-expiration-notifications` that creates event `:application.event/expiration-notifications-sent`
- create ns `rems.application.expirer-bot` which implements generating application delete and expiritaion notifications send command logic. expirer-bot is triggered by external event from `rems.application.eraser/process-applications!`
- user `:expirer` role must be granted to `expirer-bot` user prior to enabling the feature. the role is granted permissions to delete and send expiration notifications in application creation

This PR changes the logic of application eraser so that user `expirer-bot` executes commands to delete and send expiration notification emails. `expirer-bot` is triggered externally by eraser process, but it's behaviour is configurable. Sending reminder emails is optional, and it is configured with ISO-8601 duration, defined as time period before expiration. Only one reminder email is sent before expiration. If application is edited before expiration, both timers are reset.

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue

## Backwards compatibility
- [ ] Events are backwards compatible _or_ have migrations
- [ ] Config is backwards compatible (expiration notification change is breaking)

## Documentation
- [x] Update changelog if necessary
- [x] Update docs/ (if applicable)
- [x] New config options in config-defaults.edn

## Testing
- [x] Complex logic is unit tested
- [x] Valuable features are integration / browser / acceptance tested automatically


aatkin: quickly discussed over rocket chat about using bots instead of the db table - will look into this so there wouldn't be need for a migration

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5f06e992..df4339c05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,12 +12,15 @@ Changes since v2.25
 
 NB: The login has changed to allow more configurable user identity and other attributes. Consider this a big change worth some manual testing to see that everything works.
 
+NB: New feature, reminder email for application expiration uses new email template. If you enable the feature and you use customized email templates, please check `:t.email.application-expiration-notification/subject-to-member` and `:t.email.application-expiration-notification/message-to-member`.
+
 ### Breaking changes
 - The actor of the command API (for applications) is now always validated. Previously, there was a chance that a non-existent user could be sent (used mostly by our internal tests). (#2771, #2824, #2772, #2821)
 - User attributes are not saved on every request, only when logging in. (#2829)
 - The `:oidc-userid-attribute` config has been renamed to `:oidc-userid-attributes` and has new options to allow internally renaming an attribute from IdP to REMS db. (#2771, #2821)
 - Users are required a name and email from the IdP to be allowed in. (#2889)
 - User attributes have been renamed internally. If you directly accessed the database, please note that `eppn -> userid`, `commonName -> name` and `mail -> email`. (#2377)
+- Application expiration notification can now be configured to send reminder email to applicant and members before expiration. This requires a change to the configuration option `:application-expiration`. Please read `docs/bots.md` section on Expirer bot. (#2906)
 
 ### Additions
 - You can configure the OIDC attributes for name and email (see configuration.md)
diff --git a/docs/application-permissions.md b/docs/application-permissions.md
index f50f2a23d..876fabdbb 100644
--- a/docs/application-permissions.md
+++ b/docs/application-permissions.md
@@ -1,3 +1,3 @@
 # Application Permissions Reference
 
-<table border="1"><tr><th>State \ Role</th><th>applicant</th><th>decider</th><th>everyone‑else</th><th>handler</th><th>member</th><th>past‑decider</th><th>past‑reviewer</th><th>reporter</th><th>reviewer</th></tr><tr><th valign="top">approved</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑member</div><div>change‑resources</div><div>close</div><div>invite‑member</div><div>remark</div><div>remove‑member</div><div>revoke</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">closed</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">draft</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>copy‑as‑new</div><div>delete</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: handler --></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --></td><td valign="top"><!-- role: past-reviewer --></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --></td></tr><tr><th valign="top">rejected</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">returned</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>close</div><div>copy‑as‑new</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>remark</div><div>remove‑member</div><div>request‑review</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">revoked</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">submitted</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>approve</div><div>decide</div><div>reject</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>approve</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>reject</div><div>remark</div><div>remove‑member</div><div>request‑decision</div><div>request‑review</div><div>return</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr></table>
\ No newline at end of file
+<table border="1"><tr><th>State \ Role</th><th>applicant</th><th>decider</th><th>everyone‑else</th><th>expirer</th><th>handler</th><th>member</th><th>past‑decider</th><th>past‑reviewer</th><th>reporter</th><th>reviewer</th></tr><tr><th valign="top">approved</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑member</div><div>change‑resources</div><div>close</div><div>invite‑member</div><div>remark</div><div>remove‑member</div><div>revoke</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">closed</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">draft</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>copy‑as‑new</div><div>delete</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --></td><td valign="top"><!-- role: past-reviewer --></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --></td></tr><tr><th valign="top">rejected</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">returned</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>close</div><div>copy‑as‑new</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>remark</div><div>remove‑member</div><div>request‑review</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">revoked</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">submitted</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>approve</div><div>decide</div><div>reject</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>approve</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>reject</div><div>remark</div><div>remove‑member</div><div>request‑decision</div><div>request‑review</div><div>return</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr></table>
\ No newline at end of file
diff --git a/docs/bots.md b/docs/bots.md
index 94426a1ba..d4808b4b1 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -55,6 +55,40 @@ Example of creating the bot user with the API.
 curl -X POST -H "content-type: application/json" -H "x-rems-api-key: 42" -H "x-rems-user-id: owner" http://localhost:3000/api/users/create --data '{"userid": "rejecter-bot", "name": "Rejecter Bot", "email": null}'
 ```
 
+## Expirer bot
+
+User id: `expirer-bot`
+
+The Expirer bot is used to remove applications and send notification email to
+application members about impending expiration. Expirations can be configured
+by application state for both application removal and sending reminder email.
+
+Unlike other bots, the Expirer bot is designed to be triggered by an external
+event, such as a periodically ran process. The bot is supplied an application
+and it returns appropriate command description, if applicable. Responsibility
+of the command execution is left to the caller. A scheduler is implemented in
+REMS, enabled by config option `:application-expiration` to periodically call
+the Expirer bot. Expirer Bot observes applications' last activity and decides
+if application should be deleted or notification emails sent. If the activity
+timestamp is updated, users may potentially receive new notification email in
+the future.
+
+Depending on the application state and config, the Expirer bot can return one
+of two commands:
+- `application.command/delete` deletes application immediately, and
+- `application.command/send-expiration-notifications` sends emails to members
+
+NB: `:expirer` role must be granted to Expirer bot. This can be done via CLI,
+using the command `grant-role`.
+
+NB: currently only draft application removal is supported by REMS.
+
+Example of creating the bot user with the API.
+
+```sh
+curl -X POST -H "content-type: application/json" -H "x-rems-api-key: 42" -H "x-rems-user-id: owner" http://localhost:3000/api/users/create --data '{"userid": "expirer-bot", "name": "Expirer Bot", "email": null}'
+```
+
 ## Bona Fide bot
 
 User id: `bona-fide-bot`
diff --git a/resources/config-defaults.edn b/resources/config-defaults.edn
index 8f76c1877..2529df60b 100644
--- a/resources/config-defaults.edn
+++ b/resources/config-defaults.edn
@@ -338,13 +338,18 @@
  ;; nil = no limit
  :attachment-max-size 10000000
 
- ;; Should applications be deleted after a set period of time has passed?
- ;; Use map with key as required application state, and value as ISO-8601 duration:
- ;; {:application.state/draft "P90D" :application.state/closed "P7D"}
+ ;; Should applications be deleted after having been inactive for a set period of time?
+ ;; Use map with key as required application state, and value as a map:
+ ;;   {:application.state/draft {:delete-after "P90D" :reminder-before "P7D"}}
+ ;;   :delete-after - delete application after period of time since last activity
+ ;;   :reminder-before - (optional) send reminder about expiration to application members (as period of time before expiration)
  ;; nil = feature not enabled.
+ ;; NB: currently :application.state/draft is the only supported application state
+ ;; NB: expirer-bot user must be created before enabling application expiration. see docs/bots.md for more information
  ;; https://en.wikipedia.org/wiki/ISO_8601#Durations for duration formatting reference.
  :application-expiration nil
 
+
  ;; Experimental features
 
  ;; enable /api/applications/:id/experimental/pdf
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index da178ab95..d36b0b793 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -202,6 +202,7 @@
                           :draft-saved "%1 saved a draft."
                           ;; %2 - external id
                           :external-id-assigned "%1 assigned an external id %2."
+                          :expiration-notifications-sent "%1 sent expiration notification email."
                           :licenses-accepted "%1 accepted the terms of use."
                           :licenses-added "%1 added terms of use."
                           ;; %2 - member name
@@ -389,6 +390,8 @@
                                :message-to-applicant "Dear %1,\n\nYour application %3 has been closed.\n\nYou can still view the application at %6"
                                :subject-to-handler "(%3) Application has been closed"
                                :message-to-handler "Dear %1,\n\n%2 has closed the application %3 from %4.\n\nYou can view the application at %6"}
+          :application-expiration-notification {:subject-to-member "Your unsubmitted application %2 will be deleted soon"
+                                                :message-to-member "Dear %1,\n\nYour unsubmitted application has been inactive since %3 and it will be deleted after %4, if it is not edited.\n\nYou can view and edit the application at %5"}
           :application-returned {:subject-to-applicant "Your application %3 needs to be amended"
                                  :message-to-applicant "Dear %1,\n\nYour application %3 has been returned for your consideration. Please, amend according to requests and resubmit.\n\nYou can view and edit the application at %6"
                                  :subject-to-handler "(%3) Application has been returned to applicant"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index 8d4ffa065..c1f3283c6 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -190,6 +190,7 @@
                           :deleted "%1 poisti luonnoksen."
                           :draft-saved "%1 tallensi luonnoksen."
                           :external-id-assigned "%1 lisäsi tunnisteen %2."
+                          :expiration-notifications-sent "%1 lähetti sähköpostimuistutuksen vanhentuvasta hakemuksesta."
                           :licenses-accepted "%1 hyväksyi käyttöehdot."
                           :licenses-added "%1 lisäsi käyttöehtoja."
                           :member-added "%1 lisäsi käyttäjän %2 hakemuksen jäseneksi."
@@ -357,6 +358,8 @@
                                :message-to-applicant "Hyvä %1,\n\nHakemuksesi %3 on suljettu.\n\nVoit yhä tarkastella hakemusta: %6"
                                :subject-to-handler "(%3) Hakemus on suljettu"
                                :message-to-handler "Hyvä %1,\n\n%2 on sulkenut käyttäjän %4 hakemuksen %3.\n\nVoit tarkastella hakemusta: %6"}
+          :application-expiration-notification {:subject-to-member "Lähettämätön hakemuksesi %2 poistetaan pian"
+                                                :message-to-member "Hyvä %1,\n\nLähettämätön hakemuksesi on ollut passiivisena %3 lähtien ja se poistetaan %4 jälkeen, jos sitä ei muokata. \n\nVoit tarkastella ja muokata hakemusta osoitteessa %5"}
           :application-returned {:subject-to-applicant "Hakemuksesi %3 vaatii täydentämistä"
                                  :message-to-applicant "Hyvä %1,\n\nHakemuksesi %3 on palautettu muokattavaksesi. Ole hyvä ja täydennä hakemusta kommenttien mukaan.\n\nVoit tarkastella ja muokata hakemusta osoitteessa %6"
                                  :subject-to-handler "(%3) Hakemus on palautettu"
diff --git a/resources/translations/sv.edn b/resources/translations/sv.edn
index e453ef2d9..703a65df2 100644
--- a/resources/translations/sv.edn
+++ b/resources/translations/sv.edn
@@ -190,6 +190,7 @@
                           :deleted "%1 raderade utkastet."
                           :draft-saved "%1 sparade ett utkast."
                           :external-id-assigned "%1 lade till externt id %2."
+                          :expiration-notifications-sent "%1 skickade e-post p.g.a. inaktiv ansökan."
                           :licenses-accepted "%1 accepterade licenserna."
                           :licenses-added "%1 lade till licenser."
                           :member-added "%1 lade till %2 som deltagare."
@@ -357,6 +358,8 @@
                                :message-to-applicant "%1,\n\nDin ansökan %3 har stängts.\n\nDu kan granska ansökningen här: %6"
                                :subject-to-handler "Ansökan (%3)har stängts"
                                :message-to-handler "%1,\n\n%2 har stängt ansökan %3 från %4.\n\nDu kan granska ansökningen här: %6"}
+          :application-expiration-notification {:subject-to-member "Din ej inlämnad ansökan %2 kommer att raderas snart"
+                                                :message-to-member "%1,\n\nDin ej inlämnad ansökan har varit inaktiv sedan %3 och kommer att raderas efter %4, om det inte ändras. \n\nDu kan granska ansökningen här: %5"}
           :application-returned {:subject-to-applicant "Din ansökan %3 kräver komplettering"
                                  :message-to-applicant "%1,\n\nDin ansökan %3 har skickats tillbaka för komplettering. Var god och komplettera den enligt anvisningarna.\n\nDu kan granska ansökningen här: %6"
                                  :subject-to-handler "(%3) Ansökan returnerad"
diff --git a/src/clj/rems/api/services/todos.clj b/src/clj/rems/api/services/todos.clj
index 2f457606b..0562c2fde 100644
--- a/src/clj/rems/api/services/todos.clj
+++ b/src/clj/rems/api/services/todos.clj
@@ -54,7 +54,8 @@
           :application.command/remove-member
           :application.command/uninvite-member
           ;; remarks can be made without a request, also on handled todos
-          :application.command/remark}
+          :application.command/remark
+          :application.command/send-expiration-notifications}
         all-commands (set (keys commands/command-schemas))]
 
     ;; This test is to make sure that as new commands are added,
diff --git a/src/clj/rems/application/commands.clj b/src/clj/rems/application/commands.clj
index d3a5d7fe1..892bbeaa8 100644
--- a/src/clj/rems/application/commands.clj
+++ b/src/clj/rems/application/commands.clj
@@ -102,6 +102,10 @@
                          :field schema-base/FieldId
                          :value schema-base/FieldValue}]
          (s/optional-key :duo-codes) [schema-base/DuoCode]))
+(s/defschema SendExpirationNotificationsCommand
+  (assoc CommandBase
+         :last-activity DateTime
+         :expires-on DateTime))
 (s/defschema SubmitCommand
   CommandBase)
 (s/defschema DeleteCommand
@@ -137,6 +141,7 @@
    :application.command/review ReviewCommand
    :application.command/revoke RevokeCommand
    :application.command/save-draft SaveDraftCommand
+   :application.command/send-expiration-notifications SendExpirationNotificationsCommand
    :application.command/submit SubmitCommand
    :application.command/uninvite-member UninviteMemberCommand})
 
@@ -645,6 +650,17 @@
   [cmd application injections]
   (ok {:event/type :application.event/deleted}))
 
+(defn- invalid-expiration-error [cmd]
+  (when (time/before? (:expires-on cmd) (:last-activity cmd))
+    {:error [{:type :invalid-expiration}]}))
+
+(defmethod command-handler :application.command/send-expiration-notifications
+  [cmd _application _injections]
+  (or (invalid-expiration-error cmd)
+      (ok {:event/type :application.event/expiration-notifications-sent
+           :last-activity (:last-activity cmd)
+           :expires-on (:expires-on cmd)})))
+
 (defn- add-common-event-fields-from-command [event cmd]
   (-> event
       (update :application/id (fn [app-id]
diff --git a/src/clj/rems/application/eraser.clj b/src/clj/rems/application/eraser.clj
index aa367ca3f..717aa6862 100644
--- a/src/clj/rems/application/eraser.clj
+++ b/src/clj/rems/application/eraser.clj
@@ -1,66 +1,46 @@
 (ns rems.application.eraser
-  (:require [mount.core :as mount]
+  (:require [clojure.tools.logging :as log]
             [clj-time.core :as time]
-            [clojure.test :refer :all]
-            [clojure.tools.logging :as log]
-            [rems.scheduler :as scheduler]
+            [mount.core :as mount]
+            [rems.api.services.command :as command]
+            [rems.application.expirer-bot :as expirer-bot]
+            [rems.config :refer [env]]
             [rems.db.applications :as applications]
-            [rems.config :refer [env]])
-  (:import [org.joda.time Period]))
+            [rems.db.users :as users]
+            [rems.scheduler :as scheduler]))
 
-(defn- is-expired?
-  [application]
-  (let [last-activity (:application/last-activity application)
-        state (:application/state application)
-        expiration-threshold (some-> env :application-expiration state Period/parse)]
-    (if expiration-threshold
-      (time/before? last-activity (time/minus (time/now) expiration-threshold))
-      false)))
-
-(deftest test-is-expired
-  (with-redefs [env {:application-expiration {:application.state/draft "P90D"
-                                              :application.state/closed "P7D"}}]
-    (testing "should identify expired application by state"
-      (let [over-90d-ago (time/minus (time/now) (time/days 90) (time/seconds 1))
-            over-7d-ago (time/minus (time/now) (time/days 7) (time/seconds 1))
-            over-1d-ago (time/minus (time/now) (time/days 1) (time/seconds 1))]
-        (is (is-expired? {:application/state :application.state/draft
-                          :application/last-activity over-90d-ago}))
-        (is (not (is-expired? {:application/state :application.state/draft
-                               :application/last-activity over-7d-ago})))
-        (is (is-expired? {:application/state :application.state/closed
-                          :application/last-activity over-7d-ago}))
-        (is (not (is-expired? {:application/state :application.state/closed
-                               :application/last-activity over-1d-ago})))
-        (is (not (is-expired? {:application/state :application.state/rejected
-                               :application/last-activity over-90d-ago})))))))
-
-(defn remove-expired-applications!
-  []
-  (log/info :start #'remove-expired-applications!)
-  (doseq [app-id (->> (applications/get-all-unrestricted-applications)
-                      (filter is-expired?)
-                      (map :application/id))]
-    (try
-      (applications/delete-application! app-id)
-      (log/info "Succesfully removed application" app-id)
-      (catch Throwable t
-        (log/error "Failed to remove application" app-id ":" t))))
+(defn process-applications! []
+  (log/info :start #'process-applications!)
+  ; check that bot user exists, else log missing
+  (if (users/user-exists? expirer-bot/bot-userid)
+    (doseq [application (applications/get-all-unrestricted-applications)]
+      (when-some [cmd (expirer-bot/run-expirer-bot application)]
+        (command/command! cmd)))
+    (log/warnf "Cannot process applications, because user %s does not exist"
+               expirer-bot/bot-userid))
   (applications/reload-cache!)
-  (log/info :finish #'remove-expired-applications!))
+  (log/info :finish #'process-applications!))
 
 (mount/defstate expired-application-poller
   :start (when (:application-expiration env)
-           (scheduler/start! "expired-application-poller" remove-expired-applications! (.toStandardDuration (time/days 1))))
+           (scheduler/start! "expired-application-poller"
+                             process-applications!
+                             (.toStandardDuration (time/days 1))))
   :stop (when expired-application-poller
           (scheduler/stop! expired-application-poller)))
 
 (comment
-  (mount/defstate expired-application-poller-test
-    :start (scheduler/start! "expired-application-poller-test"
-                             (fn [] (with-redefs [env {:application-expiration {:application.state/draft "P90D"}}]
-                                      (remove-expired-applications!))) (.toStandardDuration (time/seconds 10)))
-    :stop (scheduler/stop! expired-application-poller-test))
+  (let [config {:application.state/draft {:delete-after "P1D"
+                                          :reminder-before "P1D"}}]
+    (mount/defstate expired-application-poller-test
+      :start (scheduler/start! "expired-application-poller-test"
+                               (fn [] (with-redefs [env (assoc env
+                                                               :application-expiration
+                                                               config)]
+                                        (process-applications!)))
+                               (.toStandardDuration (time/seconds 10)))
+      :stop (scheduler/stop! expired-application-poller-test)))
+
   (mount/start #{#'expired-application-poller-test})
   (mount/stop #{#'expired-application-poller-test}))
 
diff --git a/src/clj/rems/application/events.clj b/src/clj/rems/application/events.clj
index 71b67cb32..6c1ea47a4 100644
--- a/src/clj/rems/application/events.clj
+++ b/src/clj/rems/application/events.clj
@@ -81,6 +81,11 @@
   (assoc schema-base/EventBase
          :event/type (s/enum :application.event/external-id-assigned)
          :application/external-id s/Str))
+(s/defschema ExpirationNotificationsSentEvent
+  (assoc schema-base/EventBase
+         :event/type (s/enum :application.event/expiration-notifications-sent)
+         :last-activity DateTime
+         :expires-on DateTime))
 (s/defschema LicensesAcceptedEvent
   (assoc schema-base/EventBase
          :event/type (s/enum :application.event/licenses-accepted)
@@ -180,6 +185,7 @@
    :application.event/deleted DeletedEvent
    :application.event/draft-saved DraftSavedEvent
    :application.event/external-id-assigned ExternalIdAssignedEvent
+   :application.event/expiration-notifications-sent ExpirationNotificationsSentEvent
    :application.event/licenses-accepted LicensesAcceptedEvent
    :application.event/licenses-added LicensesAddedEvent
    :application.event/member-added MemberAddedEvent
diff --git a/src/clj/rems/application/expirer_bot.clj b/src/clj/rems/application/expirer_bot.clj
new file mode 100644
index 000000000..e555f766e
--- /dev/null
+++ b/src/clj/rems/application/expirer_bot.clj
@@ -0,0 +1,105 @@
+(ns rems.application.expirer-bot
+  "A bot that sends notification to application members when
+   application is about to expire.
+
+   See also: docs/bots.md"
+  (:require [clojure.test :refer [deftest testing is]]
+            [clj-time.core :as time]
+            [rems.config :refer [env]])
+  (:import [org.joda.time Period]))
+
+(def bot-userid "expirer-bot")
+
+(defn- get-last-activity [application]
+  (let [last-event (last (:application/events application))]
+    (if (= (:event/type last-event)
+           :application.event/expiration-notifications-sent)
+      (:last-activity last-event) ; because events update :application/last-activity timestamp
+      (:application/last-activity application))))
+
+(defn- expiration-time [application]
+  (when-some [delete-after (get-in env [:application-expiration
+                                        (:application/state application)
+                                        :delete-after])]
+    (-> (get-last-activity application)
+        (time/plus (Period/parse delete-after)))))
+
+(defn- expire-application [application]
+  (when (some-> (expiration-time application)
+                (time/before? (time/now)))
+    {:type :application.command/delete
+     :time (time/now)
+     :actor bot-userid
+     :application-id (:application/id application)}))
+
+(deftest test-expire-application
+  (with-redefs [env {:application-expiration {:application.state/draft {:delete-after "P90D"}
+                                              :application.state/closed {:delete-after "P7D"}}}]
+    (testing "should identify expired application by state"
+      (let [over-90d-ago (time/minus (time/now) (time/days 90) (time/seconds 1))
+            over-7d-ago (time/minus (time/now) (time/days 7) (time/seconds 1))
+            over-1d-ago (time/minus (time/now) (time/days 1) (time/seconds 1))]
+        (is (some? (expire-application {:application/state :application.state/draft
+                                        :application/last-activity over-90d-ago})))
+        (is (not (expire-application {:application/state :application.state/draft
+                                      :application/last-activity over-7d-ago})))
+        (is (some? (expire-application {:application/state :application.state/closed
+                                        :application/last-activity over-7d-ago})))
+        (is (not (expire-application {:application/state :application.state/closed
+                                      :application/last-activity over-1d-ago})))
+        (is (not (expire-application {:application/state :application.state/rejected
+                                      :application/last-activity over-90d-ago})))))))
+
+(defn- reminder-time [application]
+  (let [expiration (get-in env [:application-expiration
+                                (:application/state application)])
+        delete-after (:delete-after expiration)
+        reminder-before (:reminder-before expiration)]
+    (when (and delete-after reminder-before)
+      (-> (:application/last-activity application)
+          (time/plus (Period/parse delete-after))
+          (time/minus (Period/parse reminder-before))))))
+
+(deftest test-reminder-time
+  (with-redefs [env {:application-expiration {:application.state/draft {:delete-after "P90D"
+                                                                        :reminder-before "P7D"}
+                                              :application.state/closed {:delete-after "P7D"}
+                                              :application.state/rejected {:reminder-before "P7D"}}}]
+    (let [now (time/now)]
+      (testing "should return reminder time when config is valid"
+        (is (time/equal? (time/plus now (time/days 83))
+                         (reminder-time {:application/state :application.state/draft
+                                         :application/last-activity now})))
+        (is (time/equal? now
+                         (reminder-time {:application/state :application.state/draft
+                                         :application/last-activity (time/minus now (time/days 83))})))
+        (is (time/equal? (time/minus now (time/days 1))
+                         (reminder-time {:application/state :application.state/draft
+                                         :application/last-activity (time/minus now (time/days 84))})))
+        (doseq [state [:application.state/closed
+                       :application.state/rejected
+                       :application.state/submitted]]
+          (is (nil? (reminder-time {:application/state state
+                                    :application/last-activity now}))
+              (str state " returns nil because configuration is missing")))))))
+
+(defn- should-send-email [application]
+  (let [last-event-type (-> application :application/events last :event/type)]
+    (and (not= last-event-type
+               :application.event/expiration-notifications-sent)
+         (some-> (reminder-time application)
+                 (time/before? (time/now))))))
+
+(defn- send-expiration-notifications [application]
+  (when (should-send-email application)
+    {:type :application.command/send-expiration-notifications
+     :time (time/now)
+     :actor bot-userid
+     :application-id (:application/id application)
+     :last-activity (:application/last-activity application)
+     :expires-on (expiration-time application)}))
+
+(defn run-expirer-bot [application]
+  (or (expire-application application)
+      (send-expiration-notifications application)))
+
diff --git a/src/clj/rems/application/master_workflow.clj b/src/clj/rems/application/master_workflow.clj
index 8148fec7b..555e735c7 100644
--- a/src/clj/rems/application/master_workflow.clj
+++ b/src/clj/rems/application/master_workflow.clj
@@ -67,6 +67,8 @@
    :member #{:application.command/accept-licenses
              :application.command/copy-as-new}
    :reporter #{:see-everything}
+   :expirer #{:application.command/delete
+              :application.command/send-expiration-notifications}
    ;; member before accepting an invitation
    :everyone-else #{:application.command/accept-invitation}})
 
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index 8b88372a8..8e06e4ac2 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -257,6 +257,10 @@
   [application _event]
   application)
 
+(defmethod application-base-view :application.event/expiration-notifications-sent
+  [application _event]
+  application)
+
 (deftest test-event-type-specific-application-view
   (testing "supports all event types"
     (is (= (set (keys events/event-schemas))
@@ -303,7 +307,9 @@
     {:permission :application.command/submit}
     {:permission :application.command/uninvite-member}
     {:role :handler :permission :application.command/approve}
-    {:role :handler :permission :application.command/reject}]))
+    {:role :handler :permission :application.command/reject}
+    {:role :expirer :permission :application.command/delete}
+    {:role :expirer :permission :application.command/send-expiration-notifications}]))
 
 (def decider-workflow
   (permissions/compile-rules
@@ -332,7 +338,9 @@
     {:permission :application.command/submit}
     {:permission :application.command/uninvite-member}
     {:role :decider :permission :application.command/approve}
-    {:role :decider :permission :application.command/reject}]))
+    {:role :decider :permission :application.command/reject}
+    {:role :expirer :permission :application.command/delete}
+    {:role :expirer :permission :application.command/send-expiration-notifications}]))
 
 (defn- application-permissions-for-workflow-view [application event]
   (let [whitelist (case (get-in application [:application/workflow :workflow/type])
@@ -613,7 +621,8 @@
 
 (defn- enrich-super-users [application get-users-with-role]
   (-> application
-      (permissions/give-role-to-users :reporter (get-users-with-role :reporter))))
+      (permissions/give-role-to-users :reporter (get-users-with-role :reporter))
+      (permissions/give-role-to-users :expirer (get-users-with-role :expirer))))
 
 (defn add-answers [application current-answers previous-answers]
   (transform [:application/forms ALL] #(form/enrich-form-answers % current-answers previous-answers) application))
@@ -703,6 +712,7 @@
                         :application.event/deleted
                         :application.event/draft-saved
                         :application.event/external-id-assigned
+                        :application.event/expiration-notifications-sent
                         :application.event/licenses-accepted
                         :application.event/licenses-added
                         :application.event/member-added
diff --git a/src/clj/rems/db/roles.clj b/src/clj/rems/db/roles.clj
index 457cbbb5b..53ca2e929 100644
--- a/src/clj/rems/db/roles.clj
+++ b/src/clj/rems/db/roles.clj
@@ -1,10 +1,9 @@
 (ns rems.db.roles
-  (:require [rems.common.util :refer [getx]]
-            [rems.db.core :as db]
+  (:require [rems.db.core :as db]
             [rems.util :refer [errorf]]))
 
 ;; The roles that are set for users in the database instead of setting them dynamically.
-(def ^:private +db-roles+ #{:owner :reporter :user-owner})
+(def ^:private +db-roles+ #{:owner :reporter :user-owner :expirer})
 
 (defn- role-from-db [{role-string :role}]
   (let [role (keyword role-string)]
diff --git a/src/clj/rems/email/template.clj b/src/clj/rems/email/template.clj
index d23897859..ab8dbc0d2 100644
--- a/src/clj/rems/email/template.clj
+++ b/src/clj/rems/email/template.clj
@@ -5,7 +5,7 @@
             [rems.config :refer [env]]
             [rems.context :as context]
             [rems.db.user-settings :as user-settings]
-            [rems.text :refer [text text-format with-language]]
+            [rems.text :refer [localize-utc-date text text-format with-language]]
             [rems.util :as util]))
 
 ;;; Mapping events to emails
@@ -246,6 +246,28 @@
                                 :t.email.applicant-changed/subject-to-handler
                                 :t.email.applicant-changed/message-to-handler)))
 
+(defmethod event-to-emails :application.event/expiration-notifications-sent [event application]
+  (vec
+   (for [recipient (application-util/applicant-and-members application)]
+     (with-language (:language (user-settings/get-user-settings (:userid recipient)))
+       (fn []
+         {:to-user (:userid recipient)
+          :subject (text-format :t.email.application-expiration-notification/subject-to-member
+                                (application-util/get-member-name recipient)
+                                (format-application-for-email application)
+                                (localize-utc-date (:last-activity event))
+                                (localize-utc-date (:expires-on event))
+                                (link-to-application (:application/id application)))
+          :body (str
+                 (text-format :t.email.application-expiration-notification/message-to-member
+                              (application-util/get-member-name recipient)
+                              (format-application-for-email application)
+                              (localize-utc-date (:last-activity event))
+                              (localize-utc-date (:expires-on event))
+                              (link-to-application (:application/id application)))
+                 (text :t.email/regards)
+                 (text :t.email/footer))})))))
+
 ;; TODO member-joined?
 
 (defn handler-reminder-email [lang handler applications]
@@ -300,3 +322,4 @@
                              (invitation-link (:invitation/token invitation)))
                 (text :t.email/regards)
                 (text :t.email/footer))}))))
+
diff --git a/src/cljc/rems/text.cljc b/src/cljc/rems/text.cljc
index dffa8b4a7..30f928d2e 100644
--- a/src/cljc/rems/text.cljc
+++ b/src/cljc/rems/text.cljc
@@ -133,6 +133,7 @@
    :application.event/deleted :t.applications.events/deleted
    :application.event/draft-saved :t.applications.events/draft-saved
    :application.event/external-id-assigned :t.applications.events/external-id-assigned
+   :application.event/expiration-notifications-sent :t.applications.events/expiration-notifications-sent
    :application.event/licenses-accepted :t.applications.events/licenses-accepted
    :application.event/licenses-added :t.applications.events/licenses-added
    :application.event/member-added :t.applications.events/member-added
@@ -209,4 +210,4 @@
        (when-let [end (:entitlement/end event)]
          (str " " (text-format :t.applications/entitlement-end (localize-utc-date end))))
 
-       nil))))
\ No newline at end of file
+       nil))))


Test patch:
diff --git a/src/clj/rems/db/test_data.clj b/src/clj/rems/db/test_data.clj
index e0bb4efd6..046353a61 100644
--- a/src/clj/rems/db/test_data.clj
+++ b/src/clj/rems/db/test_data.clj
@@ -37,7 +37,8 @@
 
 (defn create-bots! []
   (doseq [attr (vals +bot-user-data+)]
-    (create-user! attr)))
+    (create-user! attr))
+  (roles/add-role! (:expirer-bot +bot-users+) :expirer))
 
 (defn- create-archived-form! [actor]
   (with-user actor
@@ -472,7 +473,10 @@
                  :application-id app-id
                  :actor approver
                  :deciders [reviewer]
-                 :comment ""}))))
+                 :comment ""}))
+
+    (->> (time/minus (time/now) (time/days 84))
+         (create-draft! applicant [catid] "long forgotten draft"))))
 
 (defn- range-1
   "Like `clojure.core/range`, but starts from 1 and `end` is inclusive."
diff --git a/src/clj/rems/db/test_data_helpers.clj b/src/clj/rems/db/test_data_helpers.clj
index 1456dcd6d..92c185e39 100644
--- a/src/clj/rems/db/test_data_helpers.clj
+++ b/src/clj/rems/db/test_data_helpers.clj
@@ -205,10 +205,11 @@
                               :catalogue-item-ids (or catalogue-item-ids [(create-catalogue-item! {})])
                               :actor actor})))
 
-(defn submit-application [app-id applicant]
-  (command! {:type :application.command/submit
-             :application-id app-id
-             :actor applicant}))
+(defn submit-application [{:keys [application-id actor time]}]
+  (command! {:time (or time (time/now))
+             :type :application.command/submit
+             :application-id application-id
+             :actor actor}))
 
 (defn- base-command [{:keys [application-id actor time]}]
   (assert application-id)
diff --git a/src/clj/rems/db/test_data_users.clj b/src/clj/rems/db/test_data_users.clj
index 1c1f9eb54..054455101 100644
--- a/src/clj/rems/db/test_data_users.clj
+++ b/src/clj/rems/db/test_data_users.clj
@@ -2,17 +2,20 @@
   (:require [clojure.test :refer :all]
             [rems.application.approver-bot :as approver-bot]
             [rems.application.bona-fide-bot :as bona-fide-bot]
-            [rems.application.rejecter-bot :as rejecter-bot]))
+            [rems.application.rejecter-bot :as rejecter-bot]
+            [rems.application.expirer-bot :as expirer-bot]))
 
 (def +bot-users+
   {:approver-bot approver-bot/bot-userid
    :bona-fide-bot bona-fide-bot/bot-userid
-   :rejecter-bot rejecter-bot/bot-userid})
+   :rejecter-bot rejecter-bot/bot-userid
+   :expirer-bot expirer-bot/bot-userid})
 
 (def +bot-user-data+
   {approver-bot/bot-userid {:userid approver-bot/bot-userid :name "Approver Bot"}
    bona-fide-bot/bot-userid {:userid bona-fide-bot/bot-userid :name "Bona Fide Bot"}
-   rejecter-bot/bot-userid {:userid rejecter-bot/bot-userid :name "Rejecter Bot"}})
+   rejecter-bot/bot-userid {:userid rejecter-bot/bot-userid :name "Rejecter Bot"}
+   expirer-bot/bot-userid {:userid expirer-bot/bot-userid :name "Expirer Bot"}})
 
 (def +fake-users+
   {:applicant1 "alice"
diff --git a/test-config.edn b/test-config.edn
index 738c76d00..771046ab1 100644
--- a/test-config.edn
+++ b/test-config.edn
@@ -14,7 +14,6 @@
                          {:attribute "organizations"}]
  :languages [:en :fi :sv]
  :attachment-max-size 10000
- :application-expiration {:application.state/draft "P90D"}
  :enable-pdf-api true
  :enable-permissions-api true
  ;; example keys from https://tools.ietf.org/html/rfc7517#appendix-A
diff --git a/test/clj/rems/api/test_applications.clj b/test/clj/rems/api/test_applications.clj
index 2d6a083ba..269162ebb 100644
--- a/test/clj/rems/api/test_applications.clj
+++ b/test/clj/rems/api/test_applications.clj
@@ -2220,7 +2220,9 @@
                            "application.command/uninvite-member"
                            "application.command/delete"
                            "application.command/save-draft"
-                           "application.command/change-resources"]}
+                           "application.command/change-resources"]
+               :expirer ["application.command/send-expiration-notifications"
+                         "application.command/delete"]}
               :application/modified "2010-01-01T00:00:00.000Z"
               :application/user-roles {:alice ["applicant"] :handler ["handler"] :reporter ["reporter"]}
               :application/external-id "2010/1"
diff --git a/test/clj/rems/api/test_over_http.clj b/test/clj/rems/api/test_over_http.clj
index 41d24d0f5..7bbcd20b8 100644
--- a/test/clj/rems/api/test_over_http.clj
+++ b/test/clj/rems/api/test_over_http.clj
@@ -23,7 +23,8 @@
         res-id1 (test-helpers/create-resource! nil)
         item-id1 (test-helpers/create-catalogue-item! {:form-id form :workflow-id wfid :resource-id res-id1})
         app-id (test-helpers/create-draft! "applicant" [item-id1] "draft")]
-    (test-helpers/submit-application app-id "applicant")))
+    (test-helpers/submit-application {:application-id app-id
+                                      :actor "applicant"})))
 
 (deftest test-api-sql-timeouts
   (create-test-data)
diff --git a/test/clj/rems/api/test_pdf.clj b/test/clj/rems/api/test_pdf.clj
index f7c68152d..f3cd6de2f 100644
--- a/test/clj/rems/api/test_pdf.clj
+++ b/test/clj/rems/api/test_pdf.clj
@@ -26,7 +26,8 @@
         res-id1 (test-helpers/create-resource! nil)
         item-id1 (test-helpers/create-catalogue-item! {:form-id form :workflow-id wfid :resource-id res-id1})
         app-id (test-helpers/create-draft! "applicant" [item-id1] "draft")]
-    (test-helpers/submit-application app-id "applicant")))
+    (test-helpers/submit-application {:application-id app-id
+                                      :actor "applicant"})))
 
 (deftest test-experimental-pdf-smoke
   ;; need to spin up an actual http server so that something can serve
diff --git a/test/clj/rems/api/test_permissions.clj b/test/clj/rems/api/test_permissions.clj
index 53518c052..397a90e87 100644
--- a/test/clj/rems/api/test_permissions.clj
+++ b/test/clj/rems/api/test_permissions.clj
@@ -50,7 +50,8 @@
         wf-id (test-helpers/create-workflow! {:handlers ["handler"]})
         cat-id (test-helpers/create-catalogue-item! {:resource-id res-id :workflow-id wf-id})
         application (test-helpers/create-application! {:catalogue-item-ids [cat-id] :actor "alice"})]
-    (test-helpers/submit-application application "alice")
+    (test-helpers/submit-application {:application-id application
+                                      :actor "alice"})
     (test-helpers/command! {:type :application.command/approve
                             :application-id application
                             :actor "handler"}))
diff --git a/test/clj/rems/application/test_eraser.clj b/test/clj/rems/application/test_eraser.clj
index b11cc4bf9..842f54efb 100644
--- a/test/clj/rems/application/test_eraser.clj
+++ b/test/clj/rems/application/test_eraser.clj
@@ -1,58 +1,224 @@
 (ns ^:integration rems.application.test-eraser
   (:require [clj-time.core :as time]
             [clojure.test :refer :all]
-            [rems.db.core :as db]
-            [rems.db.events :as events]
-            [rems.db.applications :as applications]
+            [clojure.tools.logging :as log]
+            [rems.application.commands :as commands]
             [rems.application.eraser :as eraser]
-            [rems.db.testing :refer [test-db-fixture rollback-db-fixture]]))
+            [rems.application.expirer-bot :as expirer-bot]
+            [rems.api.services.command :as command]
+            [rems.config :refer [env]]
+            [rems.locales]
+            [rems.db.applications :as applications]
+            [rems.db.outbox :as outbox]
+            [rems.db.roles :as roles]
+            [rems.db.testing :refer [test-db-fixture rollback-db-fixture]]
+            [rems.db.test-data-helpers :as test-helpers]
+            [rems.db.user-settings :as user-settings]
+            [rems.testing-util :refer [with-fixed-time]]))
+
+(defn- empty-footer [f]
+  (with-redefs [rems.locales/translations (assoc-in rems.locales/translations [:en :t :email :footer] "")]
+    (f)))
+
+(defn- empty-signature [f]
+  (with-redefs [rems.locales/translations (assoc-in rems.locales/translations [:en :t :email :regards] "")]
+    (f)))
 
 (use-fixtures :once test-db-fixture)
-(use-fixtures :each rollback-db-fixture)
-
-(defn- add-dummy-application!
-  [{:keys [date-time actor draft?]}]
-  (let [app-id (:id (db/create-application!))
-        created-event {:event/type :application.event/created
-                       :event/time date-time
-                       :event/actor actor
-                       :application/id app-id
-                       :application/external-id ""
-                       :application/resources []
-                       :application/licenses []
-                       :application/forms []
-                       :workflow/id 0
-                       :workflow/type :workflow/default}
-        draft-saved-event {:event/type :application.event/draft-saved
-                           :event/time date-time
-                           :event/actor actor
-                           :application/id app-id
-                           :application/field-values []}
-        submitted-event {:event/type :application.event/submitted
-                         :event/time date-time
-                         :event/actor actor
-                         :application/id app-id}]
-    (events/add-event! created-event)
-    (events/add-event! draft-saved-event)
+(use-fixtures :each empty-footer empty-signature rollback-db-fixture)
+
+(defn- create-application!
+  [{:keys [date-time actor draft? members]}]
+  (let [res (test-helpers/create-resource! {:resource-ext-id "res"})
+        wf (test-helpers/create-workflow! {:type :workflow/default})
+        cat (test-helpers/create-catalogue-item! {:title {:en "cat"}
+                                                  :workflow-id wf
+                                                  :resource-id res})
+        app-id (test-helpers/create-draft! actor [cat] "eraser-test" date-time)]
+    (doseq [member members]
+      (let [f commands/handle-command]
+        (with-redefs [commands/handle-command (fn [cmd application injections]
+                                                (->> (assoc injections
+                                                            :secure-token (constantly "test"))
+                                                     (f cmd application)))]
+          (test-helpers/command! {:time date-time
+                                  :type :application.command/invite-member
+                                  :application-id app-id
+                                  :member (dissoc member :userid)
+                                  :actor actor})
+          (test-helpers/command! {:time date-time
+                                  :type :application.command/accept-invitation
+                                  :application-id app-id
+                                  :token "test"
+                                  :actor (:userid member)}))))
     (when (not draft?)
-      (events/add-event! submitted-event))
+      (test-helpers/submit-application {:application-id app-id
+                                        :actor actor
+                                        :time date-time}))
     app-id))
 
-(deftest test-eraser
-  (testing "removes expired draft applications"
-    (let [app-id-1 (add-dummy-application! {:draft? true :date-time (time/now) :actor "alice"})
-          app-id-2 (add-dummy-application! {:date-time (time/now) :actor "alice"})
-          app-id-3 (add-dummy-application! {:date-time (time/minus (time/now) (time/days 120)) :actor "alice"})
-          expired-app-id (add-dummy-application! {:draft? true
-                                                  :date-time (time/minus (time/now) (time/days 90) (time/seconds 1))
-                                                  :actor "alice"})
-          expired-app-id-2 (add-dummy-application! {:draft? true
-                                                    :date-time (time/minus (time/now) (time/days 120))
-                                                    :actor "alice"})]
-      (let [before-apps (applications/get-all-applications "alice")]
-        (is (= #{app-id-1 app-id-2 app-id-3 expired-app-id expired-app-id-2}
-               (set (map :application/id before-apps)))))
-      (eraser/remove-expired-applications!)
-      (let [after-apps (applications/get-all-applications "alice")]
-        (is (= #{app-id-1 app-id-2 app-id-3}
-               (set (map :application/id after-apps))))))))
\ No newline at end of file
+(defn- expiration-notification-events [app-id]
+  (->> (applications/get-application-internal app-id)
+       :application/events
+       (filter (comp #{:application.event/expiration-notifications-sent}
+                     :event/type))))
+
+(defn- get-all-application-ids [user-id]
+  (->> (applications/get-all-applications user-id)
+       (map :application/id)))
+
+(defn- log*-mock [coll-atom]
+  (fn [logger level throwable message]
+    (swap! coll-atom conj {:logger logger :level level :throwable throwable :message message})))
+
+(deftest test-expire-application
+  (binding [command/*fail-on-process-manager-errors* true]
+    (test-helpers/create-user! {:userid "alice"})
+    (let [now (time/now)
+          draft (create-application! {:draft? true
+                                      :date-time now
+                                      :actor "alice"})
+          old-submitted (create-application! {:date-time (time/minus now (time/days 120))
+                                              :actor "alice"})
+          expired-draft (create-application! {:draft? true
+                                              :date-time (time/minus now (time/days 90) (time/seconds 1))
+                                              :actor "alice"})
+          outbox-emails (atom [])
+          log-messages (atom [])]
+      (with-redefs [log/log* (log*-mock log-messages)
+                    outbox/put! (fn [email] (swap! outbox-emails conj email))]
+
+        (testing "does not process applications when expirer-bot user does not exist"
+          (is (= #{draft old-submitted expired-draft} (set (get-all-application-ids "alice"))))
+          (with-redefs [env {:application-expiration {:application.state/draft {:delete-after "P90D"}}}]
+            (eraser/process-applications!))
+          (is (= #{draft old-submitted expired-draft} (set (get-all-application-ids "alice"))))
+          (is (empty? @outbox-emails))
+          (is (= [(str "Cannot process applications, because user " expirer-bot/bot-userid " does not exist")]
+                 (->> @log-messages
+                      (filter (comp #{:warn} :level))
+                      (map :message)))))
+
+        (test-helpers/create-user! {:userid expirer-bot/bot-userid})
+        (roles/add-role! expirer-bot/bot-userid :expirer)
+
+        (testing "does not delete applications when configuration is not valid"
+          (reset! log-messages [])
+          (is (= #{draft old-submitted expired-draft} (set (get-all-application-ids "alice"))))
+          (with-redefs [env {}]
+            (eraser/process-applications!))
+          (is (= #{draft old-submitted expired-draft} (set (get-all-application-ids "alice"))))
+          (is (empty? @outbox-emails))
+          (is (empty? (->> @log-messages
+                           (filter (comp #{:warn} :level))))))
+
+        (testing "removes expired applications"
+          ;; expirer-bot user has been created previously
+          (reset! log-messages [])
+          (is (= #{draft old-submitted expired-draft} (set (get-all-application-ids "alice"))))
+          (with-redefs [env {:application-expiration {:application.state/draft {:delete-after "P90D"}}}]
+            (eraser/process-applications!))
+          (is (= #{draft old-submitted} (set (get-all-application-ids "alice"))))
+          (is (empty? @outbox-emails))
+          (is (empty? (->> @log-messages
+                           (filter (comp #{:warn} :level))))))
+
+        (testing "cannot remove other than draft applications"
+          ;; expirer-bot user has been created previously
+          (is (= #{draft old-submitted} (set (get-all-application-ids "alice"))))
+          (with-redefs [env {:application-expiration {:application.state/submitted {:delete-after "P90D"}}}]
+            (is (thrown-with-msg? java.lang.AssertionError
+                                  (re-pattern (str "Tried to delete application "
+                                                   old-submitted
+                                                   " which is not a draft"))
+                                  (eraser/process-applications!))))
+          (is (= #{draft old-submitted} (set (get-all-application-ids "alice")))))))))
+
+(deftest test-send-reminder-email
+  (binding [command/*fail-on-process-manager-errors* true]
+    (with-fixed-time (time/date-time 2022)
+      (fn []
+        (test-helpers/create-user! {:userid "alice"})
+        (test-helpers/create-user! {:userid "member"})
+        (test-helpers/create-user! {:userid expirer-bot/bot-userid})
+        (roles/add-role! expirer-bot/bot-userid :expirer)
+        (let [now (time/now)
+              draft-expires-in-6d (create-application! {:draft? true
+                                                        :date-time (time/minus now (time/days 84))
+                                                        :actor "alice"
+                                                        :members [{:userid "member"
+                                                                   :name "Member"
+                                                                   :email "member@example.com"}]})
+              draft-expires-in-8d (create-application! {:draft? true
+                                                        :date-time (time/minus now (time/days 82))
+                                                        :actor "alice"})
+              submitted (create-application! {:date-time (time/minus now (time/days 84))
+                                              :actor "alice"})
+              outbox-emails (atom [])]
+          (with-redefs [outbox/put! (fn [email] (swap! outbox-emails conj email))]
+
+            (testing "should not send reminder because config is missing or partially correct"
+              (doseq [mock-env [{:application-expiration {:application.state/draft {:delete-after "P90D"}}}
+                                {:application-expiration {:application.state/draft {:reminder-before "P7D"}}}
+                                {:application-expiration nil}]]
+                (with-redefs [env mock-env]
+                  (eraser/process-applications!))
+                (is (= #{draft-expires-in-6d draft-expires-in-8d submitted}
+                       (set (get-all-application-ids "alice"))))
+                (is (empty? @outbox-emails))
+                (is (empty? (expiration-notification-events draft-expires-in-6d)))
+                (is (empty? (expiration-notification-events draft-expires-in-8d)))
+                (is (empty? (expiration-notification-events submitted)))))
+
+            (testing "send expiration notifications"
+              (with-redefs [env {:application-expiration {:application.state/draft {:delete-after "P90D"
+                                                                                    :reminder-before "P7D"}}
+                                 :application-id-column :id
+                                 :public-url "localhost/"}
+                            user-settings/get-user-settings (constantly {:language :en})]
+                (eraser/process-applications!)
+                (is (= #{draft-expires-in-6d draft-expires-in-8d submitted}
+                       (set (get-all-application-ids "alice")))
+                    "no applications have been deleted")
+                (is (empty? (expiration-notification-events draft-expires-in-8d)))
+                (is (empty? (expiration-notification-events submitted)))
+
+                (testing "expiration notifications sent event was created for draft expiring in 6 days"
+                  (let [events (expiration-notification-events draft-expires-in-6d)]
+                    (is (= 1 (count events)))
+                    (is (time/equal? (time/plus now (time/days 6)) (:expires-on (first events))))
+                    (is (time/equal? (time/minus now (time/days 84)) (:last-activity (first events))))
+                    (is (time/equal? now (:event-time (first events))))))
+                (is (= [{:outbox/deadline (time/date-time 2022)
+                         :outbox/email {:subject (str "Your unsubmitted application " draft-expires-in-6d " will be deleted soon")
+                                        :body (str "Dear alice,\n\n"
+                                                   "Your unsubmitted application has been inactive since 2021-10-09 and it will be deleted after 2022-01-07, if it is not edited.\n\n"
+                                                   "You can view and edit the application at localhost/application/" draft-expires-in-6d)
+                                        :to-user "alice"}
+                         :outbox/type :email}
+                        {:outbox/deadline (time/date-time 2022)
+                         :outbox/email {:subject (str "Your unsubmitted application " draft-expires-in-6d " will be deleted soon")
+                                        :body (str "Dear member,\n\n"
+                                                   "Your unsubmitted application has been inactive since 2021-10-09 and it will be deleted after 2022-01-07, if it is not edited.\n\n"
+                                                   "You can view and edit the application at localhost/application/" draft-expires-in-6d)
+                                        :to-user "member"}
+                         :outbox/type :email}]
+                       @outbox-emails))
+
+                (testing "processing applications again should not send new notifications"
+                  (reset! outbox-emails [])
+                  (eraser/process-applications!)
+                  (is (= #{draft-expires-in-6d draft-expires-in-8d submitted}
+                         (set (get-all-application-ids "alice")))
+                      "no applications have been deleted")
+                  (is (empty? (expiration-notification-events draft-expires-in-8d)))
+                  (is (empty? (expiration-notification-events submitted)))
+                  (is (empty? @outbox-emails))
+
+                  (testing "expiration notifications events have not changed for draft expiring in 6 days"
+                    (let [events (expiration-notification-events draft-expires-in-6d)]
+                      (is (= 1 (count events)))
+                      (is (time/equal? (time/plus now (time/days 6)) (:expires-on (first events))))
+                      (is (time/equal? (time/minus now (time/days 84)) (:last-activity (first events))))
+                      (is (time/equal? now (:event-time (first events)))))))))))))))
+
diff --git a/test/clj/rems/browser_test_util.clj b/test/clj/rems/browser_test_util.clj
index 91c084d6a..6fcf618e2 100644
--- a/test/clj/rems/browser_test_util.clj
+++ b/test/clj/rems/browser_test_util.clj
@@ -259,7 +259,8 @@
     (test-helpers/create-workflow-licence! wfid text)
     (test-helpers/create-workflow-licence! decider-wf link)
     (test-helpers/create-workflow-licence! decider-wf text)
-    (test-helpers/submit-application app-id "applicant"))
+    (test-helpers/submit-application {:application-id app-id
+                                      :actor "applicant"}))
   (f))
 (defn test-dev-or-standalone-fixture
   "Depending on if we are trying to develop browser tests or
diff --git a/test/clj/rems/test_browser.clj b/test/clj/rems/test_browser.clj
index 6449502cb..f55db9b62 100644
--- a/test/clj/rems/test_browser.clj
+++ b/test/clj/rems/test_browser.clj
@@ -785,7 +785,8 @@
     (btu/context-assoc! :application-id (test-helpers/create-draft! "alice"
                                                                     [(btu/context-getx :catalogue-id)]
                                                                     "test-invite-decider"))
-    (test-helpers/submit-application (btu/context-getx :application-id) "alice")
+    (test-helpers/submit-application {:application-id (btu/context-getx :application-id)
+                                      :actor "alice"})
     (test-helpers/create-user! {:userid "new-decider" :name "New Decider" :email "new-decider@example.com"}))
   (btu/with-postmortem
     (testing "handler invites decider"
@@ -909,7 +910,8 @@
                                                                     [(btu/context-getx :catalogue-id)
                                                                      (btu/context-getx :catalogue-id-2)]
                                                                     "test-invite-reviewer"))
-    (test-helpers/submit-application (btu/context-getx :application-id) "alice"))
+    (test-helpers/submit-application {:application-id (btu/context-getx :application-id)
+                                      :actor "alice"}))
   (btu/with-postmortem
     (testing "handler invites reviewer"
       (login-as "developer")