Repository: CSCfi/rems (clojure)

Issue:
vTHL2: sending emails based on events
A job that reads application events and sends emails based on them. See [docs/architecture/002-event-side-effects.md](docs/architecture/002-event-side-effects.md)

- [x] email to applicant when application is approved / rejected
- [x] email to applicant when application is closed
- [x] email to commenter/decider when comment/decision is requestd
- [x] email to handler when comment/decision arrives
- [x] same emails to additional members as to applicant
- [x] invitation emails for invited members

Pull request:
closes #652

Patch:
diff --git a/dev-config.edn b/dev-config.edn
index 1457d50df1..59960fdcd9 100644
--- a/dev-config.edn
+++ b/dev-config.edn
@@ -3,4 +3,5 @@
  :database-url "postgresql://localhost/rems?user=rems"
  :theme-path "example-theme/theme.edn"
  :authentication :fake-shibboleth
+ :public-url "http://localhost:3000/"
  :render-css-file? true}
diff --git a/resources/config-defaults.edn b/resources/config-defaults.edn
index 0a559b59f7..f0c3743b8c 100644
--- a/resources/config-defaults.edn
+++ b/resources/config-defaults.edn
@@ -22,6 +22,7 @@
  :smtp-host nil
  :smtp-port 25
  :mail-from nil
+ :public-url nil ;; Url for this REMS installation, for generating links in emails. Should end with a /.
 
  ;; URLs to notify about granted and revoked entitlements.
  :entitlements-target {:add nil
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 2abab90c73..091b362ed5 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -263,22 +263,24 @@
                     :rounds-workflow-description "The application will flow through a predetermined list of review and approval rounds."
                     :title "Title"}
   :email {:application-approved {:subject "Your application has been approved"
-                                 :message "Dear %1,\nYour application %2 has been approved."}
+                                 :message "Dear %1,\nYour application %2 has been approved.\nView your application: %3"}
           :application-rejected {:subject "Your application has been rejected"
-                                 :message "Dear %1,\nYour application %2 has been rejected."}
+                                 :message "Dear %1,\nYour application %2 has been rejected.\nView your application: %3"}
+          :application-closed {:subject "Your application has been closed"
+                               :message "Dear %1,\nYour application %2 has been closed.\nView your application: %3"}
           :comment-requested {:subject "Comment request"
-                              :message "Dear %1,\nUser %2 has requested your comment on application %3."}
+                              :message "Dear %1,\nUser %2 has requested your comment on application %3.\nComment here: %4"}
           :commented {:subject "New comment notification"
-                      :message "Dear %1,\nUser %2 has posted a comment on application %3."}
+                      :message "Dear %1,\nUser %2 has posted a comment on application %3.\nView the application: %4"}
           :decided {:subject "New decision notification"
-                    :message "Dear %1,\nUser %2 has sent a decision on application %3."}
+                    :message "Dear %1,\nUser %2 has sent a decision on application %3.\nView the application: %4"}
           :decision-requested {:subject "Decision request"
-                               :message "Dear %1,\nUser %2 has requested your decision on application %3."}
+                               :message "Dear %1,\nUser %2 has requested your decision on application %3.\nView the application: %4"}
           :footer "\nPlease do not reply to this automatically generated message."
           :member-added {:subject "You've been added as a member to an application"
-                         :message "Dear %1,\nYou've been added as a member to application %2."}
+                         :message "Dear %1,\nYou've been added as a member to application %2.\nView the application: %3"}
           :member-invited {:subject "Invitation to participate in an application"
-                           :message "Hello,\nThis email address (%1) has been invited to participate in an application.\nYour code: %2"}}
+                           :message "Hello,\nThis email address (%1) has been invited to participate in an application.\nParticipate with this link: %2"}}
   :footer "Powered by CSC - IT Center for Science"
   :form {:actions "Actions"
          :add-comments "Add comment"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index 51b9b31d39..6ecf1885e9 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -262,24 +262,25 @@
                     :rounds-workflow "Kierrospohjainen työvuo"
                     :rounds-workflow-description "Hakemus valuu ennaltapäätettyjen katselmointi- ja hyväksymiskierrosten läpi."
                     :title "Työvuon nimi"}
