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 @@
- Coder Logo + {{ app_name }} Logo

{{ .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 + + +
+
+ 3D"Custom +
+

+ Workspace "bobby-workspace" deleted +

+
+

Hi Bobby,

+ +

Your workspace bobby-workspace was deleted.

+ +

The specified reason was “autodeleted due to dormancy (aut= +obuild)”.

+
+
+ =20 + + View workspaces + + =20 + + View templates + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--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,