Repository: CSCfi/rems
Pull request #3247 (2024-01-15 18:47:58)

Email translation improvements
partially implements #3241

- Implements named parameters for email templates
- English localizations have been updated for reference (with both named/vector parameters)
- Updated `com.taoensso/tempura` to latest and fixed the issue preventing update before
- Removed format parameter checks and tests, these should be re-implemented

Extra translations for email templates can use vector parameters for backwards compatibility and transition period into named parameters. These two localization strings should work identically:
```clojure
;; :t.email.application-approved/message-to-applicant
"Dear %:recipient%,\n\nYour application %:application-id% has been approved.\n\nYou can view the application and the decision at %:application-url%"
"Dear %1,\n\nYour application %3 has been approved.\n\nYou can view the application and the decision at %6"
```

Mixing formats is not allowed:
```clojure
;; will throw runtime error
"Dear %1,\n\nYour application %:application-id% has been approved.\n\nYou can view the application and the decision at %:application-url%"
```

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue

## Backwards compatibility
- [x] Config is backwards compatible. Extra translations may use vector parameters, and should be flexible to transition into named parameters.

## Documentation
- [x] Update changelog if necessary

## Testing
- [x] Complex logic is unit tested

## Follow-up
- [ ] New tasks are created for pending or remaining tasks



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d83483759..732d21a08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ have notable changes.
 
 Changes since v2.35.1
 
+### Additions
+- Email templates `:t.email/...` now support named format parameters, and default english localizations have been updated for reference. While REMS moves towards fully supporting named parameters, all translations with index parameters should continue to work. Localizations with many parameters (like emails) have been burdensome to maintain with index parameters, so transitioning into named parameters is highly encouraged. (#3241)
+
 ## v2.35.1 "Selkämerenkatu +1" 2024-01-16
 
 ### Fixes
diff --git a/example-theme/extra-translations/en.edn b/example-theme/extra-translations/en.edn
index 054de43b7..e77427ce4 100644
--- a/example-theme/extra-translations/en.edn
+++ b/example-theme/extra-translations/en.edn
@@ -1,9 +1,3 @@
 {:t
  {:footer "CSC – Overridden in Extra Translations"
-  ;; the extra %1 and the :unused-key are for rems.test-locales
-  :create-license {:license-text "Text %1"}
-  :unused-key "Unused"
-  :login {:errors {:group "group"}}
-  ;; example of using named parameters instead of vector index params
-  :applications {:view-application-without-description "View application %:application/id% for resources: %:catalogue-item-names%"
-                 :view-application-with-description "View application %:application/id%: %:application/description%"}}}
+  :login {:errors {:group "group"}}}}
diff --git a/project.clj b/project.clj
index 31b8a7858..4093a01d8 100644
--- a/project.clj
+++ b/project.clj
@@ -15,7 +15,7 @@
                  [com.fasterxml.jackson.datatype/jackson-datatype-joda "2.14.1"]
                  [com.stuartsierra/dependency "1.0.0"]
                  [com.rpl/specter "1.1.4"]
-                 [com.taoensso/tempura "1.2.1"] ; 1.5.3 fails Wrong number of args (2) passed to: taoensso.tempura.impl/eval38628/compile-dictionary--38641, there must be a backwards incompatible change somewhere
+                 [com.taoensso/tempura "1.5.3"]
                  [compojure "1.7.0"]
                  [conman "0.8.4"] ; 0.8.5 switches to next.jdbc, which breaks stuff and requires proper testing in production
                  [cprop "0.1.19"]
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index e009ae1e3..a4904862c 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -433,88 +433,118 @@
         :title "Data Use Ontology"
         :validation {:mondo-not-valid "Provided Mondo codes are not compatible:"
                      :needs-validation "Data Use Ontology restriction needs to be reviewed manually:"}}
+
   ;; Format parameters for all emails (unless otherwise noted):