-  :email {;; TODO translate these? emails are always sent in English currently
-          :application-approved {:subject "Your application has been approved"
-                                 :message "Dear %1,\nYour application %2 has been approved."}
-          :application-rejected {:subject "Your application has been rejected"
-                                 :message "Dear %1,\nYour application %2 has been rejected."}
-          :comment-requested {:subject "Comment request"
-                              :message "Dear %1,\nUser %2 has requested your comment on application %3."}
-          :commented {:subject "New comment notification"
-                      :message "Dear %1,\nUser %2 has posted a comment on application %3."}
-          :decided {:subject "New decision notification"
-                    :message "Dear %1,\nUser %2 has sent a decision on application %3."}
-          :decision-requested {:subject "Decision request"
-                               :message "Dear %1,\nUser %2 has requested your decision on application %3."}
-          :footer "\nPlease do not reply to this automatically generated message."
-          :member-added {:subject "You've been added as a member to an application"
-                         :message "Dear %1,\nYou've been added as a member to application %2."}
-          :member-invited {:subject "Invitation to participate in an application"
-                           :message "Hello,\nThis email address (%1) has been invited to participate in an application.\nYour code: %2"}}
+  :email {:application-approved {:subject "Hakemuksesi on hyväksytty"
+                                 :message "Hyvä %1,\nHakemuksesi %2 on hyväksytty.\nKatsele hakemusta: %3"}
+          :application-rejected {:subject "Hakemuksesi on hylätty"
+                                 :message "Hyvä %1,\nHakemuksesi %2 on hylätty.\nKatsele hakemusta: %3"}
+          :application-closed {:subject "Hakemuksesi on suljettu"
+                               :message "Hyvä %1,\nHakemuksesi %2 on suljettu.\nKatsele hakemusta: %3"}
+          :comment-requested {:subject "Kommenttipyyntö"
+                              :message "Hyvä %1,\nKäyttäjä %2 on pyytänyt sinulta kommenttia hakemukseen %3.\nKommentoi täällä: %4"}
+          :commented {:subject "Uusi kommentti"
+                      :message "Hyvä %1,\nKäyttäjä %2 on kommentoinut hakemusta %3.\nKatsele hakemusta: %4"}
+          :decided {:subject "Uusi päätös"
+                    :message "Hyvä %1,\nKäyttäjä %2 on tehnyt päätöksen hakemuksesta %3.\nKatsele hakemusta: %4"}
+          :decision-requested {:subject "Päätöspyyntö"
+                               :message "Hyvä %1,\nKäyttäjä %2 on pyytänyt sinulta päätöstä hakemukseen %3.\nKatsele hakemusta: %4"}
+          :footer "\nTämä on automaattinen viesti. Älä vastaa tähän."
+          :member-added {:subject "Sinut on lisätty hakemukseen"
+                         :message "Hyvä %1,\nSinut on lisätty jäseneksi hakemukseen %2.\nKatsele hakemusta: %3"}
+          :member-invited {:subject "Kutsu osallistumaan hakemukseen"
+                           :message "Hei,\nTämä sähköpostiosoite (%1) on kutsuttu osallistumaan hakemukseen.\nOsallistumislinkki: %2"}}
   :footer "CSC - IT Center for Science"
   :form {:actions "Toiminnot"
          :add-comments "Lisää kommentti"
diff --git a/src/clj/rems/config.clj b/src/clj/rems/config.clj
index 0de42d43c9..3ef0b60eed 100644
--- a/src/clj/rems/config.clj
+++ b/src/clj/rems/config.clj
@@ -23,9 +23,17 @@
                  :theme-static-resources (file-sibling file "public")})
     config))
 
+;; if we start doing more thorough validation, could use a schema instead
+(defn validate-config [config]
+  (when-let [url (:public-url config)]
+    (assert (.endsWith url "/")
+            (str ":public-url should end with /:" (pr-str url))))
+  config)
+
 (defstate env :start (-> (load-config :resource "config-defaults.edn"
                                       ;; If the "rems.config" system property is not defined, the :file parameter will
                                       ;; fall back to using the "conf" system property (hard-coded in cprop).
                                       ;; If neither system property is defined, the :file parameter is silently ignored.
                                       :file (System/getProperty "rems.config"))
-                         (load-external-theme)))
+                         (load-external-theme)
+                         (validate-config)))
diff --git a/src/clj/rems/poller/email.clj b/src/clj/rems/poller/email.clj
index fb5b143814..d1a59776cf 100644
--- a/src/clj/rems/poller/email.clj
+++ b/src/clj/rems/poller/email.clj
@@ -15,10 +15,16 @@
 
 ;;; Mapping events to emails
 
-;; TODO link to application?
 ;; TODO list of resources?
 ;; TODO use real name when addressing user?
 
+;; move this to a util namespace if its needed somewhere else
+(defn- link-to-application [application-id]
+  (str (:public-url env) "#/application/" application-id))
+
+(defn- invitation-link [token]
+  (str (:public-url env) "accept-invitation?token=" token))
+
 (defmulti ^:private event-to-emails-impl
   (fn [event _application] (:event/type event)))
 
