mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+90
@@ -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 “<strong>autodeleted due to dormancy (aut=
|
||||
obuild)</strong>”.</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>© 2024 Coder. All rights reserved - <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--
|
||||
+33
@@ -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)**\"."
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user