-  ;; %1 - name of recipient
-  ;; %2 - name of event actor
-  ;; %3 - id of application (+ description of application if available)
-  ;; %4 - name of applicant
-  ;; %5 - catalogue items of application, comma separated list
-  ;; %6 - link to application
-  :email {:applicant-changed {:subject-to-member "Applicant for application %3 changed"
-                              :message-to-member "Dear %1,\n\nThe applicant for application %3 has been changed to %4 by %2.\n\nYou can view the application at %6."
-                              :subject-to-handler "(%3) Applicant changed"
-                              :message-to-handler "Dear %1,\n\nThe applicant for application %3 has been changed to %4 by %2.\n\nYou can view the application at %6."}
-          :application-approved {:subject-to-applicant "Your application %3 has been approved"
-                                 :message-to-applicant "Dear %1,\n\nYour application %3 has been approved.\n\nYou can view the application and the decision at %6"
-                                 :subject-to-handler "(%3) Application has been approved"
-                                 :message-to-handler "Dear %1,\n\n%2 has approved the application %3 from %4.\n\nYou can view the application and the decision at %6"}
-          :application-rejected {:subject-to-applicant "Your application %3 has been rejected"
-                                 :message-to-applicant "Dear %1,\n\nYour application %3 has been rejected.\n\nYou can view the application and the decision at %6"
-                                 :subject-to-handler "(%3) Application has been rejected"
-                                 :message-to-handler "Dear %1,\n\n%2 has rejected the application %3 from %4.\n\nYou can view the application and the decision at %6"}
-          :application-closed {:subject-to-applicant "Your application %3 has been closed"
-                               :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"}
-          ;; %1 - name of recipient
-          ;; %2 - id of application (+ description of application if available)
-          ;; %3 - date of last activity in application
-          ;; %4 - date of application expiration (after which draft application will be removed)
-          ;; %5 - link to application
-          :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"
-                                 :message-to-handler "Dear %1,\n\n%2 has returned the application %3 to the applicant %4 for modifications.\n\nYou can view the application at %6"}
-          :application-revoked {:subject-to-applicant "Entitlements related to your application %3 have been revoked"
-                                :message-to-applicant "Dear %1,\n\nEntitlements related to your application %3 have been revoked.\n\nYou can view the application at %6"
-                                :subject-to-handler "(%3) Entitlements have been revoked"
-                                :message-to-handler "Dear %1,\n\n%2 has revoked the entitlements related to application %3 from %4.\n\nYou can view the application at %6"}
-          :application-licenses-added {:subject "Your application's %3 terms of use have changed"
-                                       :message "Dear %1,\n\n%2 has requested your approval for changed terms of use to application %3.\n\nYou can view the application and approve the changed terms of use at %6"}
-          :application-resubmitted {:subject-to-handler "(%3) Application has been resubmitted"
-                                    :message-to-handler "Dear %1,\n\nApplication %3 has been resubmitted by %4.\n\nYou can review the application at %6"}
-          :application-submitted {:subject-to-applicant "Your application %3 has been submitted"
-                                  :message-to-applicant "Dear %1,\n\nYour application %3 has been submitted. You will be notified by email when the application has been handled.\n\nYou can view the application at %6"
-                                  :subject-to-handler "(%3) A new application has been submitted"
-                                  :message-to-handler "Dear %1,\n\n%4 has submitted a new application %3 to access resource(s) %5.\n\nYou can review the application at %6"}
-          :decided {:subject "(%3) Application decision notification"
-                    :message "Dear %1,\n\n%2 has filed a decision on application %3, submitted by %4.\n\nYou can review the application at %6"}
-          ;; generic text shared by decider-invited and reviewer-invited at the moment
-          ;; %1 - name of invited person, as entered in application
-          ;; %2 - applicant name
-          ;; %3 - application id (+ description if available)
-          ;; %4 - invitation link
-          :decider-invited {:subject "Invitation to participate in handling application %3"
-                            :message "Dear %1,\n\nYou have been invited to participate in handling application %3, by %2.\n\nYou can view the application at %4"}
-          :decision-requested {:subject "(%3) Decision request"
-                               :message "Dear %1,\n\n%2 has requested your decision on application %3, submitted by %4.\n\nYou can review application at %6"}
+  ;; %1 - %:recipient% - name of recipient
+  ;; %2 - %:event-actor% - name of event actor
+  ;; %3 - %:application-id% - id of application (+ description of application if available)
+  ;; %4 - %:applicant% - name of applicant
+  ;; %5 - %:catalogue-items% - catalogue items of application, comma separated list
+  ;; %6 - %:application-url% - link to application
+  ;;
+  ;; Vector parameters (%1, %2, ...) in extra translations are supported for backwards compatibility.
+  ;; They cannot be mixed with named format parameters, and have fixed positions.
+  :email {:applicant-changed {:subject-to-member "Applicant for application %:application-id% changed"
+                              :message-to-member "Dear %:recipient%,\n\nThe applicant for application %:application-id% has been changed to %:applicant% by %:event-actor%.\n\nYou can view the application at %:application-url%."
+                              :subject-to-handler "(%:application-id%) Applicant changed"
+                              :message-to-handler "Dear %:recipient%,\n\nThe applicant for application %:application-id% has been changed to %:applicant% by %:event-actor%.\n\nYou can view the application at %:application-url%."}
+          :application-approved {:subject-to-applicant "Your application %:application-id% has been approved"
+                                 :message-to-applicant "Dear %:recipient%,\n\nYour application %:application-id% has been approved.\n\nYou can view the application and the decision at %:application-url%"
+                                 :subject-to-handler "(%:application-id%) Application has been approved"
+                                 :message-to-handler "Dear %:recipient%,\n\n%:event-actor% has approved the application %:application-id% from %:applicant%.\n\nYou can view the application and the decision at %:application-url%"}
+          :application-rejected {:subject-to-applicant "Your application %:application-id% has been rejected"
+                                 :message-to-applicant "Dear %:recipient%,\n\nYour application %:application-id% has been rejected.\n\nYou can view the application and the decision at %:application-url%"
+                                 :subject-to-handler "(%:application-id%) Application has been rejected"
+                                 :message-to-handler "Dear %:recipient%,\n\n%:event-actor% has rejected the application %:application-id% from %:applicant%.\n\nYou can view the application and the decision at %:application-url%"}
+          :application-closed {:subject-to-applicant "Your application %:application-id% has been closed"
+                               :message-to-applicant "Dear %:recipient%,\n\nYour application %:application-id% has been closed.\n\nYou can still view the application at %:application-url%"
+                               :subject-to-handler "(%:application-id%) Application has been closed"
+                               :message-to-handler "Dear %:recipient%,\n\n%:event-actor% has closed the application %:application-id% from %:applicant%.\n\nYou can view the application at %:application-url%"}
+          ;; Format parameters for application-expiration-notification:
+          ;; %1 - %:recipient% - name of recipient
+          ;; %2 - %:application-id% - id of application (+ description of application if available)
+          ;; %3 - %:last-activity% - date of last activity in application
+          ;; %4 - %:expires-on% - date of application expiration (after which draft application will be removed)
+          ;; %5 - %:application-url% - link to application
+          :application-expiration-notification {:subject-to-member "Your unsubmitted application %:application-id% will be deleted soon"
+                                                :message-to-member "Dear %:recipient%,\n\nYour unsubmitted application has been inactive since %:last-activity% and it will be deleted after %:expires-on%, if it is not edited.\n\nYou can view and edit the application at %:application-url%"}
+          :application-returned {:subject-to-applicant "Your application %:application-id% needs to be amended"
+                                 :message-to-applicant "Dear %:recipient%,\n\nYour application %:application-id% has been returned for your consideration. Please, amend according to requests and resubmit.\n\nYou can view and edit the application at %:application-url%"
+                                 :subject-to-handler "(%:application-id%) Application has been returned to applicant"
+                                 :message-to-handler "Dear %:recipient%,\n\n%:event-actor% has returned the application %:application-id% to the applicant %:applicant% for modifications.\n\nYou can view the application at %:application-url%"}
+          :application-revoked {:subject-to-applicant "Entitlements related to your application %:application-id% have been revoked"
+                                :message-to-applicant "Dear %:recipient%,\n\nEntitlements related to your application %:application-id% have been revoked.\n\nYou can view the application at %:application-url%"
+                                :subject-to-handler "(%:application-id%) Entitlements have been revoked"
+                                :message-to-handler "Dear %:recipient%,\n\n%:event-actor% has revoked the entitlements related to application %:application-id% from %:applicant%.\n\nYou can view the application at %:application-url%"}
+          :application-licenses-added {:subject "Your application's %:application-id% terms of use have changed"
+                                       :message "Dear %:recipient%,\n\n%:event-actor% has requested your approval for changed terms of use to application %:application-id%.\n\nYou can view the application and approve the changed terms of use at %:application-url%"}
+          :application-resubmitted {:subject-to-handler "(%:application-id%) Application has been resubmitted"
+                                    :message-to-handler "Dear %:recipient%,\n\nApplication %:application-id% has been resubmitted by %:applicant%.\n\nYou can review the application at %:application-url%"}
+          :application-submitted {:subject-to-applicant "Your application %:application-id% has been submitted"
+                                  :message-to-applicant "Dear %:recipient%,\n\nYour application %:application-id% has been submitted. You will be notified by email when the application has been handled.\n\nYou can view the application at %:application-url%"
+                                  :subject-to-handler "(%:application-id%) A new application has been submitted"
+                                  :message-to-handler "Dear %:recipient%,\n\n%:applicant% has submitted a new application %:application-id% to access resource(s) %:catalogue-items%.\n\nYou can review the application at %:application-url%"}
+          :decided {:subject "(%:application-id%) Application decision notification"
+                    :message "Dear %:recipient%,\n\n%:event-actor% has filed a decision on application %:application-id%, submitted by %:applicant%.\n\nYou can review the application at %:application-url%"}
+          ;; Format parameters for decider-invited:
+          ;; %1 - %:recipient% - name of recipient
+          ;; %2 - %:applicant% - name of applicant
+          ;; %3 - %:application-id% - id of application (+ description of application if available)
+          ;; %4 - %:invitation-url% - invitation link
+          :decider-invited {:subject "Invitation to participate in handling application %:application-id%"
+                            :message "Dear %:recipient%,\n\nYou have been invited to participate in handling application %:application-id%, by %:applicant%.\n\nYou can view the application at %:invitation-url%"}
+          :decision-requested {:subject "(%:application-id%) Decision request"
+                               :message "Dear %:recipient%,\n\n%:event-actor% has requested your decision on application %:application-id%, submitted by %:applicant%.\n\nYou can review application at %:application-url%"}
           :footer "\n\nPlease do not reply to this automatically generated message."
-          :handler-reminder {:application "%1, submitted by %2"
-                             :message "Dear %1,\n\nThe following applications are in progress and may require your actions:\n\n%2\n\nYou can view the applications at %3"
+          :handler-reminder {;; Format parameters for handler-reminder/application:
+                             ;; %1 - %:applicant% - name of applicant
+                             ;; %2 - %:application-id% - id of application (+ description of application if available)
+                             :application "%:application-id%, submitted by %:applicant%"
+                             ;; Format parameters for handler-reminder/message:
+                             ;; %1 - %:recipient% - name of handler user
+                             ;; %2 - %:applications% - list of applications which require action
+                             ;; %3 - %:actions-url% - link to actions page
+                             :message "Dear %:recipient%,\n\nThe following applications are in progress and may require your actions:\n\n%:applications%\n\nYou can view the applications at %:actions-url%"
                              :subject "Applications in progress"}
-          :member-added {:subject "Added as a member of an application %3"
-                         :message "Dear %1,\n\nYou've been added as a member of application %3, submitted by %4.\n\nYou can view application and accept the terms of use at %6"}
-          ;; %1 - name of member, as entered in application
-          ;; %2 - applicant name
-          ;; %3 - application id (+ description if available)
-          ;; %4 - invitation link
-          :member-invited {:subject "Invitation to participate in application %3"
-                           :message "Dear %1,\n\nYou have been invited to participate in application %3, by %2.\n\nYou can view the application and accept the terms of use at %4"}
+          :member-added {:subject "Added as a member of an application %:application-id%"
+                         :message "Dear %:recipient%,\n\nYou've been added as a member of application %:application-id%, submitted by %:applicant%.\n\nYou can view application and accept the terms of use at %:application-url%"}
+          ;; Format parameters for member-invited:
+          ;; %1 - %:recipient% - name of recipient
+          ;; %2 - %:applicant% - name of applicant
+          ;; %3 - %:application-id% - id of application (+ description of application if available)
+          ;; %4 - %:invitation-url% - invitation link
+          :member-invited {:subject "Invitation to participate in application %:application-id%"
+                           :message "Dear %:recipient%,\n\nYou have been invited to participate in application %:application-id%, by %:applicant%.\n\nYou can view the application and accept the terms of use at %:invitation-url%"}
           :regards "\n\nKind regards,\n\nREMS\n"
-          :remarked {:subject "(%3) Application has been commented"
-                     :message "Dear %1,\n\n%2 has commented your application %3, submitted by %4.\n\nYou can view the application and the comment at %6"}
-          :review-requested {:subject "(%3) Review request"
-                             :message "Dear %1,\n\n%2 has requested your review on application %3, submitted by %4.\n\nYou can review the application at %6"}
-          :reviewed {:subject "(%3) Application has been reviewed"
-                     :message "Dear %1,\n\n%2 has reviewed the application %3, submitted by %4.\n\nYou can review the application at %6"}
-          :reviewer-invited {:subject "Invitation to participate in handling application %3"
-                             :message "Dear %1,\n\nYou have been invited to participate in handling application %3, by %2.\n\nYou can view the application at %4"}
-          :reviewer-reminder {:application "%1, submitted by %2"
-                              :message "Dear %1,\n\nThe following applications are waiting for a review from you:\n\n%2\n\nYou can view the applications at %3"
+          :remarked {:subject "(%:application-id%) Application has been commented"
+                     :message "Dear %:recipient%,\n\n%:event-actor% has commented your application %:application-id%, submitted by %:applicant%.\n\nYou can view the application and the comment at %:application-url%"}
+          :review-requested {:subject "(%:application-id%) Review request"
+                             :message "Dear %:recipient%,\n\n%:event-actor% has requested your review on application %:application-id%, submitted by %:applicant%.\n\nYou can review the application at %:application-url%"}
+          :reviewed {:subject "(%:application-id%) Application has been reviewed"
+                     :message "Dear %:recipient%,\n\n%:event-actor% has reviewed the application %:application-id%, submitted by %:applicant%.\n\nYou can review the application at %:application-url%"}
+          ;; Format parameters for reviewer-invited:
+          ;; %1 - %:recipient% - name of recipient
+          ;; %2 - %:applicant% - name of applicant
+          ;; %3 - %:application-id% - id of application (+ description of application if available)
+          ;; %4 - %:invitation-url% - invitation link
+          :reviewer-invited {:subject "Invitation to participate in handling application %:application-id%"
+                             :message "Dear %:recipient%,\n\nYou have been invited to participate in handling application %:application-id%, by %:applicant%.\n\nYou can view the application at %:invitation-url%"}
+          :reviewer-reminder {;; Format parameters for reviewer-reminder/application:
+                              ;; %1 - %:applicant% - name of applicant
+                              ;; %2 - %:application-id% - id of application (+ description of application if available)
+                              :application "%:application-id%, submitted by %:applicant%"
+                              ;; Format parameters for reviewer-reminder/message:
+                              ;; %1 - %:recipient% - name of handler user
+                              ;; %2 - %:applications% - list of applications which require action
+                              ;; %3 - %:actions-url% - link to actions page
+                              :message "Dear %:recipient%,\n\nThe following applications are waiting for a review from you:\n\n%:applications%\n\nYou can view the applications at %:actions-url%"
                               :subject "Applications pending review"}
-          :workflow-handler-invitation {:message "Dear %1,\n\n%2 has invited you to be a handler of applications of workflow %3.\n\nYou can view the workflow at %4"
+          ;; Format parameters for workflow-handler-invitation:
+          ;; %1 - %:recipient% - name of invited person, as entered in application
+          ;; %2 - %:invited-by% - name of person who sent the invitation
+          ;; %3 - %:workflow% - workflow title
+          ;; %4 - %:invitation-url% - invitation link
+          :workflow-handler-invitation {:message "Dear %:recipient%,\n\n%:invited-by% has invited you to be a handler of applications of workflow %:workflow%.\n\nYou can view the workflow at %:invitation-url%"
                                         :subject "Invitation to handle applications"}}
   :footer "CSC – IT Center for Science"
   :form {:accepted-licenses "Accepted terms of use"
diff --git a/shadow-cljs.edn b/shadow-cljs.edn
index 0c6217cf6..5430c9185 100644
--- a/shadow-cljs.edn
+++ b/shadow-cljs.edn
@@ -1,8 +1,9 @@
-{:dependencies [[cljs-ajax "0.8.4"]
+{:dependencies [[better-cond "2.1.5"]
+                [cljs-ajax "0.8.4"]
                 [clj-commons/secretary "1.2.4"]
                 [com.andrewmcveigh/cljs-time "0.5.2"]
                 [com.rpl/specter "1.1.4"]
-                [com.taoensso/tempura "1.2.1"]
+                [com.taoensso/tempura "1.5.3"]
                 [markdown-clj "1.10.9"]
                 [medley "1.3.0"]
                 [metosin/komponentit "0.3.11"]
diff --git a/src/clj/rems/email/template.clj b/src/clj/rems/email/template.clj
index 92dbef13c..282c3bf67 100644
--- a/src/clj/rems/email/template.clj
+++ b/src/clj/rems/email/template.clj
@@ -6,7 +6,7 @@
             [rems.context :as context]
             [rems.db.user-settings :as user-settings]
             [rems.permissions :as permissions]
-            [rems.text :refer [localize-user localize-utc-date text text-no-fallback text-format with-language]]
+            [rems.text :refer [localize-user localize-utc-date text text-no-fallback text-format-map with-language]]
             [rems.util :as util]))
 
 ;;; Mapping events to emails
@@ -65,24 +65,23 @@
   (vec
    (for [recipient recipients
          :let [email (with-language (:language (user-settings/get-user-settings (:userid recipient)))
-                       (when (and body-text (not (str/blank? (text-no-fallback body-text))))
-                         (let [event (apply-event-privacy event application (:userid recipient))]
+                       (when (and body-text
+                                  (not (str/blank? (text-no-fallback body-text))))
+                         (let [event (apply-event-privacy event application (:userid recipient))
+                               params {:applicant (application-util/get-applicant-name application)
+                                       :application-id (format-application-for-email application)
+                                       :application-url (link-to-application (:application/id event))
+                                       :catalogue-items (resources-for-email application)
+                                       :event-actor (localize-user (:event/actor-attributes event))
+                                       :recipient (application-util/get-member-name recipient)}]
                            {:to-user (:userid recipient)
-                            :subject (text-format subject-text
-                                                  (application-util/get-member-name recipient)
-                                                  (localize-user (:event/actor-attributes event))
-                                                  (format-application-for-email application)
-                                                  (application-util/get-applicant-name application)
-                                                  (resources-for-email application)
-                                                  (link-to-application (:application/id event)))
+                            :subject (text-format-map subject-text
+                                                      params
+                                                      [:recipient :event-actor :application-id :applicant :catalogue-items :application-url])
                             :body (str
-                                   (text-format body-text
-                                                (application-util/get-member-name recipient)
-                                                (localize-user (:event/actor-attributes event))
-                                                (format-application-for-email application)
-                                                (application-util/get-applicant-name application)
-                                                (resources-for-email application)
-                                                (link-to-application (:application/id event)))
+                                   (text-format-map body-text
+                                                    params
+                                                    [:recipient :event-actor :application-id :applicant :catalogue-items :application-url])
                                    (text :t.email/regards)
                                    (text :t.email/footer))})))]
          :when email]
@@ -208,54 +207,57 @@
 
 (defmethod event-to-emails :application.event/member-invited [event application]
   (with-language (:default-language env)
-    [{:to (:email (:application/member event))
-      :subject (text-format :t.email.member-invited/subject
-                            (:name (:application/member event))
-                            (application-util/get-applicant-name application)
-                            (format-application-for-email application)
-                            (invitation-link (:invitation/token event)))
-      :body (str
-             (text-format :t.email.member-invited/message
-                          (:name (:application/member event))
-                          (application-util/get-applicant-name application)
-                          (format-application-for-email application)
-                          (invitation-link (:invitation/token event)))
-             (text :t.email/regards)
-             (text :t.email/footer))}]))
+    (let [invited-user (:application/member event)
+          params {:applicant (application-util/get-applicant-name application)
+                  :application-id (format-application-for-email application)
+                  :invitation-url (invitation-link (:invitation/token event))
+                  :recipient (application-util/get-member-name invited-user)}]
+      [{:to (:email invited-user)
+        :subject (text-format-map :t.email.member-invited/subject
+                                  params
+                                  [:recipient :applicant :application-id :invitation-url])
+        :body (str
+               (text-format-map :t.email.member-invited/message
+                                params
+                                [:recipient :applicant :application-id :invitation-url])
+               (text :t.email/regards)
+               (text :t.email/footer))}])))
 
 (defmethod event-to-emails :application.event/reviewer-invited [event application]
-  (with-language (:default-language env)
-    [{:to (:email (:application/reviewer event))
-      :subject (text-format :t.email.reviewer-invited/subject
-                            (:name (:application/reviewer event))
-                            (application-util/get-applicant-name application)
-                            (format-application-for-email application)
-                            (invitation-link (:invitation/token event)))
-      :body (str
-             (text-format :t.email.reviewer-invited/message
-                          (:name (:application/reviewer event))
-                          (application-util/get-applicant-name application)
-                          (format-application-for-email application)
-                          (invitation-link (:invitation/token event)))
-             (text :t.email/regards)
-             (text :t.email/footer))}]))
+  (let [invited-user (:application/reviewer event)
+        params {:applicant (application-util/get-applicant-name application)
+                :application-id (format-application-for-email application)
+                :invitation-url (invitation-link (:invitation/token event))
+                :recipient (application-util/get-member-name invited-user)}]
+    (with-language (:default-language env)
+      [{:to (:email invited-user)
+        :subject (text-format-map :t.email.reviewer-invited/subject
+                                  params
+                                  [:recipient :applicant :application-id :invitation-url])
+        :body (str
+               (text-format-map :t.email.reviewer-invited/message
+                                params
+                                [:recipient :applicant :application-id :invitation-url])
+               (text :t.email/regards)
+               (text :t.email/footer))}])))
 
 (defmethod event-to-emails :application.event/decider-invited [event application]
   (with-language (:default-language env)
-    [{:to (:email (:application/decider event))
-      :subject (text-format :t.email.decider-invited/subject
-                            (:name (:application/decider event))
-                            (application-util/get-applicant-name application)
-                            (format-application-for-email application)
-                            (invitation-link (:invitation/token event)))
-      :body (str
-             (text-format :t.email.decider-invited/message
-                          (:name (:application/decider event))
-                          (application-util/get-applicant-name application)
-                          (format-application-for-email application)
-                          (invitation-link (:invitation/token event)))
-             (text :t.email/regards)
-             (text :t.email/footer))}]))
+    (let [invited-user (:application/decider event)
+          params {:applicant (application-util/get-applicant-name application)
+                  :application-id (format-application-for-email application)
+                  :invitation-url (invitation-link (:invitation/token event))
+                  :recipient (application-util/get-member-name invited-user)}]
+      [{:to (:email invited-user)
+        :subject (text-format-map :t.email.decider-invited/subject
+                                  params
+                                  [:recipient :applicant :application-id :invitation-url])
+        :body (str
+               (text-format-map :t.email.decider-invited/message
+                                params
+                                [:recipient :applicant :application-id :invitation-url])
+               (text :t.email/regards)
+               (text :t.email/footer))}])))
 
 (defmethod event-to-emails :application.event/applicant-changed [event application]
   (concat (emails-to-recipients (application-util/applicant-and-members application)
@@ -270,22 +272,21 @@
 (defmethod event-to-emails :application.event/expiration-notifications-sent [event application]
   (vec
    (let [last-activity (:event/time (application-util/get-last-applying-user-event application))]
-     (for [recipient (application-util/applicant-and-members application)]
+     (for [recipient (application-util/applicant-and-members application)
+           :let [params {:application-id (format-application-for-email application)
+                         :application-url (link-to-application (:application/id event))
+                         :expires-on (localize-utc-date (:application/expires-on event))
+                         :last-activity (localize-utc-date last-activity)
+                         :recipient (application-util/get-member-name recipient)}]]
        (with-language (:language (user-settings/get-user-settings (:userid recipient)))
          {: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)
-                                (localize-utc-date (:application/expires-on event))
-                                (link-to-application (:application/id application)))
+          :subject (text-format-map :t.email.application-expiration-notification/subject-to-member
+                                    params
+                                    [:recipient :application-id :last-activity :expires-on :application-url])
           :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)
-                              (localize-utc-date (:application/expires-on event))
-                              (link-to-application (:application/id application)))
+                 (text-format-map :t.email.application-expiration-notification/message-to-member
+                                  params
+                                  [:recipient :application-id :last-activity :expires-on :application-url])
                  (text :t.email/regards)
                  (text :t.email/footer))})))))
 
@@ -293,51 +294,54 @@
 
 (defn handler-reminder-email [lang handler applications]
   (with-language lang
-    (when (not (empty? applications))
-      (let [list (->> applications
-                      (map (fn [application]
-                             (text-format :t.email.handler-reminder/application
-                                          (format-application-for-email application)
-                                          (application-util/get-member-name (:application/applicant application)))))
-                      (str/join "\n"))]
+    (when (seq applications)
+      (let [formatted-applications (for [app applications
+                                         :let [params {:applicant (application-util/get-applicant-name app)
+                                                       :application-id (format-application-for-email app)}]]
+                                     (text-format-map :t.email.handler-reminder/application
+                                                      params
+                                                      [:application-id :applicant]))
+            params {:actions-url (str (:public-url env) "actions")
+                    :applications (str/join "\n" formatted-applications)
+                    :recipient (application-util/get-member-name handler)}]
         {:to-user (:userid handler)
          :subject (text :t.email.handler-reminder/subject)
-         :body (text-format :t.email.handler-reminder/message
-                            (application-util/get-member-name handler)
-                            list
-                            (str (:public-url env) "actions"))}))))
+         :body (text-format-map :t.email.handler-reminder/message
+                                params
+                                [:recipient :applications :actions-url])}))))
 
 (defn reviewer-reminder-email [lang reviewer applications]
   (with-language lang
-    (when (not (empty? applications))
-      (let [list (->> applications
-                      (map (fn [application]
-                             (text-format :t.email.reviewer-reminder/application
-                                          (format-application-for-email application)
-                                          (application-util/get-member-name (:application/applicant application)))))
-                      (str/join "\n"))]
+    (when (seq applications)
+      (let [formatted-applications (for [app applications
+                                         :let [params {:applicant (application-util/get-applicant-name app)
+                                                       :application-id (format-application-for-email app)}]]
+                                     (text-format-map :t.email.reviewer-reminder/application
+                                                      params
+                                                      [:application-id :applicant]))
+            params {:actions-url (str (:public-url env) "actions")
+                    :applications (str/join "\n" formatted-applications)
+                    :recipient (application-util/get-member-name reviewer)}]
         {:to-user (:userid reviewer)
          :subject (text :t.email.reviewer-reminder/subject)
-         :body (text-format :t.email.reviewer-reminder/message
-                            (application-util/get-member-name reviewer)
-                            list
-                            (str (:public-url env) "actions"))}))))
+         :body (text-format-map :t.email.reviewer-reminder/message
+                                params
+                                [:recipient :applications :actions-url])}))))
 
 (defn workflow-handler-invitation-email [lang invitation workflow]
   (with-language lang
     (when workflow
-      {:to (:invitation/email invitation)
-       :subject (text-format :t.email.workflow-handler-invitation/subject
-                             (:invitation/name invitation)
-                             (get-in invitation [:invitation/invited-by :name])
-                             (:title workflow)
-                             (invitation-link (:invitation/token invitation)))
-       :body (str
-              (text-format :t.email.workflow-handler-invitation/message
-                           (:invitation/name invitation)
-                           (get-in invitation [:invitation/invited-by :name])
-                           (:title workflow)
-                           (invitation-link (:invitation/token invitation)))
-              (text :t.email/regards)
-              (text :t.email/footer))})))
-
+      (let [params {:invited-by (get-in invitation [:invitation/invited-by :name])
+                    :invitation-url (invitation-link (:invitation/token invitation))
+                    :recipient (:invitation/name invitation)
+                    :workflow (:title workflow)}]
+        {:to (:invitation/email invitation)
+         :subject (text-format-map :t.email.workflow-handler-invitation/subject
+                                   params
+                                   [:recipient :invited-by :workflow :invitation-url])
+         :body (str
+                (text-format-map :t.email.workflow-handler-invitation/message
+                                 params
+                                 [:recipient :invited-by :workflow :invitation-url])
+                (text :t.email/regards)
+                (text :t.email/footer))}))))
diff --git a/src/clj/rems/locales.clj b/src/clj/rems/locales.clj
index 1864d2e03..83f62241c 100644
--- a/src/clj/rems/locales.clj
+++ b/src/clj/rems/locales.clj
@@ -1,11 +1,12 @@
 (ns rems.locales
   {:ns-tracker/resource-deps ["translations/da.edn" "translations/en.edn" "translations/fi.edn" "translations/sv.edn"]}
   (:require [clojure.java.io :as io]
-            [clojure.set :as set]
+            [clojure.set]
+            [clojure.test :refer [deftest is]]
             [clojure.tools.logging :as log]
             [medley.core :refer [deep-merge]]
             [mount.core :refer [defstate]]
-            [rems.common.util :refer [recursive-keys]]
+            [rems.common.util :refer [recursive-keys to-keyword]]
             [rems.config :refer [env]])
   (:import (java.io FileNotFoundException)))
 