@@ -32,7 +38,8 @@
       :subject (text :t.email.application-approved/subject)
       :body (text-format :t.email.application-approved/message
                          (:userid member)
-                         (:id application))})))
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
 
 (defmethod event-to-emails-impl :application.event/rejected [event application]
   (vec
@@ -41,7 +48,18 @@
       :subject (text :t.email.application-rejected/subject)
       :body (text-format :t.email.application-rejected/message
                          (:userid member)
-                         (:id application))})))
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
+
+(defmethod event-to-emails-impl :application.event/closed [event application]
+  (vec
+   (for [member (:members application)] ;; applicant is a member
+     {:to-user (:userid member)
+      :subject (text :t.email.application-closed/subject)
+      :body (text-format :t.email.application-closed/message
+                         (:userid member)
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
 
 (defmethod event-to-emails-impl :application.event/comment-requested [event _application]
   (vec
@@ -51,7 +69,8 @@
       :body (text-format :t.email.comment-requested/message
                          commenter
                          (:event/actor event)
-                         (:application/id event))})))
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
 
 (defmethod event-to-emails-impl :application.event/decision-requested [event _application]
   (vec
@@ -61,7 +80,8 @@
       :body (text-format :t.email.decision-requested/message
                          decider
                          (:event/actor event)
-                         (:application/id event))})))
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
 
 (defmethod event-to-emails-impl :application.event/commented [event application]
   (vec
@@ -71,7 +91,8 @@
       :body (text-format :t.email.commented/message
                          handler
                          (:event/actor event)
-                         (:application/id event))})))
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
 
 (defmethod event-to-emails-impl :application.event/decided [event application]
   (vec
@@ -81,7 +102,8 @@
       :body (text-format :t.email.decided/message
                          handler
                          (:event/actor event)
-                         (:application/id event))})))
+                         (:application/id event)
+                         (link-to-application (:application/id event)))})))
 
 (defmethod event-to-emails-impl :application.event/member-added [event _application]
   ;; TODO email to applicant? email to handler?
@@ -89,15 +111,15 @@
     :subject (text :t.email.member-added/subject)
     :body (text-format :t.email.member-added/message
                        (:userid (:application/member event))
-                       (:application/id event))}])
+                       (:application/id event)
+                       (link-to-application (:application/id event)))}])
 
 (defmethod event-to-emails-impl :application.event/member-invited [event _application]
   [{:to (:email (:application/member event))
     :subject (text :t.email.member-invited/subject)
     :body (text-format :t.email.member-invited/message
                        (:email (:application/member event))
-                       ;; TODO the actual invitation link!
-                       (:invitation/token event))}])
+                       (invitation-link (:invitation/token event)))}])
 
 ;; TODO member-joined?
 
@@ -191,7 +213,8 @@
 (defn send-email! [email-spec]
   (let [host (:smtp-host env)
         port (:smtp-port env)]
-    (if (and host port)
+    (if (not (and host port))
+      (log/info "pretending to send email:" (pr-str email-spec))
       (let [email (assoc email-spec
                          :from (:mail-from env)
                          :body (str (:body email-spec)
@@ -202,13 +225,11 @@
                                    (:to-user email-spec)))))]
         ;; TODO check that :to is set
         (log/info "sending email:" (pr-str email))
-        (postal/send-message {:host host :port port} email))
-      (do
-        (log/info "pretending to send email:" (pr-str email-spec))))))
+        (postal/send-message {:host host :port port} email)))))
 
 (defn run []
   (run-event-poller ::poller (fn [event]
-                               (with-language :en
+                               (with-language (:default-language env)
                                  #(doseq [mail (event-to-emails event)]
                                     (send-email! mail))))))
 


Test patch:
diff --git a/test-config.edn b/test-config.edn
index 0b45eac06f..688e09d5c5 100644
--- a/test-config.edn
+++ b/test-config.edn
@@ -2,4 +2,5 @@
  :port 3001
  :nrepl-port 7001
  :database-url "postgresql://localhost/rems_test?user=rems_test"
- :authentication :fake-shibboleth}
+ :authentication :fake-shibboleth
+ :public-url "http://localhost:3001/"}
diff --git a/test/clj/rems/test/poller/email.clj b/test/clj/rems/test/poller/email.clj
index cabef22022..80e78a96c1 100644
--- a/test/clj/rems/test/poller/email.clj
+++ b/test/clj/rems/test/poller/email.clj
@@ -14,96 +14,115 @@
     (f)
     (mount/stop)))
 
