diff --git a/src/metabase/channel/core.clj b/src/metabase/channel/core.clj
index c2641c50a82740eebfec655c15c9ef6aa7922417..f819e300cdf4fb1c60ea626a0a96d0e97dee1974 100644
--- a/src/metabase/channel/core.clj
+++ b/src/metabase/channel/core.clj
@@ -3,9 +3,7 @@
 
   The API is still in development and subject to change."
   (:require
-   [metabase.plugins.classloader :as classloader]
-   [metabase.util :as u]
-   [metabase.util.log :as log]))
+   [metabase.util :as u]))
 
 (set! *warn-on-reflection* true)
 
@@ -29,14 +27,14 @@
     channel-type))
 
 (defmulti render-notification
-  "Given a notification info, return a sequence of channel-specific messages.
+  "Given a notification payload, return a sequence of channel-specific messages.
 
   The message format is channel-specific, one requirement is that it should be the same format that
   the [[send!]] multimethod expects."
   {:added    "0.51.0"
-   :arglists '([channel-type notification-info template recipients])}
-  (fn [channel-type notification-info _template _recipients]
-    [channel-type (:payload_type notification-info)]))
+   :arglists '([channel-type notification-payload template recipients])}
+  (fn [channel-type notification-payload _template _recipients]
+    [channel-type (:payload_type notification-payload)]))
 
 (defmulti send!
   "Send a message to a channel."
@@ -46,13 +44,7 @@
     (:type channel)))
 
 ;; ------------------------------------------------------------------------------------------------;;
-;;                                             Utils                                               ;;
+;;                                    Load the implementations                                     ;;
 ;; ------------------------------------------------------------------------------------------------;;