@@ -31,25 +32,19 @@
   (let [theme-dir (.getParentFile (io/file theme-path))]
     (io/file theme-dir "extra-translations")))
 
-(defn extract-format-parameters [string]
-  (set
-   (when (string? string)
-     (re-seq #"%\d+" string))))
-
-(defn- unused-translation-keys [translations extras]
+(defn unused-translation-keys [translations extras]
   (let [keys (set (recursive-keys translations))
         extra-keys (set (recursive-keys extras))]
-    (seq (set/difference extra-keys keys))))
+    (->> (clojure.set/difference extra-keys keys)
+         (map to-keyword)
+         set)))
 
-(defn- nonmatching-format-parameters [translations extras]
-  (seq
-   (for [k (recursive-keys extras)
-         :let [params (extract-format-parameters (get-in translations k))
-               extra-params (extract-format-parameters (get-in extras k))]
-         :when (not= params extra-params)]
-     {:key k
-      :translations params
-      :extra-translations extra-params})))
+(deftest test-unused-translation-keys
+  (is (empty? (unused-translation-keys {:a {:b "x" :c "x"}}
+                                       {:a {:b "y"}})))
+  (is (= #{:x :a/d}
+         (unused-translation-keys {:a {:b "x" :c "x"}}
+                                  {:a {:b "y" :d "y"} :x "y"}))))
 
 (defn- load-translation [language translations-directory theme-path]
   (let [filename (str (name language) ".edn")
@@ -59,10 +54,8 @@
       (let [extra-file (io/file extra-path filename)
             extra-translations (translations-from-file extra-file)]
         (log/info "Loaded extra translations from" (str extra-file))
-        (when-let [unused (unused-translation-keys translations extra-translations)]
+        (when-some [unused (not-empty (unused-translation-keys translations extra-translations))]
           (log/warn "Unused translation keys defined in" (str extra-file) ":" unused))
-        (when-let [errors (nonmatching-format-parameters translations extra-translations)]
-          (log/warn "Nonmatching format parameters in" (str extra-file) ":" (pr-str errors)))
         (deep-merge {language translations} {language extra-translations}))
       {language translations})))
 
@@ -74,7 +67,3 @@
     (throw (RuntimeException. ":translations-directory was not set in config"))))
 
 (defstate translations :start (load-translations env))
-
-(defn tempura-config []
-  (assert (map? translations) {:translations translations})
-  {:dict translations})
diff --git a/src/cljc/rems/common/util.cljc b/src/cljc/rems/common/util.cljc
index 67e7c0ada..4149a0164 100644
--- a/src/cljc/rems/common/util.cljc
+++ b/src/cljc/rems/common/util.cljc
@@ -497,6 +497,21 @@
   (is (= #{[:a :b] [:a :c] [:a :d :e] [:a :d :f]}
          (recursive-keys {:a {:b 1 :c nil :d {:e "foo" :f [3]}}}))))
 
+(defn to-keyword [ks]
+  (case (count ks)
+    0 nil
+    1 (keyword (name (first ks)))
+    (let [kw-ns (->> (butlast ks)
+                     (map name)
+                     (str/join "."))]
+      (keyword kw-ns
+               (name (last ks))))))
+
+(deftest test-to-keyword
+  (is (= nil (to-keyword [])))
+  (is (= :test (to-keyword ["test"])))
+  (is (= :x.y.z/test (to-keyword ["x" "y" "z" "test"]))))
+
 (defn parse-int [s]
   #?(:clj (try
             (when s
diff --git a/src/cljc/rems/tempura.cljc b/src/cljc/rems/tempura.cljc
index 67f608b5f..8dbd5f889 100644
--- a/src/cljc/rems/tempura.cljc
+++ b/src/cljc/rems/tempura.cljc
@@ -1,21 +1,13 @@
 (ns rems.tempura
-  (:require [clojure.string]
+  (:require [better-cond.core :as b]
+            [clojure.string]
             [clojure.test :refer [deftest is testing]]
             [clojure.walk]
-            #?(:cljs [re-frame.core :as rf])
-            #?@(:clj [[rems.context :as context]
-                      [rems.locales]])
             [taoensso.tempura]))
 
-;; backtick (`) is used to escape vector argument (%)
-(defonce ^:private +map-args+ #"(?<!`)%:([^\s`%]+)%")
-(defonce ^:private +vector-args+ #"(?<!`)%\d")
-
-(deftest test-map-args
-  (is (= ["key" "my.special/namespace?"]
-         (->> "%:key% for (%:my.special/namespace?%) `%:ignored% %:also-ignored`%"
-              (re-seq +map-args+)
-              (mapv last)))))
+;; backtick (`) is used to escape parameter (%)
+(def +vector-args+ #"(?<!`)%\d")
+(def +map-args+ #"(?<!`)%:([^\s`%]+)%")
 
 (defn- replace-map-args [resource]
   (let [res-keys (atom {})
@@ -24,73 +16,102 @@
                   (when-not (contains? @res-keys k)
                     (swap! res-keys assoc k (swap! idx inc)))
                   (get @res-keys k))
+        index! (fn [match]
+                 (let [map-arg (keyword (second match))
+                       vec-arg (upsert! map-arg)]
+                   (str "%" vec-arg)))
         resource (->> resource
-                      (clojure.walk/postwalk
-                       (fn [node]
-                         (if (string? node)
-                           (clojure.string/replace node +map-args+ #(let [map-arg (keyword (second %))
-                                                                          vec-arg (upsert! map-arg)]
-                                                                      (str "%" vec-arg)))
-                           node))))]
+                      (clojure.walk/postwalk #(if (string? %)
+                                                (clojure.string/replace % +map-args+ index!)
+                                                %)))]
     {:resource resource
      :resource-keys (->> (sort-by val @res-keys)
                          (mapv key))}))
 