+(defn events-to-emails [events]
+  (let [application (dynamic/apply-events nil events)]
+    (text/with-language :en
+      (fn [] (mapv #(#'rems.poller.email/event-to-emails-impl % application) events)))))
+
 (deftest test-event-to-emails-impl
-  (let [events [{:application/id 7
-                 :event/type :application.event/created
-                 :event/actor "applicant"
-                 :workflow/type :workflow/dynamic
-                 :workflow.dynamic/handlers #{"handler" "assistant"}}
-                {:application/id 7
-                 :event/type :application.event/submitted
-                 :event/actor "applicant"}
-                {:application/id 7
-                 :event/type :application.event/member-invited
-                 :event/actor "applicant"
-                 :application/member {:name "Some Body" :email "somebody@example.com"}
-                 :invitation/token "abc"}
-                {:application/id 7
-                 :event/type :application.event/comment-requested
-                 :application/request-id "r1"
-                 :application/commenters ["commenter1" "commenter2"]}
-                {:application/id 7
-                 :event/type :application.event/member-joined
-                 :event/actor "somebody"}
-                {:application/id 7
-                 :event/type :application.event/commented
-                 :event/actor "commenter2"
-                 :application/request-id "r1"
-                 :application/comment ["this is a comment"]}
-                {:application/id 7
-                 :event/type :application.event/member-added
-                 :event/actor "handler"
-                 :application/member {:userid "member"}}
-                {:application/id 7
-                 :event/type :application.event/decision-requested
-                 :event/actor "assistant"
-                 :application/request-id "r2"
-                 :application/deciders ["decider"]}
-                {:application/id 7
-                 :event/type :application.event/decided
-                 :event/actor "decider"
-                 :application/decision :approved}
-                {:application/id 7
-                 :event/type :application.event/approved
-                 :event/actor "handler"}]
-        application (dynamic/apply-events nil events)]
-    (is (= [[] ;; created
-            [] ;; submitted
-            [{:to "somebody@example.com",
-              :subject "Invitation to participate in an application",
-              :body
-              "Hello,\nThis email address (somebody@example.com) has been invited to participate in an application.\nYour code: abc"}]
-            [{:to-user "commenter1",
-              :subject "Comment request",
-              :body
-              "Dear commenter1,\nUser  has requested your comment on application 7."}
-             {:to-user "commenter2",
-              :subject "Comment request",
-              :body
-              "Dear commenter2,\nUser  has requested your comment on application 7."}]
-            [] ;; member-joined
-            [{:to-user "handler",
-              :subject "New comment notification",
-              :body
-              "Dear handler,\nUser commenter2 has posted a comment on application 7."}
-             {:to-user "assistant",
-              :subject "New comment notification",
-              :body
-              "Dear assistant,\nUser commenter2 has posted a comment on application 7."}]
-            [{:to-user "member",
-              :subject "You've been added as a member to an application",
-              :body
-              "Dear member,\nYou've been added as a member to application 7."}]
-            [{:to-user "decider",
-              :subject "Decision request",
-              :body
-              "Dear decider,\nUser assistant has requested your decision on application 7."}]
-            [{:to-user "handler",
-              :subject "New decision notification",
-              :body
-              "Dear handler,\nUser decider has sent a decision on application 7."}
-             {:to-user "assistant",
-              :subject "New decision notification",
-              :body
-              "Dear assistant,\nUser decider has sent a decision on application 7."}]
-            [{:to-user "applicant",
-              :subject "Your application has been approved",
-              :body "Dear applicant,\nYour application  has been approved."}
-             {:to-user "somebody",
-              :subject "Your application has been approved",
-              :body "Dear somebody,\nYour application  has been approved."}
-             {:to-user "member",
-              :subject "Your application has been approved",
-              :body "Dear member,\nYour application  has been approved."}]]
-           (text/with-language :en
-             (fn [] (mapv #(#'rems.poller.email/event-to-emails-impl % application) events)))))))
+  (let [base-events [{:application/id 7
+                      :event/type :application.event/created
+                      :event/actor "applicant"
+                      :workflow/type :workflow/dynamic
+                      :workflow.dynamic/handlers #{"handler" "assistant"}}
+                     {:application/id 7
+                      :event/type :application.event/submitted
+                      :event/actor "applicant"}]]
+    (let [events (into
+                  base-events
+                  [{:application/id 7
+                    :event/type :application.event/member-invited
+                    :event/actor "applicant"
+                    :application/member {:name "Some Body" :email "somebody@example.com"}
+                    :invitation/token "abc"}
+                   {:application/id 7
+                    :event/type :application.event/comment-requested
+                    :event/actor "handler"
+                    :application/request-id "r1"
+                    :application/commenters ["commenter1" "commenter2"]}
+                   {:application/id 7
+                    :event/type :application.event/member-joined
+                    :event/actor "somebody"}
+                   {:application/id 7
+                    :event/type :application.event/commented
+                    :event/actor "commenter2"
+                    :application/request-id "r1"
+                    :application/comment ["this is a comment"]}
+                   {:application/id 7
+                    :event/type :application.event/member-added
+                    :event/actor "handler"
+                    :application/member {:userid "member"}}
+                   {:application/id 7
+                    :event/type :application.event/decision-requested
+                    :event/actor "assistant"
+                    :application/request-id "r2"
+                    :application/deciders ["decider"]}
+                   {:application/id 7
+                    :event/type :application.event/decided
+                    :event/actor "decider"
+                    :application/decision :approved}
+                   {:application/id 7
+                    :event/type :application.event/approved
+                    :event/actor "handler"}
+                   {:application/id 7
+                    :event/type :application.event/closed
+                    :event/actor "assistant"}])]
+      (is (= [[]
+              []
+              [{:to "somebody@example.com",
+                :subject "Invitation to participate in an application",
+                :body "Hello,\nThis email address (somebody@example.com) has been invited to participate in an application.\nParticipate with this link: http://localhost:3001/accept-invitation?token=abc"}]
+              [{:to-user "commenter1",
+                :subject "Comment request",
+                :body "Dear commenter1,\nUser handler has requested your comment on application 7.\nComment here: http://localhost:3001/#/application/7"}
+               {:to-user "commenter2",
+                :subject "Comment request",
+                :body "Dear commenter2,\nUser handler has requested your comment on application 7.\nComment here: http://localhost:3001/#/application/7"}]
+              []
+              [{:to-user "handler",
+                :subject "New comment notification",
+                :body "Dear handler,\nUser commenter2 has posted a comment on application 7.\nView the application: http://localhost:3001/#/application/7"}
+               {:to-user "assistant",
+                :subject "New comment notification",
+                :body "Dear assistant,\nUser commenter2 has posted a comment on application 7.\nView the application: http://localhost:3001/#/application/7"}]
+              [{:to-user "member",
+                :subject "You've been added as a member to an application",
+                :body "Dear member,\nYou've been added as a member to application 7.\nView the application: http://localhost:3001/#/application/7"}]
+              [{:to-user "decider",
+                :subject "Decision request",
+                :body "Dear decider,\nUser assistant has requested your decision on application 7.\nView the application: http://localhost:3001/#/application/7"}]
+              [{:to-user "handler",
+                :subject "New decision notification",
+                :body "Dear handler,\nUser decider has sent a decision on application 7.\nView the application: http://localhost:3001/#/application/7"}
+               {:to-user "assistant",
+                :subject "New decision notification",
+                :body "Dear assistant,\nUser decider has sent a decision on application 7.\nView the application: http://localhost:3001/#/application/7"}]
+              [{:to-user "applicant",
+                :subject "Your application has been approved",
+                :body "Dear applicant,\nYour application 7 has been approved.\nView your application: http://localhost:3001/#/application/7"}
+               {:to-user "somebody",
+                :subject "Your application has been approved",
+                :body "Dear somebody,\nYour application 7 has been approved.\nView your application: http://localhost:3001/#/application/7"}
+               {:to-user "member",
+                :subject "Your application has been approved",
+                :body "Dear member,\nYour application 7 has been approved.\nView your application: http://localhost:3001/#/application/7"}]
+              [{:to-user "applicant",
+                :subject "Your application has been closed",
+                :body "Dear applicant,\nYour application 7 has been closed.\nView your application: http://localhost:3001/#/application/7"}
+               {:to-user "somebody",
+                :subject "Your application has been closed",
+                :body "Dear somebody,\nYour application 7 has been closed.\nView your application: http://localhost:3001/#/application/7"}
+               {:to-user "member",
+                :subject "Your application has been closed",
+                :body "Dear member,\nYour application 7 has been closed.\nView your application: http://localhost:3001/#/application/7"}]]
+             (events-to-emails events))))
+    (let [events (conj base-events
+                      {:application/id 7
+                       :event/type :application.event/rejected
+                       :event/actor "handler"})]
+      (is (= [[]
+              []
+              [{:subject "Your application has been rejected",
+                :body "Dear applicant,\nYour application 7 has been rejected.\nView your application: http://localhost:3001/#/application/7",
+                :to-user "applicant"}]]
+             (events-to-emails events))))))