Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a42f108
feat(notifications): add company logo url when available for email no…
defelmnq Oct 2, 2024
1fa1271
feat(notifications): adapt tests with new labels
defelmnq Oct 3, 2024
2def52e
fix: revert changes on generated code version
defelmnq Oct 3, 2024
1b1a4c4
feat(notification): move logo_url and app_name logic to helpers funct…
defelmnq Oct 3, 2024
779260e
chore: remove unused GetLogoURL method from notifications store inter…
defelmnq Oct 3, 2024
1e6899f
fix: add better context and timeout for db related queries
defelmnq Oct 4, 2024
b552267
chore: lint
defelmnq Oct 4, 2024
73e07e9
feat(notifications): improve logo_url and app_name fetching moving it…
defelmnq Oct 16, 2024
70e23ae
feat(notifications): improve logo_url and app_name fetching moving it…
defelmnq Oct 16, 2024
2f620c9
feat(notifications): improve logo_url and app_name fetching moving it…
defelmnq Oct 16, 2024
fdcdf7b
feat(notifications): improve logo_url and app_name fetching moving it…
defelmnq Oct 16, 2024
9c6c105
feat(coderd): regenerate golden files
Oct 16, 2024
0c131a5
feat(notifications): working on tests for logo and app_name
defelmnq Oct 17, 2024
34d6611
feat(notifications): change helpers logic to be defined in the Dispat…
Oct 17, 2024
0a9a66a
feat(notifications): add golden file for custom appearance
Oct 17, 2024
bf558cb
feat(notifications): add golden file for custom appearance
Oct 17, 2024
a420f37
feat(notifications): work on tests
defelmnq Oct 18, 2024
51d8d33
feat(notifications): add golden file for custom appearance
defelmnq Oct 18, 2024
d492d09
feat(notifications): add golden file for custom appearance
defelmnq Oct 18, 2024
0c9c485
feat(notifications): add comment ent in tests for enterprise feature
defelmnq Oct 18, 2024
a6d4a0c
feat(coderd): remove unused call
defelmnq Oct 18, 2024
4c5cb3d
fix(notifications): improve tests and some nit fixes
defelmnq Oct 18, 2024
e419431
chore(retry): improve retry policy on fetcher
defelmnq Oct 18, 2024
a7fec66
Merge remote-tracking branch 'origin/main' into feat/notif-custom-logo
defelmnq Oct 21, 2024
8b766f6
improve errors handling
defelmnq Oct 22, 2024
157e086
improve errors handling
defelmnq Oct 22, 2024
790ff33
improve errors handling
defelmnq Oct 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions coderd/notifications/dispatch/smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion coderd/notifications/dispatch/smtp/html.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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;" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: my immediate thought was to use a Data URL but apparently support for this is extremely limited.

</div>
<h1 style="text-align: center; font-size: 24px; font-weight: 400; margin: 8px 0 32px; line-height: 1.5;">
{{ .Labels._subject }}
Expand Down
8 changes: 2 additions & 6 deletions coderd/notifications/dispatch/smtp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions coderd/notifications/dispatch/utils_test.go
Original file line number Diff line number Diff line change
@@ -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" },
}
}
3 changes: 2 additions & 1 deletion coderd/notifications/dispatch/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"io"
"net/http"
"text/template"

"github.com/google/uuid"
"golang.org/x/xerrors"
Expand Down Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion coderd/notifications/dispatch/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions coderd/notifications/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package notifications

import (
"context"
"database/sql"
"errors"
"text/template"

"golang.org/x/xerrors"
)

func (n *notifier) fetchHelpers(ctx context.Context) (map[string]any, error) {
appName, err := n.fetchAppName(ctx)
if err != nil {
return nil, xerrors.Errorf("fetch app name: %w", err)
}
logoURL, err := n.fetchLogoURL(ctx)
if err != nil {
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
}
6 changes: 3 additions & 3 deletions coderd/notifications/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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")),
}
}
Expand Down
5 changes: 3 additions & 2 deletions coderd/notifications/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"sync/atomic"
"testing"
"text/template"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -205,8 +206,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")
Expand Down
35 changes: 18 additions & 17 deletions coderd/notifications/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strconv"
"sync"
"testing"
"text/template"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -43,7 +44,7 @@ func TestMetrics(t *testing.T) {

reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
template := notifications.TemplateWorkspaceDeleted
tmpl := notifications.TemplateWorkspaceDeleted

const (
method = database.NotificationMethodSmtp
Expand Down Expand Up @@ -75,7 +76,7 @@ func TestMetrics(t *testing.T) {
user := createSampleUser(t, api.Database)

// 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{
Expand All @@ -89,7 +90,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
}
Expand Down Expand Up @@ -164,9 +165,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)
Expand Down Expand Up @@ -216,7 +217,7 @@ func TestPendingUpdatesMetric(t *testing.T) {

reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
template := notifications.TemplateWorkspaceDeleted
tmpl := notifications.TemplateWorkspaceDeleted

const method = database.NotificationMethodSmtp

Expand Down Expand Up @@ -247,9 +248,9 @@ func TestPendingUpdatesMetric(t *testing.T) {
user := createSampleUser(t, api.Database)

// 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)
Expand Down Expand Up @@ -300,7 +301,7 @@ func TestInflightDispatchesMetric(t *testing.T) {

reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
template := notifications.TemplateWorkspaceDeleted
tmpl := notifications.TemplateWorkspaceDeleted

const method = database.NotificationMethodSmtp

Expand Down Expand Up @@ -333,7 +334,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)
}

Expand All @@ -342,7 +343,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++ {
Expand Down Expand Up @@ -379,7 +380,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {
var (
reg = prometheus.NewRegistry()
metrics = notifications.NewMetrics(reg)
template = notifications.TemplateWorkspaceDeleted
tmpl = notifications.TemplateWorkspaceDeleted
anotherTemplate = notifications.TemplateWorkspaceDormant
)

Expand All @@ -390,7 +391,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {

// GIVEN: a template whose notification method differs from the default.
out, err := api.Database.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
ID: template,
ID: tmpl,
Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
})
require.NoError(t, err)
Expand All @@ -416,7 +417,7 @@ func TestCustomMethodMetricCollection(t *testing.T) {

user := createSampleUser(t, api.Database)

_, 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)
Expand All @@ -437,7 +438,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)
}

Expand Down Expand Up @@ -515,8 +516,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
}
Expand Down
Loading
Loading