-(def ^:private memoized-replace-map-args (memoize replace-map-args))
-
 (deftest test-replace-map-args
   (testing "string transformation"
     (is (= {:resource "{:x %1 :y %2}"
             :resource-keys [:x :y]}
            (replace-map-args "{:x %:x% :y %:y%}"))))
-  (testing "memoized"
-    (let [memoized-f (memoize replace-map-args)]
-      (is (= {:resource "{:x %1 :y %2}"
-              :resource-keys [:x :y]}
-             (memoized-f "{:x %:x% :y %:y%}")
-             (memoized-f "{:x %:x% :y %:y%}")))))
   (testing "hiccup transformation"
     (is (= {:resource [:div {:aria-label "argument x is %1, argument y is %2"} "{:x %1 :y %2}"]
             :resource-keys [:x :y]}
            (replace-map-args [:div {:aria-label "argument x is %:x%, argument y is %:y%"} "{:x %:x% :y %:y%}"])))))
 
-(def ^:private get-resource-compiler (:resource-compiler taoensso.tempura/default-tr-opts))
+(defn find-map-params [resource]
+  (b/cond
+    :let [extract-args #(->> (re-seq +map-args+ %)
+                             (map second))]
+
+    (string? resource) (set (extract-args resource))
+    (vector? resource) (set (->> (flatten resource)
+                                 (filter string?)
+                                 (mapcat extract-args)))
+    nil))
+
+(deftest test-find-map-params
+  (is (= #{"x" "long.ns/y" "z"}
+         (find-map-params "arg %:x%, arg %:long.ns/y%, args %:x% %:long.ns/y% %:z%")
+         (find-map-params [:div "arg %:x%" [:span "arg %:long.ns/y%"] [:span "args %:x% %:long.ns/y% %:z%"]])))
+  (is (nil? (find-map-params (constantly "arg %:x%, arg %:long.ns/y%, args %:x% %:long.ns/y% %:z%"))))
+  (is (nil? (find-map-params nil))))
 
