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, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy