diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go
index b03108e95cc72..e18aeaef88b81 100644
--- a/coderd/notifications/dispatch/smtp.go
+++ b/coderd/notifications/dispatch/smtp.go
@@ -55,15 +55,13 @@ type SMTPHandler struct {
noAuthWarnOnce sync.Once
loginWarnOnce sync.Once
-
- helpers template.FuncMap
}
-func NewSMTPHandler(cfg codersdk.NotificationsEmailConfig, helpers template.FuncMap, log slog.Logger) *SMTPHandler {
- return &SMTPHandler{cfg: cfg, helpers: helpers, log: log}
+func NewSMTPHandler(cfg codersdk.NotificationsEmailConfig, log slog.Logger) *SMTPHandler {
+ return &SMTPHandler{cfg: cfg, log: log}
}
-func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) {
+func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string, helpers template.FuncMap) (DeliveryFunc, error) {
// First render the subject & body into their own discrete strings.
subject, err := markdown.PlaintextFromMarkdown(titleTmpl)
if err != nil {
@@ -79,12 +77,12 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
// Then, reuse these strings in the HTML & plain body templates.
payload.Labels["_subject"] = subject
payload.Labels["_body"] = htmlBody
- htmlBody, err = render.GoTemplate(htmlTemplate, payload, s.helpers)
+ htmlBody, err = render.GoTemplate(htmlTemplate, payload, helpers)
if err != nil {
return nil, xerrors.Errorf("render full html template: %w", err)
}
payload.Labels["_body"] = plainBody
- plainBody, err = render.GoTemplate(plainTemplate, payload, s.helpers)
+ plainBody, err = render.GoTemplate(plainTemplate, payload, helpers)
if err != nil {
return nil, xerrors.Errorf("render full plaintext template: %w", err)
}
diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl
index 78ac053cc7b4f..23a549288fa15 100644
--- a/coderd/notifications/dispatch/smtp/html.gotmpl
+++ b/coderd/notifications/dispatch/smtp/html.gotmpl
@@ -8,7 +8,7 @@
-

+
{{ .Labels._subject }}
diff --git a/coderd/notifications/dispatch/smtp_test.go b/coderd/notifications/dispatch/smtp_test.go
index 2687e0d82bb26..c9a60b426ae70 100644
--- a/coderd/notifications/dispatch/smtp_test.go
+++ b/coderd/notifications/dispatch/smtp_test.go
@@ -442,11 +442,7 @@ func TestSMTP(t *testing.T) {
require.NoError(t, hp.Set(listen.Addr().String()))
tc.cfg.Smarthost = hp
- helpers := map[string]any{
- "base_url": func() string { return "http://test.com" },
- "current_year": func() string { return "2024" },
- }
- handler := dispatch.NewSMTPHandler(tc.cfg, helpers, logger.Named("smtp"))
+ handler := dispatch.NewSMTPHandler(tc.cfg, logger.Named("smtp"))
// Start mock SMTP server in the background.
var wg sync.WaitGroup
@@ -484,7 +480,7 @@ func TestSMTP(t *testing.T) {
Labels: make(map[string]string),
}
- dispatchFn, err := handler.Dispatcher(payload, subject, body)
+ dispatchFn, err := handler.Dispatcher(payload, subject, body, helpers())
require.NoError(t, err)
msgID := uuid.New()
diff --git a/coderd/notifications/dispatch/utils_test.go b/coderd/notifications/dispatch/utils_test.go
new file mode 100644
index 0000000000000..3ed4e09cffc11
--- /dev/null
+++ b/coderd/notifications/dispatch/utils_test.go
@@ -0,0 +1,10 @@
+package dispatch_test
+
+func helpers() map[string]any {
+ return map[string]any{
+ "base_url": func() string { return "http://test.com" },
+ "current_year": func() string { return "2024" },
+ "logo_url": func() string { return "https://coder.com/coder-logo-horizontal.png" },
+ "app_name": func() string { return "Coder" },
+ }
+}
diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go
index fcad3a7b0eae2..1322996db10e1 100644
--- a/coderd/notifications/dispatch/webhook.go
+++ b/coderd/notifications/dispatch/webhook.go
@@ -7,6 +7,7 @@ import (
"errors"
"io"
"net/http"
+ "text/template"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -41,7 +42,7 @@ func NewWebhookHandler(cfg codersdk.NotificationsWebhookConfig, log slog.Logger)
return &WebhookHandler{cfg: cfg, log: log, cl: &http.Client{}}
}
-func (w *WebhookHandler) Dispatcher(payload types.MessagePayload, titleMarkdown, bodyMarkdown string) (DeliveryFunc, error) {
+func (w *WebhookHandler) Dispatcher(payload types.MessagePayload, titleMarkdown, bodyMarkdown string, _ template.FuncMap) (DeliveryFunc, error) {
if w.cfg.Endpoint.String() == "" {
return nil, xerrors.New("webhook endpoint not defined")
}
diff --git a/coderd/notifications/dispatch/webhook_test.go b/coderd/notifications/dispatch/webhook_test.go
index 26a78752cfd45..9f898a6fd6efd 100644
--- a/coderd/notifications/dispatch/webhook_test.go
+++ b/coderd/notifications/dispatch/webhook_test.go
@@ -141,7 +141,7 @@ func TestWebhook(t *testing.T) {
Endpoint: *serpent.URLOf(endpoint),
}
handler := dispatch.NewWebhookHandler(cfg, logger.With(slog.F("test", tc.name)))
- deliveryFn, err := handler.Dispatcher(msgPayload, titleMarkdown, bodyMarkdown)
+ deliveryFn, err := handler.Dispatcher(msgPayload, titleMarkdown, bodyMarkdown, helpers())
require.NoError(t, err)
retryable, err := deliveryFn(ctx, msgID)
diff --git a/coderd/notifications/fetcher.go b/coderd/notifications/fetcher.go
new file mode 100644
index 0000000000000..82405049f933a
--- /dev/null
+++ b/coderd/notifications/fetcher.go
@@ -0,0 +1,57 @@
+package notifications
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "text/template"
+
+ "golang.org/x/xerrors"
+
+ "cdr.dev/slog"
+)
+
+func (n *notifier) fetchHelpers(ctx context.Context) (map[string]any, error) {
+ appName, err := n.fetchAppName(ctx)
+ if err != nil {
+ n.log.Error(ctx, "failed to fetch app name", slog.Error(err))
+ return nil, xerrors.Errorf("fetch app name: %w", err)
+ }
+ logoURL, err := n.fetchLogoURL(ctx)
+ if err != nil {
+ n.log.Error(ctx, "failed to fetch logo URL", slog.Error(err))
+ return nil, xerrors.Errorf("fetch logo URL: %w", err)
+ }
+
+ helpers := make(template.FuncMap)
+ for k, v := range n.helpers {
+ helpers[k] = v
+ }
+
+ helpers["app_name"] = func() string { return appName }
+ helpers["logo_url"] = func() string { return logoURL }
+
+ return helpers, nil
+}
+
+func (n *notifier) fetchAppName(ctx context.Context) (string, error) {
+ appName, err := n.store.GetApplicationName(ctx)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return notificationsDefaultAppName, nil
+ }
+ return "", xerrors.Errorf("get application name: %w", err)
+ }
+ return appName, nil
+}
+
+func (n *notifier) fetchLogoURL(ctx context.Context) (string, error) {
+ logoURL, err := n.store.GetLogoURL(ctx)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return notificationsDefaultLogoURL, nil
+ }
+ return "", xerrors.Errorf("get logo URL: %w", err)
+ }
+ return logoURL, nil
+}
diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go
index 33d7c0b96571d..ff516bfe5d2ec 100644
--- a/coderd/notifications/manager.go
+++ b/coderd/notifications/manager.go
@@ -109,7 +109,7 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template.
stop: make(chan any),
done: make(chan any),
- handlers: defaultHandlers(cfg, helpers, log),
+ handlers: defaultHandlers(cfg, log),
helpers: helpers,
clock: quartz.NewReal(),
@@ -121,9 +121,9 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template.
}
// defaultHandlers builds a set of known handlers; panics if any error occurs as these handlers should be valid at compile time.
-func defaultHandlers(cfg codersdk.NotificationsConfig, helpers template.FuncMap, log slog.Logger) map[database.NotificationMethod]Handler {
+func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger) map[database.NotificationMethod]Handler {
return map[database.NotificationMethod]Handler{
- database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, helpers, log.Named("dispatcher.smtp")),
+ database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")),
database.NotificationMethodWebhook: dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")),
}
}
diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go
index 53aa8f1354ec4..dcb7c8cc46af6 100644
--- a/coderd/notifications/manager_test.go
+++ b/coderd/notifications/manager_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"sync/atomic"
"testing"
+ "text/template"
"time"
"github.com/google/uuid"
@@ -210,8 +211,8 @@ type santaHandler struct {
nice atomic.Int32
}
-func (s *santaHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) {
- return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) {
+func (s *santaHandler) Dispatcher(payload types.MessagePayload, _, _ string, _ template.FuncMap) (dispatch.DeliveryFunc, error) {
+ return func(_ context.Context, _ uuid.UUID) (retryable bool, err error) {
if payload.Labels["nice"] != "true" {
s.naughty.Add(1)
return false, xerrors.New("be nice")
diff --git a/coderd/notifications/metrics_test.go b/coderd/notifications/metrics_test.go
index 6dec66f4bc981..d463560b33257 100644
--- a/coderd/notifications/metrics_test.go
+++ b/coderd/notifications/metrics_test.go
@@ -5,6 +5,7 @@ import (
"strconv"
"sync"
"testing"
+ "text/template"
"time"
"github.com/google/uuid"
@@ -44,7 +45,7 @@ func TestMetrics(t *testing.T) {
reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
- template := notifications.TemplateWorkspaceDeleted
+ tmpl := notifications.TemplateWorkspaceDeleted
const (
method = database.NotificationMethodSmtp
@@ -76,7 +77,7 @@ func TestMetrics(t *testing.T) {
user := createSampleUser(t, store)
// Build fingerprints for the two different series we expect.
- methodTemplateFP := fingerprintLabels(notifications.LabelMethod, string(method), notifications.LabelTemplateID, template.String())
+ methodTemplateFP := fingerprintLabels(notifications.LabelMethod, string(method), notifications.LabelTemplateID, tmpl.String())
methodFP := fingerprintLabels(notifications.LabelMethod, string(method))
expected := map[string]func(metric *dto.Metric, series string) bool{
@@ -90,7 +91,7 @@ func TestMetrics(t *testing.T) {
var match string
for result, val := range results {
- seriesFP := fingerprintLabels(notifications.LabelMethod, string(method), notifications.LabelTemplateID, template.String(), notifications.LabelResult, result)
+ seriesFP := fingerprintLabels(notifications.LabelMethod, string(method), notifications.LabelTemplateID, tmpl.String(), notifications.LabelResult, result)
if !hasMatchingFingerprint(metric, seriesFP) {
continue
}
@@ -165,9 +166,9 @@ func TestMetrics(t *testing.T) {
}
// WHEN: 2 notifications are enqueued, 1 of which will fail until its retries are exhausted, and another which will succeed
- _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success"}, "test") // this will succeed
+ _, err = enq.Enqueue(ctx, user.ID, tmpl, map[string]string{"type": "success"}, "test") // this will succeed
require.NoError(t, err)
- _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "failure"}, "test2") // this will fail and retry (maxAttempts - 1) times
+ _, err = enq.Enqueue(ctx, user.ID, tmpl, map[string]string{"type": "failure"}, "test2") // this will fail and retry (maxAttempts - 1) times
require.NoError(t, err)
mgr.Run(ctx)
@@ -218,7 +219,7 @@ func TestPendingUpdatesMetric(t *testing.T) {
reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
- template := notifications.TemplateWorkspaceDeleted
+ tmpl := notifications.TemplateWorkspaceDeleted
const method = database.NotificationMethodSmtp
@@ -252,9 +253,9 @@ func TestPendingUpdatesMetric(t *testing.T) {
user := createSampleUser(t, store)
// WHEN: 2 notifications are enqueued, one of which will fail and one which will succeed
- _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success"}, "test") // this will succeed
+ _, err = enq.Enqueue(ctx, user.ID, tmpl, map[string]string{"type": "success"}, "test") // this will succeed
require.NoError(t, err)
- _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "failure"}, "test2") // this will fail and retry (maxAttempts - 1) times
+ _, err = enq.Enqueue(ctx, user.ID, tmpl, map[string]string{"type": "failure"}, "test2") // this will fail and retry (maxAttempts - 1) times
require.NoError(t, err)
mgr.Run(ctx)
@@ -309,7 +310,7 @@ func TestInflightDispatchesMetric(t *testing.T) {
reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
- template := notifications.TemplateWorkspaceDeleted
+ tmpl := notifications.TemplateWorkspaceDeleted
const method = database.NotificationMethodSmtp
@@ -342,7 +343,7 @@ func TestInflightDispatchesMetric(t *testing.T) {
// WHEN: notifications are enqueued which will succeed (and be delayed during dispatch)
for i := 0; i < msgCount; i++ {
- _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success", "i": strconv.Itoa(i)}, "test")
+ _, err = enq.Enqueue(ctx, user.ID, tmpl, map[string]string{"type": "success", "i": strconv.Itoa(i)}, "test")
require.NoError(t, err)
}
@@ -351,7 +352,7 @@ func TestInflightDispatchesMetric(t *testing.T) {
// THEN:
// Ensure we see the dispatches of the messages inflight.
require.Eventually(t, func() bool {
- return promtest.ToFloat64(metrics.InflightDispatches.WithLabelValues(string(method), template.String())) == msgCount
+ return promtest.ToFloat64(metrics.InflightDispatches.WithLabelValues(string(method), tmpl.String())) == msgCount
}, testutil.WaitShort, testutil.IntervalFast)
for i := 0; i < msgCount; i++ {
@@ -389,7 +390,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
var (
reg = prometheus.NewRegistry()
metrics = notifications.NewMetrics(reg)
- template = notifications.TemplateWorkspaceDeleted
+ tmpl = notifications.TemplateWorkspaceDeleted
anotherTemplate = notifications.TemplateWorkspaceDormant
)
@@ -400,7 +401,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
// GIVEN: a template whose notification method differs from the default.
out, err := store.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
- ID: template,
+ ID: tmpl,
Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
})
require.NoError(t, err)
@@ -426,7 +427,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
user := createSampleUser(t, store)
- _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success"}, "test")
+ _, err = enq.Enqueue(ctx, user.ID, tmpl, map[string]string{"type": "success"}, "test")
require.NoError(t, err)
_, err = enq.Enqueue(ctx, user.ID, anotherTemplate, map[string]string{"type": "success"}, "test")
require.NoError(t, err)
@@ -447,7 +448,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
// THEN: we should have metric series for both the default and custom notification methods.
require.Eventually(t, func() bool {
return promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(defaultMethod), anotherTemplate.String(), notifications.ResultSuccess)) > 0 &&
- promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(customMethod), template.String(), notifications.ResultSuccess)) > 0
+ promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(customMethod), tmpl.String(), notifications.ResultSuccess)) > 0
}, testutil.WaitShort, testutil.IntervalFast)
}
@@ -525,8 +526,8 @@ func newBarrierHandler(total int, handler notifications.Handler) *barrierHandler
}
}
-func (bh *barrierHandler) Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) {
- deliverFn, err := bh.h.Dispatcher(payload, title, body)
+func (bh *barrierHandler) Dispatcher(payload types.MessagePayload, title, body string, helpers template.FuncMap) (dispatch.DeliveryFunc, error) {
+ deliverFn, err := bh.h.Dispatcher(payload, title, body, helpers)
if err != nil {
return nil, err
}
diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go
index b69d8910a0ce8..4a6978b5024fe 100644
--- a/coderd/notifications/notifications_test.go
+++ b/coderd/notifications/notifications_test.go
@@ -22,6 +22,7 @@ import (
"strings"
"sync"
"testing"
+ "text/template"
"time"
"github.com/emersion/go-sasl"
@@ -157,7 +158,7 @@ func TestSMTPDispatch(t *testing.T) {
Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())},
Hello: "localhost",
}
- handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, defaultHelpers(), logger.Named("smtp")))
+ handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger.Named("smtp")))
mgr, err := notifications.NewManager(cfg, store, defaultHelpers(), createMetrics(), logger.Named("manager"))
require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler})
@@ -751,6 +752,9 @@ func TestNotificationTemplates_Golden(t *testing.T) {
name string
id uuid.UUID
payload types.MessagePayload
+
+ appName string
+ logoURL string
}{
{
name: "TemplateWorkspaceDeleted",
@@ -1001,6 +1005,22 @@ func TestNotificationTemplates_Golden(t *testing.T) {
},
},
},
+ {
+ name: "TemplateWorkspaceDeleted_CustomAppearance",
+ id: notifications.TemplateWorkspaceDeleted,
+ payload: types.MessagePayload{
+ UserName: "Bobby",
+ UserEmail: "bobby@coder.com",
+ UserUsername: "bobby",
+ Labels: map[string]string{
+ "name": "bobby-workspace",
+ "reason": "autodeleted due to dormancy",
+ "initiator": "autobuild",
+ },
+ },
+ appName: "Custom Application Name",
+ logoURL: "https://custom.application/logo.png",
+ },
}
// We must have a test case for every notification_template. This is enforced below:
@@ -1122,6 +1142,19 @@ func TestNotificationTemplates_Golden(t *testing.T) {
)
require.NoError(t, err)
+ // we apply ApplicationName and LogoURL changes directly in the db
+ // as appearance changes are enterprise features and we do not want to mix those
+ // can't use the api
+ if tc.appName != "" {
+ err = (*db).UpsertApplicationName(ctx, "Custom Application")
+ require.NoError(t, err)
+ }
+
+ if tc.logoURL != "" {
+ err = (*db).UpsertLogoURL(ctx, "https://custom.application/logo.png")
+ require.NoError(t, err)
+ }
+
smtpManager.Run(ctx)
notificationCfg := defaultNotificationsConfig(database.NotificationMethodSmtp)
@@ -1460,12 +1493,12 @@ func TestCustomNotificationMethod(t *testing.T) {
// GIVEN: a notification template which has a method explicitly set
var (
- template = notifications.TemplateWorkspaceDormant
+ tmpl = notifications.TemplateWorkspaceDormant
defaultMethod = database.NotificationMethodSmtp
customMethod = database.NotificationMethodWebhook
)
out, err := store.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
- ID: template,
+ ID: tmpl,
Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
})
require.NoError(t, err)
@@ -1493,7 +1526,7 @@ func TestCustomNotificationMethod(t *testing.T) {
// WHEN: a notification of that template is enqueued, it should be delivered with the configured method - not the default.
user := createSampleUser(t, store)
- msgID, err := enq.Enqueue(ctx, user.ID, template, map[string]string{}, "test")
+ msgID, err := enq.Enqueue(ctx, user.ID, tmpl, map[string]string{}, "test")
require.NoError(t, err)
// THEN: the notification should be received by the custom dispatch method
@@ -1609,7 +1642,7 @@ type fakeHandler struct {
succeeded, failed []string
}
-func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) {
+func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string, _ template.FuncMap) (dispatch.DeliveryFunc, error) {
return func(_ context.Context, msgID uuid.UUID) (retryable bool, err error) {
f.mu.Lock()
defer f.mu.Unlock()
diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go
index 8a8c92b3e81d1..5fa71d80ce175 100644
--- a/coderd/notifications/notifier.go
+++ b/coderd/notifications/notifier.go
@@ -22,6 +22,13 @@ import (
"github.com/coder/coder/v2/coderd/database"
)
+const (
+ notificationsDefaultLogoURL = "https://coder.com/coder-logo-horizontal.png"
+ notificationsDefaultAppName = "Coder"
+)
+
+var errDecorateHelpersFailed = xerrors.New("failed to decorate helpers")
+
// notifier is a consumer of the notifications_messages queue. It dequeues messages from that table and processes them
// through a pipeline of fetch -> prepare -> render -> acquire handler -> deliver.
type notifier struct {
@@ -158,8 +165,7 @@ func (n *notifier) process(ctx context.Context, success chan<- dispatchResult, f
deliverFn, err := n.prepare(ctx, msg)
if err != nil {
n.log.Warn(ctx, "dispatcher construction failed", slog.F("msg_id", msg.ID), slog.Error(err))
- failure <- n.newFailedDispatch(msg, err, false)
-
+ failure <- n.newFailedDispatch(msg, err, xerrors.Is(err, errDecorateHelpersFailed))
n.metrics.PendingUpdates.Set(float64(len(success) + len(failure)))
continue
}
@@ -218,15 +224,20 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification
return nil, xerrors.Errorf("failed to resolve handler %q", msg.Method)
}
+ helpers, err := n.fetchHelpers(ctx)
+ if err != nil {
+ return nil, errDecorateHelpersFailed
+ }
+
var title, body string
- if title, err = render.GoTemplate(msg.TitleTemplate, payload, n.helpers); err != nil {
+ if title, err = render.GoTemplate(msg.TitleTemplate, payload, helpers); err != nil {
return nil, xerrors.Errorf("render title: %w", err)
}
- if body, err = render.GoTemplate(msg.BodyTemplate, payload, n.helpers); err != nil {
+ if body, err = render.GoTemplate(msg.BodyTemplate, payload, helpers); err != nil {
return nil, xerrors.Errorf("render body: %w", err)
}
- return handler.Dispatcher(payload, title, body)
+ return handler.Dispatcher(payload, title, body, helpers)
}
// deliver sends a given notification message via its defined method.
diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go
index b8ae063cc919e..7ac40b6cae8b8 100644
--- a/coderd/notifications/spec.go
+++ b/coderd/notifications/spec.go
@@ -2,6 +2,7 @@ package notifications
import (
"context"
+ "text/template"
"github.com/google/uuid"
@@ -22,12 +23,14 @@ type Store interface {
FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error)
GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error)
GetNotificationsSettings(ctx context.Context) (string, error)
+ GetApplicationName(ctx context.Context) (string, error)
+ GetLogoURL(ctx context.Context) (string, error)
}
// Handler is responsible for preparing and delivering a notification by a given method.
type Handler interface {
// Dispatcher constructs a DeliveryFunc to be used for delivering a notification via the chosen method.
- Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error)
+ Dispatcher(payload types.MessagePayload, title, body string, helpers template.FuncMap) (dispatch.DeliveryFunc, error)
}
// Enqueuer enqueues a new notification message in the store and returns its ID, should it enqueue without failure.
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden
new file mode 100644
index 0000000000000..a6aa1f62d9ab9
--- /dev/null
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden
@@ -0,0 +1,90 @@
+From: system@coder.com
+To: bobby@coder.com
+Subject: Workspace "bobby-workspace" deleted
+Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
+Date: Fri, 11 Oct 2024 09:03:06 +0000
+Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
+MIME-Version: 1.0
+
+--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+Hi Bobby,
+
+Your workspace bobby-workspace was deleted.
+
+The specified reason was "autodeleted due to dormancy (autobuild)".
+
+
+View workspaces: http://test.com/workspaces
+
+View templates: http://test.com/templates
+
+--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/html; charset=UTF-8
+
+
+
+
+
+
+ Workspace "bobby-workspace" deleted
+
+
+
+
+

+
+
+ Workspace "bobby-workspace" deleted
+
+
+
Hi Bobby,
+
+
Your workspace bobby-workspace was deleted.
+
+
The specified reason was “autodeleted due to dormancy (aut=
+obuild)”.
+
+
+
+
+
+
+
+--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDeleted_CustomAppearance.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDeleted_CustomAppearance.json.golden
new file mode 100644
index 0000000000000..171e893dd943f
--- /dev/null
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDeleted_CustomAppearance.json.golden
@@ -0,0 +1,33 @@
+{
+ "_version": "1.1",
+ "msg_id": "00000000-0000-0000-0000-000000000000",
+ "payload": {
+ "_version": "1.1",
+ "notification_name": "Workspace Deleted",
+ "notification_template_id": "00000000-0000-0000-0000-000000000000",
+ "user_id": "00000000-0000-0000-0000-000000000000",
+ "user_email": "bobby@coder.com",
+ "user_name": "Bobby",
+ "user_username": "bobby",
+ "actions": [
+ {
+ "label": "View workspaces",
+ "url": "http://test.com/workspaces"
+ },
+ {
+ "label": "View templates",
+ "url": "http://test.com/templates"
+ }
+ ],
+ "labels": {
+ "initiator": "autobuild",
+ "name": "bobby-workspace",
+ "reason": "autodeleted due to dormancy"
+ },
+ "data": null
+ },
+ "title": "Workspace \"bobby-workspace\" deleted",
+ "title_markdown": "Workspace \"bobby-workspace\" deleted",
+ "body": "Hi Bobby,\n\nYour workspace bobby-workspace was deleted.\n\nThe specified reason was \"autodeleted due to dormancy (autobuild)\".",
+ "body_markdown": "Hi Bobby,\n\nYour workspace **bobby-workspace** was deleted.\n\nThe specified reason was \"**autodeleted due to dormancy (autobuild)**\"."
+}
\ No newline at end of file
diff --git a/coderd/notifications/utils_test.go b/coderd/notifications/utils_test.go
index 9799d52e7bc17..95155ea39c347 100644
--- a/coderd/notifications/utils_test.go
+++ b/coderd/notifications/utils_test.go
@@ -4,6 +4,7 @@ import (
"context"
"sync/atomic"
"testing"
+ "text/template"
"time"
"github.com/google/uuid"
@@ -39,6 +40,8 @@ func defaultHelpers() map[string]any {
return map[string]any{
"base_url": func() string { return "http://test.com" },
"current_year": func() string { return "2024" },
+ "logo_url": func() string { return "https://coder.com/coder-logo-horizontal.png" },
+ "app_name": func() string { return "Coder" },
}
}
@@ -67,9 +70,9 @@ func newDispatchInterceptor(h notifications.Handler) *dispatchInterceptor {
return &dispatchInterceptor{handler: h}
}
-func (i *dispatchInterceptor) Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) {
+func (i *dispatchInterceptor) Dispatcher(payload types.MessagePayload, title, body string, _ template.FuncMap) (dispatch.DeliveryFunc, error) {
return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) {
- deliveryFn, err := i.handler.Dispatcher(payload, title, body)
+ deliveryFn, err := i.handler.Dispatcher(payload, title, body, defaultHelpers())
if err != nil {
return false, err
}
@@ -108,7 +111,7 @@ type chanHandler struct {
calls chan dispatchCall
}
-func (c chanHandler) Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) {
+func (c chanHandler) Dispatcher(payload types.MessagePayload, title, body string, _ template.FuncMap) (dispatch.DeliveryFunc, error) {
result := make(chan dispatchResult)
call := dispatchCall{
payload: payload,