-(defn- compile-vec-args [resource vargs]
-  (let [compile-vargs (get-resource-compiler resource)]
-    (compile-vargs (vec vargs))))
+(def ^:private get-default-resource-compiler (:resource-compiler taoensso.tempura/default-tr-opts))
 
-(defn- compile-map-args [resource arg-map]
-  (let [res-map (memoized-replace-map-args resource)]
-    (compile-vec-args (:resource res-map)
-                      (map arg-map (:resource-keys res-map)))))
+(defn- get-vec-compiler [resource]
+  (let [f (get-default-resource-compiler resource)]
+    (fn compile-vec-args [vargs]
+      (let [res-args (if (map? (first vargs))
+                       (vec (rest vargs))
+                       vargs)]
+        (f res-args)))))
 
-(defn- tempura-config []
-  {:dict #?(:clj rems.locales/translations
-            :cljs @(rf/subscribe [:translations]))
-   :resource-compiler (fn [resource]
-                        (fn [vargs]
-                          (cond
-                            (map? (first vargs)) (if (re-find +vector-args+ resource)
-                                                   (compile-vec-args resource (rest vargs))
-                                                   (compile-map-args resource (first vargs)))
-                            :else (compile-vec-args resource vargs))))})
+(defn- get-map-compiler [{:keys [resource resource-keys]}]
+  (let [f (get-default-resource-compiler resource)]
+    (fn compile-map-args [vargs]
+      (assert (map? (first vargs)) {:resource resource
+                                    :vargs vargs})
+      (let [res-args (mapv (first vargs) resource-keys)]
+        (f res-args)))))
 
-(defn get-language []
-  #?(:clj context/*lang*
-     :cljs @(rf/subscribe [:language])))
+(defn- get-resource-compiler [resource]
+  (if (seq (find-map-params resource))
+    (get-map-compiler (replace-map-args resource))
+    (get-vec-compiler resource)))
 
 (defn tr
-  "When translation function is called with both map and vector arguments,
-   custom resource compiler can use either argument format for translation.
-   Argument formats cannot be mixed. When using both argument formats,
-   map argument must be given first followed by vector arguments:
+  "Wrapper for `taoensso.tempura/tr`."
+  ([translations language ks] (taoensso.tempura/tr {:dict translations}
+                                                   [language]
+                                                   (vec ks)))
+  ([translations language ks args] (taoensso.tempura/tr {:dict translations
+                                                         :resource-compiler get-resource-compiler}
+                                                        [language]
+                                                        (vec ks)
+                                                        (vec args))))
 
-   (tr [:key] [{:k :v} x1 x2])"
-  ([ks args] (taoensso.tempura/tr (tempura-config)
-                                  [(get-language)]
-                                  (vec ks)
-                                  (vec args)))
-  ([ks] (taoensso.tempura/tr (tempura-config)
-                             [(get-language)]
-                             (vec ks))))
+(deftest test-resource-compiler
+  (let [dict {:en
+              {:string {:no-args "test"
+                        :vector "%1 %2 %1"
+                        :map "%:x% %:y% %:x%"}
+               :hiccup {:no-args [:div {:aria-label "test"} [:span "test"]]
+                        :vector [:div {:aria-label "%1 %2 %1"} [:span "%1 %2 %1"]]
+                        :map [:div {:aria-label "%:x% %:y% %:x%"} [:span "%:x% %:y% %:x%"]]}}}]
+    (testing "no args"
+      (is (= "test"
+             (tr dict :en [:string/no-args])))
+      (is (= [:div {:aria-label "test"} [:span "test"]]
+             (tr dict :en [:hiccup/no-args]))))
+    (testing "index parameters"
+      (is (= "1 2 1"
+             (tr dict :en [:string/vector] [1 2 3])))
+      ;; map attributes are identified by custom resource compiler, but tempura does not use them
+      (is (= [:div {:aria-label "%1 %2 %1"} [:span "" 1 " " 2 " " 1]]
+             (tr dict :en [:hiccup/vector] [1 2 3]))))
+    (testing "named parameters"
+      (is (= "1 2 1"
+             (tr dict :en [:string/map] [{:x 1 :y 2 :z 3}])))
+      ;; map attributes are identified by custom resource compiler, but tempura does not use them
+      (is (= [:div {:aria-label "%1 %2 %1"} [:span "" 1 " " 2 " " 1]]
+             (tr dict :en [:hiccup/map] [{:x 1 :y 2 :z 3}]))))))
diff --git a/src/cljc/rems/text.cljc b/src/cljc/rems/text.cljc
index eaf405ee2..23dd689c6 100644
--- a/src/cljc/rems/text.cljc
+++ b/src/cljc/rems/text.cljc
@@ -1,11 +1,13 @@
 (ns rems.text
-  (:require [clojure.test :refer [deftest is]]
+  (:require [clojure.string :as str]
+            [clojure.test :refer [deftest is]]
             #?(:clj [clj-time.core :as time]
                :cljs [cljs-time.core :as time])
             #?(:clj [clj-time.format :as time-format]
                :cljs [cljs-time.format :as time-format])
             #?(:cljs [cljs-time.coerce :as time-coerce])
-            [clojure.string :as str]
+            #?(:clj [rems.locales]
+               :cljs [re-frame.core :as rf])
             [rems.common.application-util :as application-util]
             #?(:clj [rems.context :as context])
             [rems.tempura]))
