feat(coderd): add company logo when available for email notifications (#14935)

This PR aims to close #14253 

We keep the default behavior using the Coder logo if there's no logo
set.
Otherwise we want to use the logo based on the URL set in appearance.

---------

Co-authored-by: defelmnq <yvincent@coder.com>
This commit is contained in:
Vincent Vielle
2024-10-22 14:06:19 +02:00
committed by GitHub
parent c42f487668
commit 297089e944
16 changed files with 289 additions and 52 deletions
+5 -7
View File
@@ -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)
}
@@ -8,7 +8,7 @@
<body style="margin: 0; padding: 0; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617; background: #f8fafc;">
<div style="max-width: 600px; margin: 20px auto; padding: 60px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-align: left; font-size: 14px; line-height: 1.5;">
<div style="text-align: center;">
<img src="https://coder.com/coder-logo-horizontal.png" alt="Coder Logo" style="height: 40px;" />
<img src="{{ logo_url }}" alt="{{ app_name }} Logo" style="height: 40px;" />
</div>
<h1 style="text-align: center; font-size: 24px; font-weight: 400; margin: 8px 0 32px; line-height: 1.5;">
{{ .Labels._subject }}
+2 -6
View File
@@ -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()
@@ -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" },
}
}
+2 -1
View File
@@ -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")
}
@@ -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)
+57
View File
@@ -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
}
+3 -3
View File
@@ -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")),
}
}
+3 -2
View File
@@ -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")
+18 -17
View File
@@ -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
}
+38 -5
View File
@@ -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()
+16 -5
View File
@@ -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.
+4 -1
View File
@@ -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.
@@ -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
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace "bobby-workspace" deleted</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://custom.application/logo.png" alt=3D"Custom Appl=
ication Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Workspace "bobby-workspace" deleted
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>Your workspace <strong>bobby-workspace</strong> was deleted.</p>
<p>The specified reason was &ldquo;<strong>autodeleted due to dormancy (aut=
obuild)</strong>&rdquo;.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/workspaces" style=3D"display: inline-blo=
ck; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-dec=
oration: none; border-radius: 8px; margin: 0 4px;">
View workspaces
</a>
=20
<a href=3D"http://test.com/templates" style=3D"display: inline-bloc=
k; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-deco=
ration: none; border-radius: 8px; margin: 0 4px;">
View templates
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3Df51=
7da0b-cdc9-410f-ab89-a86107c420ed" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -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)**\"."
}
+6 -3
View File
@@ -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,