-
-(defn find-and-load-metabase-channels!
-  "Load namespaces that start with `metabase.channel."
-  []
-  (doseq [ns-symb u/metabase-namespace-symbols
-          :when   (.startsWith (name ns-symb) "metabase.channel.")]
-    (log/info "Loading channel namespace:" (u/format-color :blue ns-symb))
-    (classloader/require ns-symb)))
+(when-not *compile-files*
+  (u/find-and-load-namespaces! "metabase.channel.impl"))
diff --git a/src/metabase/channel/email.clj b/src/metabase/channel/impl/email.clj
similarity index 90%
rename from src/metabase/channel/email.clj
rename to src/metabase/channel/impl/email.clj
index 4a3b5f5d6b45e930c78d4e8f672de31ff44dc7aa..5d4b504d971ab2e437dff8887d9bd00ff1cfcbf7 100644
--- a/src/metabase/channel/email.clj
+++ b/src/metabase/channel/impl/email.clj
@@ -1,4 +1,4 @@
-(ns metabase.channel.email
+(ns metabase.channel.impl.email
   (:require
    [metabase.channel.core :as channel]
    [metabase.channel.params :as channel.params]
@@ -7,7 +7,6 @@
    [metabase.email.messages :as messages]
    [metabase.models.channel :as models.channel]
    [metabase.models.notification :as models.notification]
-   [metabase.notification.core :as notification]
    [metabase.util :as u]
    [metabase.util.i18n :refer [trs]]
    [metabase.util.malli :as mu]
@@ -120,7 +119,7 @@
 ;; ------------------------------------------------------------------------------------------------;;
 
 (defn- notification-recipients->emails
-  [recipients payload]
+  [recipients notification-payload]
   (into [] cat (for [recipient recipients
                      :let [details (:details recipient)
                            emails (case (:type recipient)
@@ -131,7 +130,7 @@
                                     :notification-recipient/external-email
                                     [(:email details)]
                                     :notification-recipient/template
-                                    [(not-empty (channel.params/substitute-params (:pattern details) payload :ignore-missing? (:is_optional details)))]
+                                    [(not-empty (channel.params/substitute-params (:pattern details) notification-payload :ignore-missing? (:is_optional details)))]
                                     nil)]
                      :let  [emails (filter some? emails)]
                      :when (seq emails)]
@@ -149,13 +148,12 @@
 (mu/defmethod channel/render-notification
   [:channel/email :notification/system-event]
   [_channel-type
-   notification-info :- notification/NotificationInfo
-   template          :- models.channel/ChannelTemplate
-   recipients        :- [:sequential models.notification/NotificationRecipient]]
+   notification-payload #_:- #_notification/NotificationPayload
+   template             :- models.channel/ChannelTemplate
+   recipients           :- [:sequential models.notification/NotificationRecipient]]
   (assert (some? template) "Template is required for system event notifications")
-  (let [payload (:payload notification-info)]
-    [(construct-email (channel.params/substitute-params (-> template :details :subject) payload)
-                      (notification-recipients->emails recipients payload)
-                      [{:type    "text/html; charset=utf-8"
-                        :content (render-body template payload)}]
-                      (-> template :details :recipient-type keyword))]))
+  [(construct-email (channel.params/substitute-params (-> template :details :subject) notification-payload)
+                    (notification-recipients->emails recipients notification-payload)
+                    [{:type    "text/html; charset=utf-8"
+                      :content (render-body template notification-payload)}]
+                    (-> template :details :recipient-type keyword))])
diff --git a/src/metabase/channel/http.clj b/src/metabase/channel/impl/http.clj
similarity index 99%
rename from src/metabase/channel/http.clj
rename to src/metabase/channel/impl/http.clj
index 6c43843120df3705205b51b58023b738ee09afd3..c2201a9b820a8a5ab15f610c0613875612dd0ff7 100644
--- a/src/metabase/channel/http.clj
+++ b/src/metabase/channel/impl/http.clj
@@ -1,4 +1,4 @@
-(ns metabase.channel.http
+(ns metabase.channel.impl.http
   (:require
    [cheshire.core :as json]
    [clj-http.client :as http]
diff --git a/src/metabase/channel/slack.clj b/src/metabase/channel/impl/slack.clj
similarity index 99%
rename from src/metabase/channel/slack.clj
rename to src/metabase/channel/impl/slack.clj
index 95a7f5a54b7260d20f4a4cb41c092ff291d67718..1d833797beb469fd436fb12ae38337e0d22dda5f 100644
--- a/src/metabase/channel/slack.clj
+++ b/src/metabase/channel/impl/slack.clj
@@ -1,4 +1,4 @@
-(ns metabase.channel.slack
+(ns metabase.channel.impl.slack
   (:require
    [clojure.string :as str]
    [metabase.channel.core :as channel]
diff --git a/src/metabase/core.clj b/src/metabase/core.clj
index a8cfd16f7547db33c38b373cc775a9fb1f6016fa..7ffcf096dfa0ff8f6d1dd312ea2c18d95b888f6e 100644
--- a/src/metabase/core.clj
+++ b/src/metabase/core.clj
@@ -5,7 +5,6 @@
    [environ.core :as env]
    [java-time.api :as t]
    [metabase.analytics.prometheus :as prometheus]
-   [metabase.channel.core :as channel]
    [metabase.config :as config]
    [metabase.core.config-from-file :as config-from-file]
    [metabase.core.initialization-status :as init-status]
@@ -166,8 +165,6 @@
   (task/start-scheduler!)
   ;; In case we could not do this earlier (e.g. for DBs added via config file), because the scheduler was not up yet:
   (database/check-and-schedule-tasks!)
-  ;; load the channels
-  (channel/find-and-load-metabase-channels!)
   (init-status/set-complete!)
   (let [start-time (.getStartTime (ManagementFactory/getRuntimeMXBean))
         duration   (- (System/currentTimeMillis) start-time)]
diff --git a/src/metabase/db/custom_migrations.clj b/src/metabase/db/custom_migrations.clj
index c029fbb70835000cfb931f04b52b448f8a0c7729..4713276833815c66b7d46aaf3c69634f40700ba7 100644
--- a/src/metabase/db/custom_migrations.clj
+++ b/src/metabase/db/custom_migrations.clj
@@ -1601,7 +1601,7 @@
                      {:name         "User joined Email template"
                       :channel_type "channel/email"
                       :details      (json/generate-string {:type           "email/mustache-resource"
-                                                           :subject        "{{context.extra.user-invited-email-subject}}"
+                                                           :subject        "{{payload.custom.user_invited_email_subject}}"
                                                            :path           "metabase/email/new_user_invite.mustache"
                                                            :recipient-type :cc})
                       :created_at   :%now
@@ -1614,7 +1614,7 @@
        :channel_id      nil
        :template_id     template-id
        :recipients      [{:type    "notification-recipient/template"
-                          :details (json/generate-string {:pattern "{{event-info.object.email}}"})}]}])))
+                          :details (json/generate-string {:pattern "{{payload.event_info.object.email}}"})}]}])))
 
 (define-migration CreateSystemNotificationAlertCreated
   (let [template-id (t2/insert-returning-pk!
@@ -1635,7 +1635,7 @@
        :channel_id      nil
        :template_id     template-id
        :recipients      [{:type    "notification-recipient/template"
-                          :details (json/generate-string {:pattern "{{event-info.user.email}}"})}]}])))
+                          :details (json/generate-string {:pattern "{{payload.event_info.user.email}}"})}]}])))
 
 (define-migration CreateSystemNotificationSlackTokenError
   (let [template-id (t2/insert-returning-pk!
@@ -1656,7 +1656,7 @@
        :channel_id      nil
        :template_id     template-id
        :recipients      [{:type    "notification-recipient/template"
-                          :details (json/generate-string {:pattern     "{{context.admin-email}}"
+                          :details (json/generate-string {:pattern     "{{context.admin_email}}"
                                                           :is_optional true})}
                          {:type                 "notification-recipient/group"
                           :permissions_group_id (t2/select-one-pk :permissions_group :name "Administrators")}]}])))
diff --git a/src/metabase/email/alert_new_confirmation.mustache b/src/metabase/email/alert_new_confirmation.mustache
index c4fed5832e2b8d4c09fdf208229b02f67204b035..cb3c9cb01188b6a49f750d58a057187e4cdd179e 100644
--- a/src/metabase/email/alert_new_confirmation.mustache
+++ b/src/metabase/email/alert_new_confirmation.mustache
@@ -1,4 +1,4 @@
 {{> metabase/email/_header }}
-    <p>This is just a confirmation that your alert about <a href="{{context.site-url}}/question/{{event-info.object.card.id}}">{{event-info.object.card.name}}</a> has been set up.</p>
-    <p>This alert will be sent {{context.extra.alert-create-condition-description}}.</p>
+    <p>This is just a confirmation that your alert about <a href="{{context.site_url}}/question/{{payload.event_info.object.card.id}}">{{payload.event_info.object.card.name}}</a> has been set up.</p>
+    <p>This alert will be sent {{payload.custom.alert_create_condition_description}}.</p>
 {{> metabase/email/_footer}}
diff --git a/src/metabase/email/new_user_invite.mustache b/src/metabase/email/new_user_invite.mustache
index 71c31accca24d0d1b400f1877e84902a5f061468..0d4676edb03a63a60c9b67c6eac586bf4a43dde6 100644
--- a/src/metabase/email/new_user_invite.mustache
+++ b/src/metabase/email/new_user_invite.mustache
@@ -1,6 +1,6 @@
 {{> metabase/email/_header }}
   <div style="padding-bottom: 2em; padding-top: 1em; text-align: center;">
-    <img width="32" height="40" src="{{context.application-logo-url}}"/>
+    <img width="32" height="40" src="{{context.application_logo_url}}"/>
   </div>
   <div>
     <div style="padding: 0.25em 0em .25em 0em; text-align: center; margin-left: auto; margin-right: auto; max-width: 400px; position: relative">
@@ -8,42 +8,42 @@
         <tr><td colspan="3"><img src="http://static.metabase.com/email_graph_top.png" width="296" height="73" style="display:block" /></td></tr>
         <tr>
           <td height="15" width="60"><img src="http://static.metabase.com/email_graph_left.png" width="60" height="15" style="display:block" /></td>
-          <td height="15" width="68" valign="middle" align="center" style="font-weight: bold; font-size: 0.6rem; line-height:15px;color: #fff; background-color:#333">{{{context.extra.user-invited-today}}}</td>
+          <td height="15" width="68" valign="middle" align="center" style="font-weight: bold; font-size: 0.6rem; line-height:15px;color: #fff; background-color:#333">{{{payload.custom.user_invited_today}}}</td>
           <td height="15" width="168"><img src="http://static.metabase.com/email_graph_right.png" width="168" height="15" style="display:block" /></td></tr>
         <tr><td colspan="3" height="46"><img src="http://static.metabase.com/email_graph_bottom.png" width="296" height="56" style="display:block" /></td></tr>
       </table>
-      <p style="line-height: 1.3rem; font-size: 12px; color: #949AAB;">{{event-info.object.first_name}}'s happiness and productivity over time</p>
+      <p style="line-height: 1.3rem; font-size: 12px; color: #949AAB;">{{payload.event_info.object.first_name}}'s happiness and productivity over time</p>
     </div>
-    {{#event-info.object.is_from_setup}}
+    {{#payload.event_info.object.is_from_setup}}
       <div style="max-width: 500px; margin-left: auto; margin-right: auto">
-        <h2 style="font-weight: bold; color: #4C5773; line-height: 2rem;">{{event-info.details.invitor.first_name}} could use your help setting up {{context.application-name}}</h2>
+        <h2 style="font-weight: bold; color: #4C5773; line-height: 2rem;">{{payload.event_info.details.invitor.first_name}} could use your help setting up {{context.application_name}}</h2>
       </div>
       <div style="max-width: 500px; margin-left: auto; margin-right: auto; line-height: 1.5rem; color: #4C5773;">
         <p>{{context.application-name}} is a simple and powerful analytics tool that lets you easily <strong>ask questions</strong> and <strong>get answers</strong> about your company’s data.
-        <p>Your {{context.application-name}} is up and running, but {{event-info.details.invitor.first_name}} needs you to connect your data. You'll probably need:
+        <p>Your {{context.application-name}} is up and running, but {{payload.event_info.details.invitor.first_name}} needs you to connect your data. You'll probably need:
         <ul>
           <li>Database type (Redshift, Snowflake, etc...)</li>
           <li>Host and port</li>
           <li>Username and password</li>
         </ul>
       </div>
-    {{/event-info.object.is_from_setup}}
-    {{^event-info.object.is_from_setup}}
+    {{/payload.event_info.object.is_from_setup}}
+    {{^payload.event_info.object.is_from_setup}}
       <div style="text-align: center">
-        <h2 style="font-weight: bold; color: #4C5773; line-height: 2rem;">{{event-info.details.invitor.first_name}} wants you to join them on {{context.application-name}}</h2>
+        <h2 style="font-weight: bold; color: #4C5773; line-height: 2rem;">{{payload.event_info.details.invitor.first_name}} wants you to join them on {{context.application_name}}</h2>
       </div>
       <div style="max-width: 500px; margin-left: auto; margin-right: auto; line-height: 1.5rem; color: #4C5773; text-align: center">
-        <p>{{context.application-name}} is a simple and powerful analytics tool that lets you easily <strong>ask questions</strong> and <strong>get answers</strong> about your company’s data.
+        <p>{{context.application_name}} is a simple and powerful analytics tool that lets you easily <strong>ask questions</strong> and <strong>get answers</strong> about your company’s data.
       </div>
-    {{/event-info.object.is_from_setup}}
+    {{/payload.event_info.object.is_from_setup}}
     <div style="padding: 1.25em 0 1em 0; text-align: center">
-      <a style="display: inline-block; box-sizing: border-box; text-decoration: none; font-size: 1rem; padding: 0.8rem 2.25rem; background: #FBFCFD; color: #444; cursor: pointer; text-decoration: none; font-weight: bold; border-radius: 6px; background-color: #509EE3; color: #fff;" href="{{context.extra.user-invited-join-url}}">Join now</a>
+      <a style="display: inline-block; box-sizing: border-box; text-decoration: none; font-size: 1rem; padding: 0.8rem 2.25rem; background: #FBFCFD; color: #444; cursor: pointer; text-decoration: none; font-weight: bold; border-radius: 6px; background-color: #509EE3; color: #fff;" href="{{payload.custom.user_invited_join_url}}">Join now</a>
     </div>
-    {{#event-info.object.is_from_setup}}
+    {{#payload.event_info.object.is_from_setup}}
       <div style="max-width: 500px; margin-left: auto; margin-right: auto; line-height: 1.5rem; color: #4C5773">
         <p>P.S. If you run into any issues, check out our <a href="https://www.metabase.com/docs/latest/databases/connecting">connection</a> and <a href="https://www.metabase.com/docs/latest/troubleshooting-guide/db-connection">troubleshooting</a> guides</p>
       </div>
-    {{/event-info.object.is_from_setup}}
+    {{/payload.event_info.object.is_from_setup}}
   </div>
 </div>
 </body>
diff --git a/src/metabase/email/slack_token_error.mustache b/src/metabase/email/slack_token_error.mustache
index 4cb5ff0871527b21eb2373f29a6a066f9d7a7ec9..f87f9613323f96cf29bec9ef6cfbf8bc4d3a0740 100644
--- a/src/metabase/email/slack_token_error.mustache
+++ b/src/metabase/email/slack_token_error.mustache
@@ -1,12 +1,12 @@
 {{> metabase/email/_header }}
   <div style="padding-bottom: 2em; padding-top: 1em; text-align: center;">
-    <img width="32" height="40" src="{{context.application-logo-url}}"/>
+    <img width="32" height="40" src="{{context.application_logo_url}}"/>
   </div>
   <div style="text-align: center; margin: 0 auto; max-width: 500px">
     <div style="text-align: left">
       <h2 style="font-weight: normal; color: #4C545B; line-height: 34px;">Your Slack connection stopped working</h2>
       <p style="line-height: 22px; margin-bottom: 40px">This may affect existing dashboard subscriptions. Follow the steps in settings to reconnect Slack and get things up and running again.</p>
     </div>
-    <a style="{{context.style.button}}" href="{{context.site-url}}/admin/settings/slack">Go to settings</a>
+    <a style="{{context.style.button}}" href="{{context.site_url}}/admin/settings/slack">Go to settings</a>
   </div>
 {{> metabase/email/_footer }}
diff --git a/src/metabase/events.clj b/src/metabase/events.clj
index 24860c295ae7fdeba1e964f06b5bbc8ab3c97bd6..f0f7b82aa290146d7e575bc6f0441ccff6d10dd7 100644
--- a/src/metabase/events.clj
+++ b/src/metabase/events.clj
@@ -19,7 +19,12 @@
    [metabase.util.methodical.null-cache :as u.methodical.null-cache]
    [metabase.util.methodical.unsorted-dispatcher
     :as u.methodical.unsorted-dispatcher]
-   [methodical.core :as methodical]))
+   [methodical.core :as methodical]
+   [potemkin :as p]))
+
+(p/import-vars
+ [events.schema
+  topic->schema])
 
 (set! *warn-on-reflection* true)
 
diff --git a/src/metabase/events/notification.clj b/src/metabase/events/notification.clj
index d31422b6ec579cd710e3c65fb516a11c3c3cf867..7bd30143932572c99ba9fde812860836cca62a38 100644
--- a/src/metabase/events/notification.clj
+++ b/src/metabase/events/notification.clj
@@ -1,18 +1,11 @@
 (ns metabase.events.notification
   (:require
-   [java-time.api :as t]
    [malli.core :as mc]
    [malli.transform :as mtx]
-   [metabase.email.messages :as messages]
    [metabase.events :as events]
-   [metabase.events.schema :as events.schema]
    [metabase.models.notification :as models.notification]
    [metabase.models.task-history :as task-history]
-   [metabase.models.user :as user]
    [metabase.notification.core :as notification]
-   [metabase.public-settings :as public-settings]
-   [metabase.pulse.core :as pulse]
-   [metabase.util.i18n :as i18n :refer [trs]]
    [metabase.util.log :as log]
    [methodical.core :as methodical]
    [toucan2.core :as t2]))
@@ -43,7 +36,7 @@
 
 (defn- hydrate!
   "Given a schema and value, hydrate the keys that are marked as to-hydrate.
-  Hydrated keys have the :hydrate properties that can be added by [[events.schema/with-hydration]].
+  Hydrated keys have the :hydrate properties that can be added by [[metabase.events.schema/with-hydration]].
 
     (hydrate! [:map
                 [:user_id {:hydrate {:key :user
@@ -54,55 +47,19 @@
   [schema value]
   (mc/decode schema value hydrate-transformer))
 
+(defn maybe-hydrate-event-info
+  "Hydrate event-info if the topic has a schema."
+  [topic event-info]
+  (cond->> event-info
+    (some? (events/topic->schema topic))
+    (hydrate! (events/topic->schema topic))))
+
 (defn- notifications-for-topic
   "Returns notifications for a given topic if it is supported and has notifications."
   [topic]
   (when (supported-topics topic)
     (models.notification/notifications-for-event topic)))
 
-(defn- join-url
-  [new-user]
-  (let [reset-token               (user/set-password-reset-token! (:id new-user))
-        should-link-to-login-page (and (public-settings/sso-enabled?)
-                                       (not (public-settings/enable-password-login)))]
-    (if should-link-to-login-page
-      (str (public-settings/site-url) "/auth/login")
-      ;; NOTE: the new user join url is just a password reset with an indicator that this is a first time user
-      (str (user/form-password-reset-url reset-token) "#new"))))
-
-(defn- extra-context
-  "Returns a map of extra context for a given topic and event-info.
-  Extra are set of contexts that are specific to a certain emails.
-  Currently we need it to support usecases that our template engines doesn't support such as i18n,
-  but ideally this should be part of the template."
-  [topic event-info]
-  (case topic
-    :event/user-invited
-    {:user-invited-today         (t/format "MMM'&nbsp;'dd,'&nbsp;'yyyy" (t/zoned-date-time))
-     :user-invited-email-subject (trs "You''re invited to join {0}''s {1}" (public-settings/site-name) (messages/app-name-trs))
-     :user-invited-join-url      (join-url (:object event-info))}
-
-    :event/alert-create
-    {:alert-create-condition-description (->> event-info :object
-                                              messages/pulse->alert-condition-kwd
-                                              (get messages/alert-condition-text))}
-    {}))
-
-(defn- enriched-event-info
-  [topic event-info]
-  ;; DO NOT delete or rename these fields, they are used in the notification templates
-  {:context     {:application-name     (public-settings/application-name)
-                 :application-logo-url (messages/logo-url)
-                 :site-name            (public-settings/site-name)
-                 :site-url             (public-settings/site-url)
-                 :admin-email          (public-settings/admin-email)
-                 :style                {:button (messages/button-style (pulse/primary-color))}
-                 :extra                (extra-context topic event-info)}
-   :event-info  (cond->> event-info
-                  (some? (events.schema/topic->schema topic))
-                  (hydrate! (events.schema/topic->schema topic)))
-   :event-topic topic})
-
 (def ^:dynamic *skip-sending-notification?*
   "Used as a hack for when we need to skip sending notifications for certain events.
 
@@ -117,10 +74,10 @@
                                        :task_details {:trigger_type     :notification-subscription/system-event
                                                       :event_name       topic
                                                       :notification_ids (map :id notifications)}}
-        (let [event-info (enriched-event-info topic event-info)]
-          (log/infof "Found %d notifications for event: %s" (count notifications) topic)
-          (doseq [notification notifications]
-            (notification/*send-notification!* (assoc notification :payload event-info))))))))
+        (log/infof "Found %d notifications for event: %s" (count notifications) topic)
+        (doseq [notification notifications]
+          (notification/*send-notification!* (assoc notification :payload {:event_info  (maybe-hydrate-event-info topic event-info)
+                                                                           :event_topic topic})))))))
 
 (methodical/defmethod events/publish-event! ::notification
   [topic event-info]
diff --git a/src/metabase/models/notification.clj b/src/metabase/models/notification.clj
index aa70157c5228c4f29faf28fd65383e06e3d75d3c..4c765b538b450644e589734e88f47225549a37e0 100644
--- a/src/metabase/models/notification.clj
+++ b/src/metabase/models/notification.clj
@@ -36,6 +36,7 @@
 (def notification-types
   "Set of valid notification types."
   #{:notification/system-event
+    :notification/dashboard-subscription
     ;; for testing only
     :notification/testing})
 
diff --git a/src/metabase/notification/core.clj b/src/metabase/notification/core.clj
index 58c2a6fa78fd3ab161c04bfa1136d38790f8e340..02e8a304869d7537cb31738ef94bd8e59bac9d98 100644
--- a/src/metabase/notification/core.clj
+++ b/src/metabase/notification/core.clj
@@ -1,158 +1,20 @@
 (ns metabase.notification.core
+  "Core functionality for notifications."
   (:require
-   [java-time.api :as t]
-   [metabase.channel.core :as channel]
-   [metabase.models.notification :as models.notification]
-   [metabase.models.setting :as setting]
-   [metabase.models.task-history :as task-history]
-   [metabase.util :as u]
-   [metabase.util.log :as log]
-   [metabase.util.malli :as mu]
-   [metabase.util.malli.schema :as ms]
-   [metabase.util.retry :as retry]
-   [toucan2.core :as t2])
-  (:import
-   (java.util.concurrent Callable Executors ExecutorService)
-   (org.apache.commons.lang3.concurrent BasicThreadFactory$Builder)))
+   [metabase.notification.payload.core :as notification.payload]
+   [metabase.notification.send :as notification.send]
+   [potemkin :as p]))
 
-(set! *warn-on-reflection* true)
+;; ------------------------------------------------------------------------------------------------;;
+;;                                           Public APIs                                           ;;
+;; ------------------------------------------------------------------------------------------------;;
 
-(def ^:private Notification
-  [:map {:closed true}
-   [:payload_type                  (into [:enum] models.notification/notification-types)]
-   [:id           {:optional true} ms/PositiveInt]
-   [:active       {:optional true} :boolean]
-   [:created_at   {:optional true} :any]
-   [:updated_at   {:optional true} :any]])
-
-(def NotificationInfo
-  "Schema for the notificaiton info."
-  [:multi {:dispatch :payload_type}
-   [:notification/system-event [:merge
-                                Notification
-                                [:map {:closed true}
-                                 [:payload
-                                  [:map {:closed true}
-                                   ;; TODO: event-info schema for each event type
-                                   [:event-topic [:fn #(= "event" (-> % keyword namespace))]]
-                                   [:event-info  [:maybe :map]]
-                                   [:context     :map]]]]]]
-   ;; for testing only
-   [:notification/testing       :map]])
-
-(defn- hydrate-notification-handler
-  [notification-handlers]
-  (t2/hydrate notification-handlers
-              :channel
-              :template
-              :recipients))
-
-(setting/defsetting notification-thread-pool-size
-  "The size of the thread pool used to send notifications."
-  :default 10
-  :export? false
-  :type :integer
-  :visibility :internal)
-
-(defonce ^:private pool
-  (delay (Executors/newFixedThreadPool
-          (notification-thread-pool-size)
-          (.build
-           (doto (BasicThreadFactory$Builder.)
-             (.namingPattern "send-notification-thread-pool-%d"))))))
-
-(def ^:private default-retry-config
-  {:max-attempts            7
-   :initial-interval-millis 500
-   :multiplier              2.0
-   :randomization-factor    0.1
-   :max-interval-millis     30000})
-
-(defn- handler->channel-name
-  [{:keys [channel_type channel_id]}]
-  (if channel_id
-    (str (u/qualified-name channel_type) " " channel_id)
-    (u/qualified-name channel_type)))
-
-(defn- channel-send-retrying!
-  [handler message]
-  (try
-    (let [notification-id (:notification_id handler)
-          retry-config default-retry-config
-          retry-errors (volatile! [])
-          retry-report (fn []
-                         {:attempted_retries (count @retry-errors)
-                          ;; we want the last retry to be the most recent
-                          :retry_errors       (reverse @retry-errors)})
-          channel     (or (:channel handler)
-                          {:type (:channel_type handler)})
-          send!        (fn []
-                         (try
-                           (channel/send! channel message)
-                           (catch Exception e
-                             (vswap! retry-errors conj {:message   (u/strip-error e)
-                                                        :timestamp (t/offset-date-time)})
-                             (log/warnf e "[Notification %d] Failed to send to channel %s , retrying..." notification-id (handler->channel-name handler))
-                             (throw e))))
-          retrier     (retry/make retry-config)]
-      (log/debugf "[Notification %d] Sending a message to channel %s" notification-id (handler->channel-name handler))
-      (task-history/with-task-history {:task            "channel-send"
-                                       :on-success-info (fn [update-map _result]
-                                                          (cond-> update-map
-                                                            (seq @retry-errors)
-                                                            (update :task_details merge (retry-report))))
-                                       :on-fail-info    (fn [update-map _result]
-                                                          (update update-map :task_details merge (retry-report)))
-                                       :task_details    {:retry_config    retry-config
-                                                         :channel_id      (:id channel)
-                                                         :channel_type    (:type channel)
-                                                         :template_id     (:template_id handler)
-                                                         :notification_id notification-id
-                                                         :recipient_ids   (map :id (:recipients handler))}}
-        (retrier send!)
-        (log/debugf "[Notification %d] Sent to channel %s with %d retries" notification-id (handler->channel-name handler) (count @retry-errors))))
-    (catch Throwable e
-      (log/errorf e "[Notification %d] Error sending notification!" (:notification_id handler)))))
-
-(mu/defn- send-notification-sync!
-  "Send the notification to all handlers synchronously. Do not use this directly, use *send-notification!* instead."
-  [notification-info :- NotificationInfo]
-  (try
-    (let [noti-handlers (hydrate-notification-handler (t2/select :model/NotificationHandler :notification_id (:id notification-info)))]
-      (log/debugf "[Notification %d] Found %d handlers" (:id notification-info) (count noti-handlers))
-      (task-history/with-task-history
-       {:task          "notification-send"
-        :task_details {:notification_id       (:id notification-info)
-                       :notification_handlers (map #(select-keys % [:id :channel_type :channel_id :template_id]) noti-handlers)}}
-        (doseq [handler noti-handlers]
-          (let [channel-type (:channel_type handler)
-                messages     (channel/render-notification
-                              channel-type
-                              notification-info
-                              (:template handler)
-                              (:recipients handler))]
-            (log/debugf "[Notification %d] Got %d messages for channel %s with template %d"
-                        (:id notification-info) (count messages)
-                        (handler->channel-name handler)
-                        (-> handler :template :id))
-            (doseq [message messages]
-              (log/infof "[Notification %d] Sending message to channel %s"
-                         (:id notification-info) (:channel_type handler))
-              (channel-send-retrying! handler message))))
-        (log/infof "[Notification %d] Sent successfully" (:id notification-info))))
-    (catch Exception e
-      (log/errorf e "[Notification %d] Failed to send" (:id notification-info))
-      (throw e)))
-  nil)
-
-(defn- send-notification-async!
-  "Send a notification asynchronously."
-  [notification]
-  (.submit ^ExecutorService @pool ^Callable
-           (fn []
-             (send-notification-sync! notification)))
-  nil)
+(p/import-vars
+ [notification.payload
+  notification-payload
+  Notification
+  NotificationPayload])
 
 (def ^:dynamic *send-notification!*
-  "The function to send a notification. Defaults to `send-notification-async!`."
-  send-notification-async!)
+  "The function to send a notification. Defaults to `notification.send/send-notification-async!`."
+  notification.send/send-notification-async!)
diff --git a/src/metabase/notification/payload/core.clj b/src/metabase/notification/payload/core.clj
new file mode 100644
index 0000000000000000000000000000000000000000..c9ba2b9bb032abe8db371bc78d9317f3db803ca4
--- /dev/null
+++ b/src/metabase/notification/payload/core.clj
@@ -0,0 +1,76 @@
+(ns metabase.notification.payload.core
+  (:require
+   [metabase.email.messages :as messages]
+   [metabase.models.notification :as models.notification]
+   [metabase.public-settings :as public-settings]
+   [metabase.pulse.core :as pulse]
+   [metabase.util :as u]
+   [metabase.util.malli :as mu]
+   [metabase.util.malli.schema :as ms]))
+
+(def Notification
+  "Schema for the notification."
+  ;; TODO: how do we make this schema closed after :merge?
+  [:merge #_{:closed true}
+   [:map
+    [:payload_type                  (into [:enum] models.notification/notification-types)]
+    [:id           {:optional true} ms/PositiveInt]
+    [:active       {:optional true} :boolean]
+    [:created_at   {:optional true} :any]
+    [:updated_at   {:optional true} :any]]
+   [:multi {:dispatch :payload_type}
+    ;; system event is a bit special in that part of the payload comes from the event itself
+    [:notification/system-event [:map
+                                 [:payload
+                                  [:map {:closed true}
+                                   ;; TODO: event-info schema for each event type
+                                   [:event_topic [:fn #(= "event" (-> % keyword namespace))]]
+                                   [:event_info  [:maybe :map]]]]]]
+
+    ;; for testing only
+    [:notification/testing       :map]]])
+
+(def NotificationPayload
+  "Schema for the notification payload."
+  ;; TODO: how do we make this schema closed after :merge?
+  [:merge
+   Notification
+   [:map
+    [:context [:map]]]
+   [:multi {:dispatch :payload_type}
+    ;; override payload to add extra-context key
+    [:notification/system-event [:map
+                                 ;; override the payload with extra context
+                                 [:payload
+                                  [:map {:closed true}
+                                   [:event_topic                   [:fn #(= "event" (-> % keyword namespace))]]
+                                   [:event_info                    [:maybe :map]]
+                                   [:custom       {:optional true} [:maybe :map]]]]]]
+    [:notification/testing       :map]]])
+
+(defn- default-context
+  []
+  ;; DO NOT delete or rename these fields, they are used in the notification templates
+  {:application_name     (public-settings/application-name)
+   :application_logo_url (messages/logo-url)
+   :site_name            (public-settings/site-name)
+   :site_url             (public-settings/site-url)
+   :admin_email          (public-settings/admin-email)
+   :style                {:button (messages/button-style (pulse/primary-color))}})
+
+(defmulti payload
+  "Given a notification info, return the notification payload."
+  :payload_type)
+
+(mu/defn notification-payload :- NotificationPayload
+  "Realize notification-info with :context and :payload."
+  [notification :- Notification]
+  (assoc notification
+         :payload (payload notification)
+         :context (default-context)))
+
+;; ------------------------------------------------------------------------------------------------;;
+;;                                    Load the implementations                                     ;;
+;; ------------------------------------------------------------------------------------------------;;
+(when-not *compile-files*
+  (u/find-and-load-namespaces! "metabase.notification.payload.impl"))
diff --git a/src/metabase/notification/payload/impl/system_event.clj b/src/metabase/notification/payload/impl/system_event.clj
new file mode 100644
index 0000000000000000000000000000000000000000..52472eba9e7c659069a944b8fe2d0800df24e82d
--- /dev/null
+++ b/src/metabase/notification/payload/impl/system_event.clj
@@ -0,0 +1,44 @@
+(ns metabase.notification.payload.impl.system-event
+  (:require
+   [java-time.api :as t]
+   [metabase.email.messages :as messages]
+   [metabase.models.user :as user]
+   [metabase.notification.payload.core :as notification.payload]
+   [metabase.public-settings :as public-settings]
+   [metabase.util.i18n :as i18n :refer [trs]]
+   [metabase.util.malli :as mu]))
+
+(defn- join-url
+  [new-user]
+  ;; TODO: the reset token should come from the event-info, not generated here!
+  (let [reset-token               (user/set-password-reset-token! (:id new-user))
+        should-link-to-login-page (and (public-settings/sso-enabled?)
+                                       (not (public-settings/enable-password-login)))]
+    (if should-link-to-login-page
+      (str (public-settings/site-url) "/auth/login")
+      ;; NOTE: the new user join url is just a password reset with an indicator that this is a first time user
+      (str (user/form-password-reset-url reset-token) "#new"))))
+
+(defn- custom-payload
+  "Returns a map of custom payload for a given topic and event-info.
+  Custom are set of contexts that are specific to certain emails.
+  Currently we need it to support usecases that our template engines doesn't support such as i18n,
+  but ideally this should be part of the template."
+  [topic event-info]
+  (case topic
+    :event/user-invited
+    {:user_invited_today         (t/format "MMM'&nbsp;'dd,'&nbsp;'yyyy" (t/zoned-date-time))
+     :user_invited_email_subject (trs "You''re invited to join {0}''s {1}" (public-settings/site-name) (messages/app-name-trs))
+     :user_invited_join_url      (join-url (:object event-info))}
+
+    :event/alert-create
+    {:alert_create_condition_description (->> event-info :object
+                                              messages/pulse->alert-condition-kwd
+                                              (get messages/alert-condition-text))}
+    {}))
+
+(mu/defmethod notification.payload/payload :notification/system-event
+  [notification-info :- notification.payload/Notification]
+  (let [payload                          (:payload notification-info)
+        {:keys [event_topic event_info]} payload]
+    (assoc payload :custom (custom-payload event_topic event_info))))
diff --git a/src/metabase/notification/send.clj b/src/metabase/notification/send.clj
new file mode 100644
index 0000000000000000000000000000000000000000..e1a5ebc80d97a38745869d56d4d64b198a348d18
--- /dev/null
+++ b/src/metabase/notification/send.clj
@@ -0,0 +1,134 @@
+(ns metabase.notification.send
+  (:require
+   [java-time.api :as t]
+   [metabase.channel.core :as channel]
+   [metabase.models.setting :as setting]
+   [metabase.models.task-history :as task-history]
+   [metabase.notification.payload.core :as notification.payload]
+   [metabase.util :as u]
+   [metabase.util.log :as log]
+   [metabase.util.malli :as mu]
+   [metabase.util.retry :as retry]
+   [toucan2.core :as t2])
+  (:import
+   (java.util.concurrent Callable Executors ExecutorService)
+   (org.apache.commons.lang3.concurrent BasicThreadFactory$Builder)))
+
+(set! *warn-on-reflection* true)
+
+(defn- hydrate-notification-handler
+  [notification-handlers]
+  (t2/hydrate notification-handlers
+              :channel
+              :template
+              :recipients))
+
+(defn- handler->channel-name
+  [{:keys [channel_type channel_id]}]
+  (if channel_id
+    (str (u/qualified-name channel_type) " " channel_id)
+    (u/qualified-name channel_type)))
+
+(setting/defsetting notification-thread-pool-size
+  "The size of the thread pool used to send notifications."
+  :default 10
+  :export? false
+  :type :integer
+  :visibility :internal)
+
+(defonce ^:private pool
+  (delay (Executors/newFixedThreadPool
+          (notification-thread-pool-size)
+          (.build
+           (doto (BasicThreadFactory$Builder.)
+             (.namingPattern "send-notification-thread-pool-%d"))))))
+
+(def ^:private default-retry-config
+  {:max-attempts            7
+   :initial-interval-millis 500
+   :multiplier              2.0
+   :randomization-factor    0.1
+   :max-interval-millis     30000})
+
+(defn- channel-send-retrying!
+  [handler message]
+  (try
+    (let [notification-id (:notification_id handler)
+          retry-config default-retry-config
+          retry-errors (volatile! [])
+          retry-report (fn []
+                         {:attempted_retries (count @retry-errors)
+                          ;; we want the last retry to be the most recent
+                          :retry_errors       (reverse @retry-errors)})
+          channel     (or (:channel handler)
+                          {:type (:channel_type handler)})
+          send!        (fn []
+                         (try
+                           (channel/send! channel message)
+                           (catch Exception e
+                             (vswap! retry-errors conj {:message   (u/strip-error e)
+                                                        :timestamp (t/offset-date-time)})
+                             (log/warnf e "[Notification %d] Failed to send to channel %s , retrying..."
+                                        notification-id (handler->channel-name handler))
+                             (throw e))))
+          retrier     (retry/make retry-config)]
+      (log/debugf "[Notification %d] Sending a message to channel %s" notification-id (handler->channel-name handler))
+      (task-history/with-task-history {:task            "channel-send"
+                                       :on-success-info (fn [update-map _result]
+                                                          (cond-> update-map
+                                                            (seq @retry-errors)
+                                                            (update :task_details merge (retry-report))))
+                                       :on-fail-info    (fn [update-map _result]
+                                                          (update update-map :task_details merge (retry-report)))
+                                       :task_details    {:retry_config    retry-config
+                                                         :channel_id      (:id channel)
+                                                         :channel_type    (:type channel)
+                                                         :template_id     (:template_id handler)
+                                                         :notification_id notification-id
+                                                         :recipient_ids   (map :id (:recipients handler))}}
+        (retrier send!)
+        (log/debugf "[Notification %d] Sent to channel %s with %d retries"
+                    notification-id (handler->channel-name handler) (count @retry-errors))))
+    (catch Throwable e
+      (log/errorf e "[Notification %d] Error sending notification!" (:notification_id handler)))))
+
+(mu/defn send-notification-sync!
+  "Send the notification to all handlers synchronously. Do not use this directly, use *send-notification!* instead."
+  [notification-info :- notification.payload/Notification]
+  (try
+    (let [noti-handlers        (hydrate-notification-handler
+                                (t2/select :model/NotificationHandler :notification_id (:id notification-info)))
+          notification-payload (notification.payload/notification-payload notification-info)]
+      (log/debugf "[Notification %d] Found %d handlers" (:id notification-info) (count noti-handlers))
+      (task-history/with-task-history
+       {:task          "notification-send"
+        :task_details {:notification_id       (:id notification-info)
+                       :notification_handlers (map #(select-keys % [:id :channel_type :channel_id :template_id]) noti-handlers)}}
+        (doseq [handler noti-handlers]
+          (let [channel-type (:channel_type handler)
+                messages     (channel/render-notification
+                              channel-type
+                              notification-payload
+                              (:template handler)
+                              (:recipients handler))]
+            (log/debugf "[Notification %d] Got %d messages for channel %s with template %d"
+                        (:id notification-info) (count messages)
+                        (handler->channel-name handler)
+                        (-> handler :template :id))
+            (doseq [message messages]
+              (log/infof "[Notification %d] Sending message to channel %s"
+                         (:id notification-info) (:channel_type handler))
+              (channel-send-retrying! handler message))))
+        (log/infof "[Notification %d] Sent successfully" (:id notification-info))))
+    (catch Exception e
+      (log/errorf e "[Notification %d] Failed to send" (:id notification-info))
+      (throw e)))
+  nil)
+
+(mu/defn send-notification-async!
+  "Send a notification asynchronously."
+  [notification :- notification.payload/Notification]
+  (.submit ^ExecutorService @pool ^Callable
+           (fn []
+             (send-notification-sync! notification)))
+  nil)
diff --git a/src/metabase/pulse/send.clj b/src/metabase/pulse/send.clj
index 465d02a0221c6d9bdd827fe5f34da7571431a6f5..062d1e3996965e9b50e1b5c0eecf9c4972290ee6 100644
--- a/src/metabase/pulse/send.clj
+++ b/src/metabase/pulse/send.clj
@@ -2,7 +2,6 @@
   "Code related to sending Pulses (Alerts or Dashboard Subscriptions)."
   (:require
    [metabase.api.common :as api]
-   [metabase.channel.core :as channel]
    [metabase.events :as events]
    [metabase.models.dashboard :as dashboard :refer [Dashboard]]
    [metabase.models.dashboard-card :as dashboard-card]
@@ -285,6 +284,10 @@
     (str (name type) " " id)
     (name type)))
 
+(defn- channel-send!
+  [& args]
+  (apply (requiring-resolve 'metabase.channel.core/send!) args))
+
 (defn- send-retrying!
   [pulse-id channel message]
   (try
@@ -297,7 +300,7 @@
                           :retry_errors       @retry-errors})
           send!        (fn []
                          (try
-                           (channel/send! channel message)
+                           (channel-send! channel message)
                            (catch Exception e
                              (vswap! retry-errors conj e)
                              ;; Token errors have already been logged and we should not retry.
@@ -333,6 +336,10 @@
           :when part]
       part)))
 
+(defn- channel-render-notification
+  [& args]
+  (apply (requiring-resolve 'metabase.channel.core/render-notification) args))
+
 (defn- pc->channel
   "Given a pulse channel, return the channel object.
 
@@ -360,7 +367,7 @@
         (u/prog1 (doseq [pulse-channel channels]
                    (try
                      (let [channel  (pc->channel pulse-channel)
-                           messages (channel/render-notification (:type channel)
+                           messages (channel-render-notification (:type channel)
                                                                  (get-notification-info pulse parts pulse-channel)
                                                                  nil
                                                                  (channel-recipients pulse-channel))]
diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc
index 6a6fee3072c9cbc61d8a168d3b8f411d4efcb7eb..3d60ad1ca6c99c607d9a5bdab324263fa4b95797 100644
--- a/src/metabase/util.cljc
+++ b/src/metabase/util.cljc
@@ -66,7 +66,9 @@
                         with-timeout
                         with-us-locale]
                        [u.str
-                        build-sentence]))
+                        build-sentence]
+                       [u.ns
+                        find-and-load-namespaces!]))
 
 (defmacro or-with
   "Like or, but determines truthiness with `pred`."
diff --git a/src/metabase/util/namespaces.clj b/src/metabase/util/namespaces.clj
index 8d6529cc022c63c24bf745b03e871ca7fbdda423..2dbf34010c6194363d7bb31e9bf6c361703256fe 100644
--- a/src/metabase/util/namespaces.clj
+++ b/src/metabase/util/namespaces.clj
@@ -1,9 +1,15 @@
 (ns metabase.util.namespaces
   "Potemkin is Java-only, so here's a basic function-importing macro that works for both CLJS and CLJ."
+  #_{:clj-kondo/ignore [:discouraged-namespace]}
   (:require
+   [metabase.plugins.classloader :as classloader]
+   [metabase.util.jvm :as u.jvm]
+   [metabase.util.log :as log]
    [net.cgrand.macrovich :as macros]
    [potemkin :as p]))
 
+(set! *warn-on-reflection* true)
+
 (defn- redef [target sym]
   (let [defn-name (or sym (symbol (name target)))]
     `(def ~defn-name "docstring" (fn [& args#] (apply ~target args#)))))
@@ -35,3 +41,12 @@
                            target     (symbol (name target-ns) (name target-sym))]]
                  (redef target new-sym)))
     :clj  `(p/import-vars ~@spaces)))
+
+(defn find-and-load-namespaces!
+  "Find and load all sub-namespaces of `root-ns` that are part of the Metabase channel system."
+  [root-ns]
+  (assert (string? root-ns) "root-ns must be a string")
+  (doseq [ns-symb u.jvm/metabase-namespace-symbols
+          :when   (.startsWith (name ns-symb) root-ns)]
+    (log/infof "Loading namespace: %s" ns-symb)
+    (classloader/require ns-symb)))
diff --git a/test/metabase/api/channel_test.clj b/test/metabase/api/channel_test.clj
index 52c14afa3d0fd3c0d1055fb8c186d9687389f871..faa85d9b5d08966c8d1071f08cf8742b6ab32e5c 100644
--- a/test/metabase/api/channel_test.clj
+++ b/test/metabase/api/channel_test.clj
@@ -8,8 +8,8 @@
    [metabase.test :as mt]
    [toucan2.core :as t2]))
 
-#_{:clj-kondo/ignore [:metabase/validate-deftest]}
-(use-fixtures :once (fn [& _args] (channel/find-and-load-metabase-channels!)))
+(comment
+  channel/keep-me)
 
 (set! *warn-on-reflection* true)
 
diff --git a/test/metabase/channel/email_test.clj b/test/metabase/channel/impl/email_test.clj
similarity index 96%
rename from test/metabase/channel/email_test.clj
rename to test/metabase/channel/impl/email_test.clj
index 4bb3efd015a574e0819c14ee74094138d54ac7dd..7b76c9944f1f47214c3b81ebc4ad78077e64c923 100644
--- a/test/metabase/channel/email_test.clj
+++ b/test/metabase/channel/impl/email_test.clj
@@ -1,4 +1,4 @@
-(ns metabase.channel.email-test
+(ns metabase.channel.impl.email-test
   (:require
    [clojure.test :refer :all]
    [metabase.channel.core :as channel]
diff --git a/test/metabase/channel/slack_test.clj b/test/metabase/channel/slack_test.clj
index 7b250115b499e195da0019efec2422d7a2d383d7..4988838dab7e4377467b677f53eb247348ad50ec 100644
--- a/test/metabase/channel/slack_test.clj
+++ b/test/metabase/channel/slack_test.clj
@@ -1,7 +1,7 @@
 (ns metabase.channel.slack-test
   (:require
    [clojure.test :refer :all]
-   [metabase.channel.slack :as channel.slack]
+   [metabase.channel.impl.slack :as channel.slack]
    [metabase.integrations.slack :as slack]))
 
 (deftest create-and-upload-slack-attachments!-test
diff --git a/test/metabase/dashboard_subscription_test.clj b/test/metabase/dashboard_subscription_test.clj
index c6c899158bd1d328badc0bda0fb35ef408c1e2d2..a6459db89970adbc77250b6e65f27a06c987abbb 100644
--- a/test/metabase/dashboard_subscription_test.clj
+++ b/test/metabase/dashboard_subscription_test.clj
@@ -2,7 +2,7 @@
   (:require
    [clojure.string :as str]
    [clojure.test :refer :all]
-   [metabase.channel.slack :as channel.slack]
+   [metabase.channel.impl.slack :as channel.slack]
    [metabase.email.result-attachment :as email.result-attachment]
    [metabase.models
     :refer [Card
diff --git a/test/metabase/events/notification_test.clj b/test/metabase/events/notification_test.clj
index 926ed870358011136675622488b143267066f4d8..162c812fffc0d48864197fdbe5e95624a2ad81d8 100644
--- a/test/metabase/events/notification_test.clj
+++ b/test/metabase/events/notification_test.clj
@@ -40,8 +40,8 @@
             [notification/*send-notification!*      (fn [notification] (swap! sent-notis conj notification))
              events.notification/supported-topics #{:event/test-notification}]
             (events/publish-event! topic {::hi true})
-            (is (=? [[(:id n-1) {:event-info {::hi true}}]
-                     [(:id n-2) {:event-info {::hi true}}]]
+            (is (=? [[(:id n-1) {:event_info {::hi true}}]
+                     [(:id n-2) {:event_info {::hi true}}]]
                     (->> @sent-notis
                          (map (juxt :id :payload))
                          (sort-by first))))))))))
@@ -64,28 +64,6 @@
             (events/publish-event! :event/unsupported-topic {::hi true})
             (is (empty? @sent-notis))))))))
 
-(deftest enriched-event-info-settings-test
-  (let [event-info {:foo :bar}]
-    (testing "you shouldn't delete or rename these fields without 100% sure that it's not referenced
-             in any channel_template.details or notification_recipient.details"
-      (mt/with-additional-premium-features #{:whitelabel}
-        (mt/with-temporary-setting-values
-          [application-name "Metabase Test"
-           site-name        "Metabase Test"
-           site-url         "https://metabase.com"
-           admin-email      "ngoc@metabase.com"]
-          (is (= {:event-info  {:foo :bar}
-                  :event-topic :event/user-joined
-                  :context     {:application-name     "Metabase Test"
-                                :application-logo-url "http://static.metabase.com/email_logo.png"
-                                :site-name            "Metabase Test"
-                                :site-url             "https://metabase.com"
-                                :admin-email          "ngoc@metabase.com"
-                                :style                {:button true}
-                                :extra                {}}}
-                 (-> (#'events.notification/enriched-event-info :event/user-joined event-info)
-                     (update-in [:context :style :button] string?)))))))))
-
 (def user-hydra-model [:model/User :id :first_name])
 
 (deftest hydrate-event-notifcation-test
diff --git a/test/metabase/models/notification_test.clj b/test/metabase/models/notification_test.clj
index 06f169dc457aeac26f6bdadcd0349ff673ec8e5a..dd05e5d634c0c82ec3fbde977c6491c2f6c9d403 100644
--- a/test/metabase/models/notification_test.clj
+++ b/test/metabase/models/notification_test.clj
@@ -149,18 +149,19 @@
 
 (deftest delete-template-set-null-on-existing-handlers-test
   (testing "if a channel template is deleted, then set null on existing notification_handler"
-    (mt/with-temp [:model/Channel         chn-1   (assoc api.channel-test/default-test-channel :name "Channel 1")
-                   :model/ChannelTemplate tmpl-1 {:channel_type (:type chn-1)}]
-      (let [noti (models.notification/create-notification!
-                  default-system-event-notification
-                  [default-user-invited-subscription]
-                  [{:channel_type (:type chn-1)
-                    :channel_id   (:id chn-1)
-                    :template_id  (:id tmpl-1)
-                    :recipients   [{:type     :notification-recipient/user
-                                    :user_id  (mt/user->id :rasta)}]}])]
-        (t2/delete! :model/ChannelTemplate (:id tmpl-1))
-        (is (=? {:template_id nil} (t2/select-one :model/NotificationHandler :notification_id (:id noti))))))))
+    (mt/with-model-cleanup [:model/Notification]
+      (mt/with-temp [:model/Channel         chn-1   (assoc api.channel-test/default-test-channel :name "Channel 1")
+                     :model/ChannelTemplate tmpl-1 {:channel_type (:type chn-1)}]
+        (let [noti (models.notification/create-notification!
+                    default-system-event-notification
+                    [default-user-invited-subscription]
+                    [{:channel_type (:type chn-1)
+                      :channel_id   (:id chn-1)
+                      :template_id  (:id tmpl-1)
+                      :recipients   [{:type     :notification-recipient/user
+                                      :user_id  (mt/user->id :rasta)}]}])]
+          (t2/delete! :model/ChannelTemplate (:id tmpl-1))
+          (is (=? {:template_id nil} (t2/select-one :model/NotificationHandler :notification_id (:id noti)))))))))
 
 (deftest cross-check-channel-type-and-template-type-test
   (testing "can't create a handler with a template that has different channel type"
diff --git a/test/metabase/models/user_test.clj b/test/metabase/models/user_test.clj
index b33f79b30b8cc2e9acb79ec238022a39fea4ebc9..15a19adb78329383726e04abbc17d2219ec89b64 100644
--- a/test/metabase/models/user_test.clj
+++ b/test/metabase/models/user_test.clj
@@ -31,6 +31,7 @@
    [metabase.server.middleware.session :as mw.session]
    [metabase.test :as mt]
    [metabase.test.data.users :as test.users]
+   [metabase.test.fixtures :as fixtures]
    [metabase.test.integrations.ldap :as ldap.test]
    [metabase.util :as u]
    [metabase.util.password :as u.password]
@@ -39,6 +40,10 @@
 
 (set! *warn-on-reflection* true)
 
+(use-fixtures
+  :once
+  (fixtures/initialize :test-users))
+
 (comment
   ;; this has to be loaded for the Google Auth tests to work
   metabase.integrations.google/keep-me)
diff --git a/test/metabase/notification/payload/core_test.clj b/test/metabase/notification/payload/core_test.clj
new file mode 100644
index 0000000000000000000000000000000000000000..3a73bc919f07a2d4c0d6afc0c1a47af4396f61b3
--- /dev/null
+++ b/test/metabase/notification/payload/core_test.clj
@@ -0,0 +1,29 @@
+(ns metabase.notification.payload.core-test
+  (:require
+   [clojure.test :refer :all]
+   [metabase.notification.payload.core :as notification.payload]
+   [metabase.test :as mt]))
+
+(deftest default-context-test
+  (testing "you shouldn't delete or rename these fields without 100% sure that it's not referenced
+           in any channel_template.details or notification_recipient.details"
+    (mt/with-additional-premium-features #{:whitelabel}
+      (mt/with-temporary-setting-values
+        [application-name "Metabase Test"
+         site-name        "Metabase Test"
+         site-url         "https://metabase.com"
+         admin-email      "ngoc@metabase.com"]
+        (is (= {:payload_type :notification/system-event
+                :payload      {:event_info  {:foo :bar}
+                               :event_topic :event/user-joined
+                               :custom      {}}
+                :context     {:application_name     "Metabase Test"
+                              :application_logo_url "http://static.metabase.com/email_logo.png"
+                              :site_name            "Metabase Test"
+                              :site_url             "https://metabase.com"
+                              :admin_email          "ngoc@metabase.com"
+                              :style                {:button true}}}
+               (-> (notification.payload/notification-payload {:payload_type :notification/system-event
+                                                               :payload      {:event_topic :event/user-joined
+                                                                              :event_info {:foo :bar}}})
+                   (update-in [:context :style :button] string?))))))))
diff --git a/test/metabase/notification/system_event_test.clj b/test/metabase/notification/payload/impl/system_event_test.clj
similarity index 63%
rename from test/metabase/notification/system_event_test.clj
rename to test/metabase/notification/payload/impl/system_event_test.clj
index 8c28ef1b737d172bb6afb91029f7677183b90e95..8c6c164d6bfa4c8b67da7b6241155c72ba86a494 100644
--- a/test/metabase/notification/system_event_test.clj
+++ b/test/metabase/notification/payload/impl/system_event_test.clj
@@ -1,4 +1,4 @@
-(ns metabase.notification.system-event-test
+(ns metabase.notification.payload.impl.system-event-test
   (:require
    [clojure.test :refer :all]
    [metabase.events :as events]
@@ -23,79 +23,77 @@
 
 (deftest system-event-e2e-test
   (testing "a system event that sends to an email channel with a custom template to an user recipient"
-    (mt/with-model-cleanup [:model/Notification]
-      (notification.tu/with-send-notification-sync
-        (mt/with-temp [:model/ChannelTemplate tmpl {:channel_type :channel/email
-                                                    :details      {:type    :email/mustache-text
-                                                                   :subject "Welcome {{event-info.object.first_name}} to {{context.site-name}}"
-                                                                   :body    "Hello {{event-info.object.first_name}}! Welcome to {{context.site-name}}!"}}
-                       :model/User             {user-id :id} {:email "ngoc@metabase.com"}
-                       :model/PermissionsGroup {group-id :id} {:name "Avengers"}
-                       :model/PermissionsGroupMembership _ {:group_id group-id
-                                                            :user_id user-id}]
-          (let [rasta (mt/fetch-user :rasta)]
-            (models.notification/create-notification!
-             {:payload_type :notification/system-event}
-             [{:type       :notification-subscription/system-event
-               :event_name :event/user-invited}]
-             [{:channel_type :channel/email
-               :template_id  (:id tmpl)
-               :recipients   [{:type    :notification-recipient/user
-                               :user_id (mt/user->id :crowberto)}
-                              {:type                 :notification-recipient/group
-                               :permissions_group_id group-id}
-                              {:type    :notification-recipient/external-email
-                               :details {:email "hi@metabase.com"}}]}])
-            (mt/with-temporary-setting-values
-              [site-name "Metabase Test"]
-              (mt/with-fake-inbox
-                (publish-user-invited-event! rasta {:first_name "Ngoc" :email "ngoc@metabase.com"} false)
-                (let [email {:from    "notifications@metabase.com",
-                             :subject "Welcome Rasta to Metabase Test"
-                             :body    [{:type    "text/html; charset=utf-8"
-                                        :content "Hello Rasta! Welcome to Metabase Test!"}]}]
-                  (is (=? {"crowberto@metabase.com" [email]
-                           "ngoc@metabase.com"      [email]
-                           "hi@metabase.com"        [email]}
-                          @mt/inbox)))))))))))
+    (notification.tu/with-notification-testing-setup
+      (mt/with-temp [:model/ChannelTemplate tmpl {:channel_type :channel/email
+                                                  :details      {:type    :email/mustache-text
+                                                                 :subject "Welcome {{payload.event_info.object.first_name}} to {{context.site_name}}"
+                                                                 :body    "Hello {{payload.event_info.object.first_name}}! Welcome to {{context.site_name}}!"}}
+                     :model/User             {user-id :id} {:email "ngoc@metabase.com"}
+                     :model/PermissionsGroup {group-id :id} {:name "Avengers"}
+                     :model/PermissionsGroupMembership _ {:group_id group-id
+                                                          :user_id user-id}]
+        (let [rasta (mt/fetch-user :rasta)]
+          (models.notification/create-notification!
+           {:payload_type :notification/system-event}
+           [{:type       :notification-subscription/system-event
+             :event_name :event/user-invited}]
+           [{:channel_type :channel/email
+             :template_id  (:id tmpl)
+             :recipients   [{:type    :notification-recipient/user
+                             :user_id (mt/user->id :crowberto)}
+                            {:type                 :notification-recipient/group
+                             :permissions_group_id group-id}
+                            {:type    :notification-recipient/external-email
+                             :details {:email "hi@metabase.com"}}]}])
+          (mt/with-temporary-setting-values
+            [site-name "Metabase Test"]
+            (mt/with-fake-inbox
+              (publish-user-invited-event! rasta {:first_name "Ngoc" :email "ngoc@metabase.com"} false)
+              (let [email {:from    "notifications@metabase.com",
+                           :subject "Welcome Rasta to Metabase Test"
+                           :body    [{:type    "text/html; charset=utf-8"
+                                      :content "Hello Rasta! Welcome to Metabase Test!"}]}]
+                (is (=? {"crowberto@metabase.com" [email]
+                         "ngoc@metabase.com"      [email]
+                         "hi@metabase.com"        [email]}
+                        @mt/inbox))))))))))
 
 (deftest system-event-resouce-template-test
   (testing "a system event that sends to an email channel with a custom template to an user recipient"
-    (mt/with-model-cleanup [:model/Notification]
-      (notification.tu/with-send-notification-sync
-        (mt/with-temp [:model/ChannelTemplate tmpl {:channel_type :channel/email
-                                                    :details      {:type    :email/mustache-resource
-                                                                   :subject "Welcome {{event-info.object.first_name}} to {{context.site-name}}"
-                                                                   :path    "notification/channel_template/hello_world"}}
-                       :model/User             {user-id :id} {:email "ngoc@metabase.com"}
-                       :model/PermissionsGroup {group-id :id} {:name "Avengers"}
-                       :model/PermissionsGroupMembership _ {:group_id group-id
-                                                            :user_id user-id}]
-          (let [rasta (mt/fetch-user :rasta)]
-            (models.notification/create-notification!
-             {:payload_type :notification/system-event}
-             [{:type       :notification-subscription/system-event
-               :event_name :event/user-invited}]
-             [{:channel_type :channel/email
-               :template_id  (:id tmpl)
-               :recipients   [{:type    :notification-recipient/user
-                               :user_id (mt/user->id :crowberto)}
-                              {:type                 :notification-recipient/group
-                               :permissions_group_id group-id}
-                              {:type    :notification-recipient/external-email
-                               :details {:email "hi@metabase.com"}}]}])
-            (mt/with-temporary-setting-values
-              [site-name "Metabase Test"]
-              (mt/with-fake-inbox
-                (publish-user-invited-event! rasta {:first_name "Ngoc" :email "ngoc@metabase.com"} false)
-                (let [email {:from    "notifications@metabase.com",
-                             :subject "Welcome Rasta to Metabase Test"
-                             :body    [{:type    "text/html; charset=utf-8"
-                                        :content "Hello Rasta! Welcome to Metabase Test!\n"}]}]
-                  (is (=? {"crowberto@metabase.com" [email]
-                           "ngoc@metabase.com"      [email]
-                           "hi@metabase.com"        [email]}
-                          @mt/inbox)))))))))))
+    (notification.tu/with-notification-testing-setup
+      (mt/with-temp [:model/ChannelTemplate tmpl {:channel_type :channel/email
+                                                  :details      {:type    :email/mustache-resource
+                                                                 :subject "Welcome {{payload.event_info.object.first_name}} to {{context.site_name}}"
+                                                                 :path    "notification/channel_template/hello_world"}}
+                     :model/User             {user-id :id} {:email "ngoc@metabase.com"}
+                     :model/PermissionsGroup {group-id :id} {:name "Avengers"}
+                     :model/PermissionsGroupMembership _ {:group_id group-id
+                                                          :user_id user-id}]
+        (let [rasta (mt/fetch-user :rasta)]
+          (models.notification/create-notification!
+           {:payload_type :notification/system-event}
+           [{:type       :notification-subscription/system-event
+             :event_name :event/user-invited}]
+           [{:channel_type :channel/email
+             :template_id  (:id tmpl)
+             :recipients   [{:type    :notification-recipient/user
+                             :user_id (mt/user->id :crowberto)}
+                            {:type                 :notification-recipient/group
+                             :permissions_group_id group-id}
+                            {:type    :notification-recipient/external-email
+                             :details {:email "hi@metabase.com"}}]}])
+          (mt/with-temporary-setting-values
+            [site-name "Metabase Test"]
+            (mt/with-fake-inbox
+              (publish-user-invited-event! rasta {:first_name "Ngoc" :email "ngoc@metabase.com"} false)
+              (let [email {:from    "notifications@metabase.com",
+                           :subject "Welcome Rasta to Metabase Test"
+                           :body    [{:type    "text/html; charset=utf-8"
+                                      :content "Hello Rasta! Welcome to Metabase Test!\n"}]}]
+                (is (=? {"crowberto@metabase.com" [email]
+                         "ngoc@metabase.com"      [email]
+                         "hi@metabase.com"        [email]}
+                        @mt/inbox))))))))))
 
 (deftest user-invited-event-send-email-test
   (testing "publish an :user-invited event will send an email"
diff --git a/test/metabase/notification/core_test.clj b/test/metabase/notification/send_test.clj
similarity index 86%
rename from test/metabase/notification/core_test.clj
rename to test/metabase/notification/send_test.clj
index 8ee3cfb14dc0698c1853cf131a1096785008419e..2de32b4959ae64f47eed21ce86805f76ecdd4285 100644
--- a/test/metabase/notification/core_test.clj
+++ b/test/metabase/notification/send_test.clj
@@ -1,9 +1,9 @@
-(ns metabase.notification.core-test
+(ns metabase.notification.send-test
   (:require
    [clojure.test :refer :all]
    [metabase.channel.core :as channel]
    [metabase.models.notification :as models.notification]
-   [metabase.notification.core :as notification]
+   [metabase.notification.send :as notification.send]
    [metabase.notification.test-util :as notification.tu]
    [metabase.test :as mt]
    [metabase.test.fixtures :as fixtures]
@@ -29,13 +29,17 @@
                                    :channel_id   (:id chn-2)
                                    :recipients   [{:type    :notification-recipient/user
                                                    :user_id (mt/user->id :rasta)}]}])
-              notification-info (assoc n :payload {:event-info  {:test true}
-                                                   :event-topic :event/test
-                                                   :context     {:test true}})
+              notification-info (assoc n :payload {:event_info  {:test true}
+                                                   :event_topic :event/test})
+              expected-notification-payload (mt/malli=?
+                                             [:map
+                                              [:payload_type [:= :notification/system-event]]
+                                              [:context :map]
+                                              [:payload :map]])
               renders           (atom [])]
-          (mt/with-dynamic-redefs [channel/render-notification (fn [channel-type notification template recipients]
+          (mt/with-dynamic-redefs [channel/render-notification (fn [channel-type notification-payload template recipients]
                                                                  (swap! renders conj {:channel-type channel-type
-                                                                                      :notification notification
+                                                                                      :notification-payload notification-payload
                                                                                       :template template
                                                                                       :recipients recipients})
                                                                  ;; rendered messages are recipients
@@ -44,15 +48,15 @@
               (is (=? {:channel/metabase-test [{:type :notification-recipient/user :user_id (mt/user->id :crowberto)}
                                                {:type :notification-recipient/user :user_id (mt/user->id :rasta)}]}
                       (notification.tu/with-captured-channel-send!
-                        (notification/*send-notification!* notification-info)))))
+                        (notification.send/send-notification-sync! notification-info)))))
 
             (testing "render-notification is called on all handlers with the correct channel and template"
               (is (=? [{:channel-type (keyword notification.tu/test-channel-type)
-                        :notification notification-info
+                        :notification-payload expected-notification-payload
                         :template     tmpl
                         :recipients   [{:type :notification-recipient/user :user_id (mt/user->id :crowberto)}]}
                        {:channel-type (keyword notification.tu/test-channel-type)
-                        :notification notification-info
+                        :notification-payload expected-notification-payload
                         :template     nil
                         :recipients   [{:type :notification-recipient/user :user_id (mt/user->id :rasta)}]}]
                       @renders)))))))))
@@ -67,7 +71,7 @@
                  :channel_id   (:id chn)
                  :recipients   [{:type :notification-recipient/user :user_id (mt/user->id :crowberto)}]}])]
         (t2/delete! :model/TaskHistory)
-        (notification/*send-notification!* n)
+        (notification.send/send-notification-sync! n)
         (is (=? [{:task         "notification-send"
                   :task_details {:notification_id (:id n)
                                  :notification_handlers [{:id           (mt/malli=? :int)
@@ -105,7 +109,7 @@
                                   (throw (Exception. "test-exception"))
                                   (reset! send-args args)))]
               (with-redefs [channel/send! send!]
-                (notification/*send-notification!* n))
+                (notification.send/send-notification-sync! n))
               (is (some? @send-args))
               (is (=? {:task "channel-send"
                        :task_details {:attempted_retries 1
diff --git a/test/metabase/notification/test_util.clj b/test/metabase/notification/test_util.clj
index c5df6f2be4dd4e4d8312feb8f532ed0a05643dca..bf4628f30ef9926116b83bf9c497c3abf3222baf 100644
--- a/test/metabase/notification/test_util.clj
+++ b/test/metabase/notification/test_util.clj
@@ -5,6 +5,8 @@
    [metabase.channel.core :as channel]
    [metabase.events.notification :as events.notification]
    [metabase.notification.core :as notification]
+   [metabase.notification.payload.core :as notification.payload]
+   [metabase.notification.send :as notification.send]
    [metabase.test :as mt]
    [metabase.util :as u]))
 
@@ -29,11 +31,15 @@
   [_channel-type notification-info _template _recipients]
   [notification-info])
 
+(defmethod notification.payload/payload :notification/testing
+  [_notification]
+  {::payload? true})
+
 #_{:clj-kondo/ignore [:metabase/test-helpers-use-non-thread-safe-functions]}
 (defmacro with-send-notification-sync
   "Notifications are sent async by default, wrap the body in this macro to send them synchronously."
   [& body]
-  `(binding [notification/*send-notification!* #'notification/send-notification-sync!]
+  `(binding [notification/*send-notification!* #'notification.send/send-notification-sync!]
      ~@body))
 
 (defn do-with-captured-channel-send!
@@ -96,27 +102,9 @@
                  :return-value true}
    :active      true})
 
-;; :model/ChannelTemplate
-
 (def channel-template-email-with-mustache-body
-  "A :model/ChannelTemplate for email channels that has a :event/mustache template."
+  "A :model/ChannelTemplate for email channels that has a :event/mustache-text template."
   {:channel_type :channel/email
    :details      {:type    :email/mustache-text
-                  :subject "Welcome {{event-info.object.first_name}} to {{settings.site-name}}"
-                  :body    "Hello {{event-info.object.first_name}}! Welcome to {{settings.site-name}}!"}})
-
-;; notification info
-(def notification-info-user-joined-event
-  "A notification-info of the user-joined system event notification that can be used
-  to test [[channel/render-notification]]."
-  {:payload_type :notification/system-event
-   :payload      (#'events.notification/enriched-event-info
-                  :event/user-joined
-                  {:object
-                   {:email        "rasta@metabase.com"
-                    :first_name   "Rasta"
-                    :last_login   nil
-                    :is_qbnewb    true
-                    :is_superuser false
-                    :last_name    "Toucan"
-                    :common_name  "Rasta Toucan"}})})
+                  :subject "Welcome {{payload.event_info.object.first_name}} to {{context.site_name}}"
+                  :body    "Hello {{payload.event_info.object.first_name}}! Welcome to {{context.site_name}}!"}})
diff --git a/test/metabase/test/initialize/plugins.clj b/test/metabase/test/initialize/plugins.clj
index 349a81431eaf7033c985d0d8aa6768f51d1087ed..fe29d5f26ecaea84ebaee8b1e0b7e9b6e8f1f204 100644
--- a/test/metabase/test/initialize/plugins.clj
+++ b/test/metabase/test/initialize/plugins.clj
@@ -2,7 +2,6 @@
   (:require
    [clojure.java.io :as io]
    [clojure.tools.reader.edn :as edn]
-   [metabase.channel.core :as channel]
    [metabase.plugins :as plugins]
    [metabase.plugins.initialize :as plugins.init]
    [metabase.test.data.env.impl :as tx.env.impl]
@@ -62,8 +61,7 @@
 
 (defn init! []
   (plugins/load-plugins!)
-  (load-plugin-manifests!)
-  (channel/find-and-load-metabase-channels!))
+  (load-plugin-manifests!))
 
 (defn init-test-drivers!
   "Explicitly initialize the given test `drivers` via plugin manifests. These manifests can live in test_modules (having
diff --git a/test_resources/notification/channel_template/hello_world.mustache b/test_resources/notification/channel_template/hello_world.mustache
index 3fc51fda50178c0b558e0c14c13292f5026655c0..d3aa045159c33a9ef8ff29011372dc07f38d0109 100644
--- a/test_resources/notification/channel_template/hello_world.mustache
+++ b/test_resources/notification/channel_template/hello_world.mustache
@@ -1 +1 @@
-Hello {{event-info.object.first_name}}! Welcome to {{context.site-name}}!
+Hello {{payload.event_info.object.first_name}}! Welcome to {{context.site_name}}!