@@ -24,26 +26,43 @@
                  (cons k args)))))
 
 (defn text-format
-  "Return the tempura translation for a given key and arguments.
-   Map can be used for named parameters as `(first args)`,
-   when the localization resource supports them. `(rest args)`
-   are then used as vector arguments if localization resource does not support
-   named parameters. See `rems.tempura/tr`
+  "Return the tempura translation for a given key and arguments:
 
-   (text-format :key {:arg :value} varg-1 varg-2 ...)"
+   `(text-format :key 1 2)`"
   [k & args]
-  #?(:clj (rems.tempura/tr [k :t/missing]
+  #?(:clj (rems.tempura/tr rems.locales/translations
+                           context/*lang*
+                           [k :t/missing]
                            args)
-     :cljs (rems.tempura/tr [k :t/missing (failsafe-fallback k args)]
+     :cljs (rems.tempura/tr @(rf/subscribe [:translations])
+                            @(rf/subscribe [:language])
+                            [k :t/missing (failsafe-fallback k args)]
                             args)))
 
+(defn text-format-map
+  "Return the tempura translation for a given key and argument map:
+
+   `(text-format-map :key {:a 1 :b 2})`
+
+   Additional vector of keys can be given to create vector arguments from map,
+   in which case resource compiler infers (from resource) which parameters to use:
+
+   `(text-format-map :key {:a 1 :b 2} [:b :a])`"
+  ([k arg-map] (text-format k arg-map))
+  ([k arg-map arg-vec] (apply text-format k arg-map (for [k arg-vec]
+                                                      (get arg-map k)))))
+
 (defn text-no-fallback
   "Return the tempura translation for a given key. Additional fallback
   keys can be given but there is no default fallback text."
   [& ks]
-  #?(:clj (rems.tempura/tr ks)
+  #?(:clj (rems.tempura/tr rems.locales/translations
+                           context/*lang*
+                           ks)
      :cljs (try
-             (rems.tempura/tr ks)
+             (rems.tempura/tr @(rf/subscribe [:translations])
+                              @(rf/subscribe [:language])
+                              ks)
              (catch js/Object e
                ;; fail gracefully if the re-frame state is incomplete
                (.error js/console e)
@@ -53,21 +72,23 @@
   "Return the tempura translation for a given key. Additional fallback
   keys can be given."
   [& ks]
-  #?(:clj (apply text-no-fallback
-                 (conj (vec ks)
-                       (text-format :t/missing (vec ks))))
+  #?(:clj (rems.tempura/tr rems.locales/translations
+                           context/*lang*
+                           (conj (vec ks) (text-format :t/missing (vec ks))))
      ;; NB: we can't call the text-no-fallback here as in CLJS
      ;; we can both call this as function or use as a React component
      :cljs (try
-             (rems.tempura/tr (conj (vec ks)
-                                    (text-format :t/missing (vec ks))))
+             (rems.tempura/tr @(rf/subscribe [:translations])
+                              @(rf/subscribe [:language])
+                              (conj (vec ks) (text-format :t/missing (vec ks))))
              (catch js/Object e
                ;; fail gracefully if the re-frame state is incomplete
                (.error js/console e)
                (str (vec ks))))))
 
 (defn localized [m]
-  (let [lang (rems.tempura/get-language)]
+  (let [lang #?(:clj context/*lang*
+                :cljs @(rf/subscribe [:language]))]
     (or (get m lang)
         (first (vals m)))))
 
diff --git a/src/cljs/rems/actions.cljs b/src/cljs/rems/actions.cljs
index e286625ed..d9c7a2463 100644
--- a/src/cljs/rems/actions.cljs
+++ b/src/cljs/rems/actions.cljs
@@ -8,7 +8,7 @@
             [rems.flash-message :as flash-message]
             [rems.profile :as profile]
             [rems.search :as search]
-            [rems.text :refer [text text-format]]))
+            [rems.text :refer [text text-format text-format-map]]))
 
 (rf/reg-event-fx
  ::enter-page
@@ -48,7 +48,7 @@
         :open? true
         :title (text :t.actions/todo-applications)
         :bottom-less-button? false
-        :always [:div (text-format :t.actions/todo-applications-count {:count (some-> @(rf/subscribe [::todo-applications :data]) count)})]
+        :always [:div (text-format-map :t.actions/todo-applications-count {:count (some-> @(rf/subscribe [::todo-applications :data]) count)})]
         :collapse [:<>
                    [search/application-search-field {:id "todo-search"
                                                      :on-search #(rf/dispatch [::todo-applications {:query %}])
@@ -64,7 +64,7 @@
                       (rf/dispatch [::handled-applications-count]))
         :top-less-button? false
         :bottom-less-button? false
-        :always (when handled-count [:div [:p (text-format :t.actions/handled-applications-count {:count handled-count})]])
+        :always (when handled-count [:div [:p (text-format-map :t.actions/handled-applications-count {:count handled-count})]])
         :collapse (when (and handled-count (pos? handled-count)) ; is there anything to show?
                     [:<>
                      [search/application-search-field {:id "handled-search"


Test patch:
diff --git a/test-data/test-theme/extra-translations/da.edn b/test-data/test-theme/extra-translations/da.edn
new file mode 100644
index 000000000..9e26dfeeb
--- /dev/null
+++ b/test-data/test-theme/extra-translations/da.edn
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
diff --git a/test-data/test-theme/extra-translations/en.edn b/test-data/test-theme/extra-translations/en.edn
new file mode 100644
index 000000000..2268da149
--- /dev/null
+++ b/test-data/test-theme/extra-translations/en.edn
@@ -0,0 +1,6 @@
+{:t
+ {:footer "CSC – Overridden in Extra Translations"
+  :unused-key "Unused"
+  :applications {:view-application-without-description "View application %:application/id% for resources: %2"
+                 :view-application-with-description "View application %1: %:application/description%"}
+  :actions {:errors {:invalid-token [:div [:p "Joining application failed."] [:p "Check the invitation token."]]}}}}
diff --git a/test-data/test-theme/extra-translations/fi.edn b/test-data/test-theme/extra-translations/fi.edn
new file mode 100644
index 000000000..12d7172cd
--- /dev/null
+++ b/test-data/test-theme/extra-translations/fi.edn
@@ -0,0 +1,1 @@
+{:t {}}
diff --git a/test-data/test-theme/extra-translations/sv.edn b/test-data/test-theme/extra-translations/sv.edn
new file mode 100644
index 000000000..12d7172cd
--- /dev/null
+++ b/test-data/test-theme/extra-translations/sv.edn
@@ -0,0 +1,1 @@
+{:t {}}
diff --git a/test-data/test-theme/theme.edn b/test-data/test-theme/theme.edn
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/test-data/test-theme/theme.edn
@@ -0,0 +1,1 @@
+{}
diff --git a/test/clj/rems/test_locales.clj b/test/clj/rems/test_locales.clj
index e47995ffa..4f81afce5 100644
--- a/test/clj/rems/test_locales.clj
+++ b/test/clj/rems/test_locales.clj
@@ -5,21 +5,15 @@
             [clojure.string :as str]
             [clojure.test :refer [deftest is testing]]
             [clojure.tools.logging]
+            [clojure.tools.logging.test :as log-test]
             [rems.common.util :refer [recursive-keys]]
             [rems.locales :as locales]
             [rems.tempura]
             [rems.testing-util :refer [create-temp-dir]]
             [rems.util :refer [getx-in delete-directory-recursively]]
-            [taoensso.tempura.impl :refer [compile-dictionary]])
+            [taoensso.tempura.impl])
   (:import (java.io FileNotFoundException)))
 
-(deftest test-unused-translation-keys
-  (let [unused-translation-keys #'rems.locales/unused-translation-keys]
-    (is (= nil (unused-translation-keys {:a {:b "x" :c "x"}} {:a {:b "y"}})))
-    (is (= (set [[:x] [:a :d]])
-           (set (unused-translation-keys {:a {:b "x" :c "x"}}
-                                         {:a {:b "y" :d "y"} :x "y"}))))))
-
 (defn loc-da []
   (read-string (slurp (io/resource "translations/da.edn"))))
 
@@ -33,9 +27,9 @@
   (read-string (slurp (io/resource "translations/sv.edn"))))
 
 (deftest test-all-languages-defined
-  #_(is (= (recursive-keys (loc-en))
-           (recursive-keys (loc-da)))
-        "en matches da")
+  (is (= (recursive-keys (loc-en))
+         (recursive-keys (loc-da)))
+      "en matches da")
   (is (= (recursive-keys (loc-en))
          (recursive-keys (loc-fi)))
       "en matches fi")
@@ -43,38 +37,6 @@
          (recursive-keys (loc-sv)))
       "en matches sv"))
 
-(deftest test-extract-format-parameters
-  (is (= #{} (locales/extract-format-parameters "hey you are 100% correct!")))
-  (is (= #{"%3" "%5" "%7"} (locales/extract-format-parameters "user %3 has made %7 alterations in %5!"))))
-
-(deftest test-format-parameters-match
-  (let [en (loc-en)
-        da (loc-da)
-        fi (loc-fi)
-        sv (loc-sv)
-        translation-keys (recursive-keys en)]
-    #_(testing "[:en vs :da]"
-        (doseq [ks translation-keys]
-          (testing ks
-            (is (= (locales/extract-format-parameters (getx-in en (vec ks)))
-                   (locales/extract-format-parameters (getx-in da (vec ks)))))
-            (is (= (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in en (vec ks)))))
-                   (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in da (vec ks))))))))))
-    (testing "[:en vs :fi]"
-      (doseq [ks translation-keys]
-        (testing ks
-          (is (= (locales/extract-format-parameters (getx-in en (vec ks)))
-                 (locales/extract-format-parameters (getx-in fi (vec ks)))))
-          (is (= (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in en (vec ks)))))
-                 (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in fi (vec ks))))))))))
-    (testing "[:en vs :sv]"
-      (doseq [ks translation-keys]
-        (testing ks
-          (is (= (locales/extract-format-parameters (getx-in en (vec ks)))
-                 (locales/extract-format-parameters (getx-in sv (vec ks)))))
-          (is (= (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in en (vec ks)))))
-                 (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in sv (vec ks))))))))))))
-
 (defn- translation-keywords-in-use []
   ;; git grep would be nice, but circleci's git grep doesn't have -o
   ;; --include is needed to exclude editor backup files etc.
@@ -92,10 +54,10 @@
     (assert (seq keys-in-source))
     (doseq [lang [:en :fi :sv]]
       (testing lang
-        (let [dictionary (->> (locales/load-translations {:languages [lang]
-                                                          :translations-directory "translations/"})
-                              lang
-                              (compile-dictionary false))
+        (let [dictionary (-> (locales/load-translations {:languages [lang]
+                                                         :translations-directory "translations/"})
+                             lang
+                             taoensso.tempura.impl/compile-dictionary)
               keys-in-dictionary (set (keys dictionary))]
           (assert (seq keys-in-dictionary))
           (testing "dictionary is missing translations"
@@ -140,25 +102,19 @@
                                                       :languages [:xx]})))))
 
 (deftest override-translations-with-extra-translations
-  (let [log (atom [])
-        ;; redeffing log* is hacky, could instead set *logger-factory* to a fake logger
-        translations (with-redefs [clojure.tools.logging/log* (fn [_ _ _ msg] (swap! log conj (str msg)))]
-                       (locales/load-translations {:languages [:en]
+  (log-test/with-log
+    (let [translations (locales/load-translations {:languages [:en]
                                                    :translations-directory "translations/"
-                                                   :theme-path "./example-theme/theme.edn"}))]
-    (testing "extra translations override translations"
-      (is (= "CSC – Overridden in Extra Translations" (getx-in translations [:en :t :footer])))
-      (is (= "Text %1" (getx-in translations [:en :t :create-license :license-text]))))
-    (testing "extra translations don't override keys that are not defined in extras"
-      (is (= "Active" (getx-in translations [:en :t :administration :active]))))
-    (testing "warnings"
-      (is (< 0 (count @log)))
-      (testing "for unused key"
-        (is (some #(.contains % ":unused-key") @log)
-            (pr-str @log)))
-      (testing "for extra format parameters"
-        (is (some #(.contains % "%1") @log)
-            (pr-str @log))))))
+                                                   :theme-path "./test-data/test-theme/theme.edn"})]
+      (testing "extra translations override translations"
+        (is (= "CSC – Overridden in Extra Translations" (getx-in translations [:en :t :footer]))))
+      (testing "extra translations don't override keys that are not defined in extras"
+        (is (= "Active" (getx-in translations [:en :t :administration :active]))))
+      (testing "warnings from unused translation keys"
+        (is (log-test/logged? "rems.locales"
+                              :warn
+                              "Unused translation keys defined in ./test-data/test-theme/extra-translations/en.edn : #{:t/unused-key}")
+            {:log (log-test/the-log)})))))
 
 (deftest theme-path-given-no-extra-translations
   (testing "translations work with theme-path in config and no extra-translations"