revert: "fix: markdown rendering improvements" (#27979)

This reverts commit 07f79af65b ("fix:
markdown rendering improvements").

The revert was applied cleanly with `git revert` and restores the
notification rendering pipeline to its prior state, including:

- `coderd/notifications/dispatch/smtp.go` and `smtp/html.gotmpl`
- `coderd/notifications/notifier.go` and `render/gotmpl.go`
- Removal of `coderd/notifications/render/sanitize_test.go` and
`smtp_internal_test.go` additions
- Regenerated SMTP golden templates under
`coderd/notifications/testdata/`

`go build ./coderd/notifications/...` passes on the reverted tree.

---

_This PR was generated by Coder Agents on behalf of @jdomeracki-coder._
This commit is contained in:
Jakub Domeracki
2026-08-10 12:58:01 +02:00
committed by GitHub
parent cfeae56bed
commit 8c2f7adeb1
37 changed files with 70 additions and 962 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
return nil, xerrors.Errorf("render subject: %w", err)
}
htmlBody := markdown.HTMLFromMarkdownSafe(bodyTmpl)
htmlBody := markdown.HTMLFromMarkdown(bodyTmpl)
plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
if err != nil {
return nil, xerrors.Errorf("render plaintext body: %w", err)
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .Labels._subject | html }}</title>
<title>{{ .Labels._subject }}</title>
</head>
<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;">
@@ -11,16 +11,16 @@
<img src="{{ logo_url | html }}" alt="{{ app_name | html }} 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 | html }}
{{ .Labels._subject }}
</h1>
<div style="line-height: 1.5;">
<p>Hi {{ .UserName | html }},</p>
<p>Hi {{ .UserName }},</p>
{{ .Labels._body }}
</div>
<div style="text-align: center; margin-top: 32px;">
{{ range $action := .Actions }}
<a href="{{ $action.URL | html }}" style="display: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
{{ $action.Label | html }}
<a href="{{ $action.URL }}" style="display: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
{{ $action.Label }}
</a>
{{ end }}
</div>
@@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/require"
markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
@@ -45,354 +43,6 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered")
}
// TestSMTPHTMLTemplateMarkdownInjection is an end-to-end regression test that
// exercises the full notification email rendering pipeline with malicious input.
// It simulates the complete flow: SanitizePayload -> GoTemplate (body) ->
// HTMLFromMarkdownSafe -> GoTemplate (outer HTML template) and asserts that the
// final HTML email output contains no attacker-controlled links, headings, images,
// scripts, or other injected content.
//
// If this test fails, it likely means a change to the rendering pipeline has
// introduced a Markdown or HTML injection vulnerability in notification emails.
func TestSMTPHTMLTemplateMarkdownInjection(t *testing.T) {
t.Parallel()
helpers := map[string]any{
"base_url": func() string { return "https://coder.example.com" },
"current_year": func() string { return "2026" },
"logo_url": func() string { return "https://coder.example.com/logo.png" },
"app_name": func() string { return "Coder" },
}
tests := []struct {
name string
// payload is the unsanitized notification payload (as it would arrive
// from the database before SanitizePayload runs).
payload types.MessagePayload
// bodyTemplate is the notification body template (stored in the DB).
bodyTemplate string
// absentInHTML lists substrings that must NOT appear in the final HTML
// email output.
absentInHTML []string
// presentInHTML lists substrings that MUST appear in the final HTML.
presentInHTML []string
}{
{
name: "markdown link injection via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Eve\n[Re-authenticate](https://evil.example/login)",
Labels: map[string]string{
"created_account_user_name": "Eve\n[Re-authenticate](https://evil.example/login)",
"initiator": "admin",
},
},
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
`<a href="https://evil.example`,
`href="https://evil.example`,
},
presentInHTML: []string{
"<strong>admin</strong>",
},
},
{
name: "heading injection via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Eve\n## URGENT SECURITY ALERT",
Labels: map[string]string{
"suspended_account_user_name": "Eve\n## URGENT SECURITY ALERT",
"initiator": "admin",
},
},
bodyTemplate: `Account **{{.Labels.suspended_account_user_name}}** suspended by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
"<h2>",
"<h1>",
"<h3>",
},
presentInHTML: []string{
"## URGENT SECURITY ALERT", // rendered as literal text
},
},
{
name: "image tracking pixel injection",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "![](https://evil.example/track.gif)",
Labels: map[string]string{
"name": "![](https://evil.example/track.gif)",
},
},
bodyTemplate: `Workspace **{{.Labels.name}}** deleted.`,
absentInHTML: []string{
`src="https://evil.example`, // no attacker-controlled image source
},
presentInHTML: []string{
"![]", // rendered as literal text
},
},
{
name: "javascript URI via markdown link",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "normal",
Labels: map[string]string{
"name": "[xss](javascript:alert(document.cookie))",
},
},
bodyTemplate: `Workspace **{{.Labels.name}}** updated.`,
absentInHTML: []string{
`href="javascript:`, // no javascript link
},
},
{
name: "raw HTML injection in display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: `<script>alert(1)</script>`,
Labels: map[string]string{},
},
bodyTemplate: `Hello.`,
absentInHTML: []string{
"<script>", // raw script tag must not appear
},
},
{
name: "bare URL autolink in label value",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{
"name": "workspace https://evil.example test",
},
},
bodyTemplate: `Workspace **{{.Labels.name}}** created.`,
absentInHTML: []string{
`<a href="https://evil.example"`,
},
},
{
// Regression: CommonMark angle-bracket autolink via display name.
// Escaping ">" alone did not stop this ("<" was never escaped).
name: "angle-bracket autolink via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "<https://evil.example/login>",
Labels: map[string]string{
"created_account_user_name": "<https://evil.example/login>",
"initiator": "admin",
},
},
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
`href="https://evil.example`,
},
presentInHTML: []string{
"<strong>admin</strong>",
},
},
{
// Regression: the backslash-tweaked bypass of the angle-bracket
// autolink escape. A user backslash combined with the sanitizer's
// "\>" left a live autolink terminator until "\" was also escaped.
name: "backslash-tweaked autolink bypass via display name",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "<https://evil.example/login\\>",
Labels: map[string]string{
"created_account_user_name": "<https://evil.example/login\\>",
"initiator": "admin",
},
},
bodyTemplate: `Account created for **{{.Labels.created_account_user_name}}** by **{{.Labels.initiator}}**.`,
absentInHTML: []string{
`href="https://evil.example`,
},
presentInHTML: []string{
"<strong>admin</strong>",
},
},
{
name: "legitimate template content renders correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "John Smith",
Labels: map[string]string{
"workspace": "my-dev-env",
"initiator": "admin",
},
},
bodyTemplate: `Workspace **{{.Labels.workspace}}** was updated by **{{.Labels.initiator}}**.`,
presentInHTML: []string{
"Hi John Smith,",
"<strong>my-dev-env</strong>",
"<strong>admin</strong>",
},
},
{
name: "unicode display name preserved",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "Jos\u00e9 Garc\u00eda",
Labels: map[string]string{"name": "workspace-1"},
},
bodyTemplate: `Workspace **{{.Labels.name}}** ready.`,
presentInHTML: []string{
"Jos\u00e9 Garc\u00eda",
"<strong>workspace-1</strong>",
},
},
// Legitimate rendering regression tests below. These verify that
// production notification features continue working after the
// security hardening.
{
name: "template-authored markdown link renders correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "user",
Labels: map[string]string{
"name": "my-workspace",
"reason": "inactivity",
},
},
bodyTemplate: `Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.`,
presentInHTML: []string{
`<a href="https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise">`,
"<strong>dormant</strong>",
"<strong>my-workspace</strong>",
},
},
{
name: "markdown list renders correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{},
},
bodyTemplate: "Build failures:\n\n- **template-v1** failed 3 times\n- **template-v2** failed 1 time",
presentInHTML: []string{
"<li>",
"<strong>template-v1</strong>",
"<strong>template-v2</strong>",
},
},
{
name: "emphasis and code spans render correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{
"workspace": "my-ws",
},
},
bodyTemplate: "Volume **`/home/coder`** is over 90%% full in workspace **{{.Labels.workspace}}**.",
presentInHTML: []string{
"<code>/home/coder</code>",
"<strong>my-ws</strong>",
},
},
{
name: "action buttons render with proper URLs",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "user",
Labels: map[string]string{},
Actions: []types.TemplateAction{
{Label: "View workspace", URL: "https://coder.example.com/@user/my-workspace"},
{Label: "View template", URL: "https://coder.example.com/templates/docker"},
},
},
bodyTemplate: `Your workspace is ready.`,
presentInHTML: []string{
`href="https://coder.example.com/@user/my-workspace"`,
"View workspace",
`href="https://coder.example.com/templates/docker"`,
"View template",
},
},
{
name: "footer links and logo render correctly",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "user",
Labels: map[string]string{},
},
bodyTemplate: `Hello.`,
presentInHTML: []string{
// Footer links
`href="https://coder.example.com/settings/notifications"`,
"manage your notification settings",
"Stop receiving emails like this",
// Logo
`<img src="https://coder.example.com/logo.png"`,
// Copyright
"Coder. All rights reserved",
},
},
{
name: "conditional label rendering",
payload: types.MessagePayload{
NotificationTemplateID: "00000000-0000-0000-0000-000000000000",
UserName: "admin",
Labels: map[string]string{
"created_account_name": "newuser",
"created_account_user_name": "New User",
"initiator": "admin",
},
},
bodyTemplate: `New user account **{{.Labels.created_account_name}}** has been created.` + "\n\n" +
`This new user account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.`,
presentInHTML: []string{
"<strong>newuser</strong>",
"<strong>New User</strong>",
"<strong>admin</strong>",
"for",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Step 1: Render the body from a sanitized copy, leaving the
// payload itself raw (as notifier.prepare does).
sanitized := render.SanitizedPayload(tt.payload)
// Step 2: Render body template via GoTemplate
body, err := render.GoTemplate(tt.bodyTemplate, sanitized, nil)
require.NoError(t, err)
// Step 3: Convert markdown body to HTML (as SMTP dispatcher does)
htmlBody := markdown.HTMLFromMarkdownSafe(body)
// Step 4: Render subject via PlaintextFromMarkdown
subject := "Test notification"
// Step 5: Inject into outer HTML template using the raw payload (as
// the SMTP dispatcher does). The greeting interpolates the raw
// UserName, made safe by the template's `| html`, not by Markdown
// escaping.
tt.payload.Labels["_subject"] = subject
tt.payload.Labels["_body"] = htmlBody
finalHTML, err := render.GoTemplate(htmlTemplate, tt.payload, helpers)
require.NoError(t, err)
// Step 6: Assert on final HTML output
for _, s := range tt.absentInHTML {
require.False(t, strings.Contains(finalHTML, s),
"final HTML email must NOT contain %q", s)
}
for _, s := range tt.presentInHTML {
require.True(t, strings.Contains(finalHTML, s),
"final HTML email must contain %q", s)
}
})
}
}
func TestValidateFromAddr(t *testing.T) {
t.Parallel()
+2 -11
View File
@@ -250,20 +250,11 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification
return nil, decorateHelpersError{err}
}
// Render the title and body from a sanitized copy so that user-controlled
// values (display names from OIDC claims, label values) cannot inject
// Markdown into the contexts that are subsequently Markdown-rendered: the
// SMTP HTML email and the react-markdown inbox. The original payload is
// passed to the dispatcher unmodified so that non-Markdown consumers (the
// webhook JSON, the SMTP greeting, and the plaintext email part) receive
// verbatim values rather than escaped ones.
sanitized := render.SanitizedPayload(payload)
var title, body string
if title, err = render.GoTemplate(msg.TitleTemplate, sanitized, 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, sanitized, helpers); err != nil {
if body, err = render.GoTemplate(msg.BodyTemplate, payload, helpers); err != nil {
return nil, xerrors.Errorf("render body: %w", err)
}
-64
View File
@@ -13,70 +13,6 @@ import (
// This string is not exported as a const from the text/template.
const NoValue = "<no value>"
// markdownReplacer escapes characters that have special meaning in Markdown.
// This prevents user-controlled values (display names, labels) from being
// interpreted as Markdown syntax when interpolated into notification body
// templates that are subsequently rendered as HTML.
var markdownReplacer = strings.NewReplacer(
// Escape the backslash first (conceptually): strings.NewReplacer performs a
// single non-overlapping left-to-right pass and never re-processes its own
// output, so a user-supplied "\" becomes a literal "\\" without touching the
// backslashes we introduce below.
"\\", "\\\\",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"#", "\\#",
"!", "\\!",
"*", "\\*",
// Escape both angle brackets. ">" alone left "<...>" CommonMark autolinks
// exploitable (angle-bracket autolinks are core inline syntax, not the
// parser.Autolink extension, so disabling that extension does not stop
// them); escaping "<" prevents such an autolink from ever opening.
"<", "\\<",
">", "\\>",
"~", "\\~",
"`", "\\`",
"|", "\\|",
"_", "\\_",
"\n", " ",
"\r", "",
)
// SanitizeMarkdown escapes Markdown metacharacters in a string and
// collapses newlines so that the value is rendered as literal text
// when embedded in a Markdown document.
func SanitizeMarkdown(s string) string {
return markdownReplacer.Replace(s)
}
// SanitizedPayload returns a copy of p with Markdown metacharacters escaped in
// the user-controlled fields (UserName and Labels). Use the returned copy only
// to render the title and body templates, whose output is subsequently passed
// through a Markdown renderer (HTMLFromMarkdownSafe for the SMTP HTML email, and
// react-markdown for the in-product inbox). Escaping there prevents display
// names and label values from injecting Markdown such as links or headings.
//
// The original payload is left unmodified. Non-Markdown consumers must receive
// the verbatim values: the webhook delivers payload.Labels and payload.UserName
// as raw JSON, the SMTP greeting interpolates UserName as HTML (escaped by the
// template's `| html`, not by Markdown escaping), and the plaintext email part
// is not Markdown. Escaping those in place left literal backslashes in every one
// of them.
func SanitizedPayload(p types.MessagePayload) types.MessagePayload {
sanitized := p
sanitized.UserName = SanitizeMarkdown(p.UserName)
if p.Labels != nil {
// Copy the map so the caller's payload keeps its raw label values.
sanitized.Labels = make(map[string]string, len(p.Labels))
for k, v := range p.Labels {
sanitized.Labels[k] = SanitizeMarkdown(v)
}
}
return sanitized
}
// GoTemplate attempts to substitute the given payload into the given template using Go's templating syntax.
// TODO: memoize templates for memory efficiency?
func GoTemplate(in string, payload types.MessagePayload, extraFuncs template.FuncMap) (string, error) {
@@ -1,333 +0,0 @@
package render_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
coderrender "github.com/coder/coder/v2/coderd/render"
)
func TestSanitizeMarkdown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
contains []string // substrings the output must contain
absent []string // substrings the output must NOT contain
exact string // exact expected output, empty to skip
}{
{
name: "plain text unchanged",
input: "Hello World",
exact: "Hello World",
},
{
name: "empty string",
input: "",
exact: "",
},
{
name: "unicode names preserved",
input: "José García",
exact: "José García",
},
{
name: "CJK names preserved",
input: "田中太郎",
exact: "田中太郎",
},
{
name: "markdown link escaped",
input: "[Click me](https://evil.example)",
absent: []string{"[Click me]"},
contains: []string{"\\[", "\\]", "\\(", "\\)"},
},
{
name: "heading injection escaped",
input: "## URGENT heading",
absent: []string{"## "},
contains: []string{"\\#\\# URGENT heading"},
},
{
name: "image injection escaped",
input: "![tracker](https://evil.example/pixel.gif)",
absent: []string{"![tracker]("},
contains: []string{"\\!", "\\[tracker\\]"},
},
{
name: "bold/italic escaped",
input: "**bold** and *italic*",
contains: []string{"\\*\\*bold\\*\\*", "\\*italic\\*"},
},
{
name: "newlines collapsed to spaces",
input: "line1\nline2\nline3",
exact: "line1 line2 line3",
},
{
name: "carriage returns removed",
input: "line1\r\nline2",
exact: "line1 line2",
},
{
name: "backtick code escaped",
input: "`code block`",
contains: []string{"\\`code block\\`"},
},
{
name: "pipe table escaped",
input: "| col1 | col2 |",
contains: []string{"\\|"},
},
{
name: "blockquote escaped",
input: "> quoted text",
contains: []string{"\\>"},
},
{
name: "underscore escaped",
input: "_emphasis_",
contains: []string{"\\_emphasis\\_"},
},
{
name: "full phishing payload",
input: "Eve\n## URGENT: SSO certificate expiring\n[Re-authenticate now](https://coder-sso.attacker.example/login)",
absent: []string{"\n", "[Re-authenticate now]"},
contains: []string{"Eve ", "\\#\\# URGENT"},
},
{
name: "javascript URI in markdown link",
input: "[Click](javascript:alert(1))",
absent: []string{"[Click](javascript"},
contains: []string{"\\[Click\\]\\(javascript"},
},
{
name: "parentheses in normal name",
input: "Jane (Admin)",
exact: "Jane \\(Admin\\)",
},
{
// Regression: angle-bracket autolink must not survive. Escaping ">"
// alone was insufficient because "<" was never escaped, so the
// "<...>" autolink still opened (SEC-93 / GHSA-2w2x-w3c8-9jrw
// follow-up). The escaped output still contains the "<" byte (as
// "\<"), so correctness is asserted via the exact escaped form here;
// the "no clickable <a href>" guarantee is covered end-to-end in
// TestSMTPHTMLTemplateMarkdownInjection.
name: "angle-bracket autolink escaped",
input: "<https://evil.example/login>",
exact: "\\<https://evil.example/login\\>",
},
{
// Regression: the backslash-tweaked bypass. Without escaping "\",
// a user backslash combined with the sanitizer's own "\>" to leave
// a live autolink terminator.
name: "backslash bypass of autolink escaped",
input: "<https://evil.example/login\\>",
exact: "\\<https://evil.example/login\\\\\\>",
},
{
name: "lone backslash escaped",
input: "back\\slash",
exact: "back\\\\slash",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := render.SanitizeMarkdown(tt.input)
if tt.exact != "" {
assert.Equal(t, tt.exact, result)
}
for _, s := range tt.contains {
assert.Contains(t, result, s, "output should contain %q", s)
}
for _, s := range tt.absent {
assert.NotContains(t, result, s, "output should NOT contain %q", s)
}
})
}
}
func TestSanitizedPayload(t *testing.T) {
t.Parallel()
t.Run("sanitizes UserName and Labels in the copy", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
UserName: "[evil](https://evil.example)",
UserEmail: "user@example.com",
UserUsername: "normaluser",
Labels: map[string]string{
"initiator": "admin",
"created_account_user_name": "## Heading\n[link](https://evil.example)",
"safe_label": "no-special-chars",
},
}
sanitized := render.SanitizedPayload(payload)
// UserName should be sanitized in the copy
assert.NotContains(t, sanitized.UserName, "[evil]")
assert.Contains(t, sanitized.UserName, "\\[evil\\]")
// Labels should be sanitized in the copy
assert.NotContains(t, sanitized.Labels["created_account_user_name"], "## Heading")
assert.Contains(t, sanitized.Labels["created_account_user_name"], "\\#\\# Heading")
assert.NotContains(t, sanitized.Labels["created_account_user_name"], "\n")
// Non-special labels unchanged
assert.Equal(t, "no-special-chars", sanitized.Labels["safe_label"])
assert.Equal(t, "admin", sanitized.Labels["initiator"])
// Fields NOT in scope are NOT modified
assert.Equal(t, "user@example.com", sanitized.UserEmail)
assert.Equal(t, "normaluser", sanitized.UserUsername)
})
t.Run("leaves the original payload untouched", func(t *testing.T) {
t.Parallel()
// The original must keep its raw values so non-Markdown consumers
// (webhook JSON, SMTP greeting, plaintext) receive them verbatim.
payload := types.MessagePayload{
UserName: "Jane (Admin)",
Labels: map[string]string{
"template_version": "angry_torvalds",
},
}
sanitized := render.SanitizedPayload(payload)
// Copy is escaped.
assert.Equal(t, "Jane \\(Admin\\)", sanitized.UserName)
assert.Equal(t, "angry\\_torvalds", sanitized.Labels["template_version"])
// Original is verbatim.
assert.Equal(t, "Jane (Admin)", payload.UserName)
assert.Equal(t, "angry_torvalds", payload.Labels["template_version"])
})
t.Run("handles nil labels map", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
UserName: "test",
Labels: nil,
}
var sanitized types.MessagePayload
require.NotPanics(t, func() {
sanitized = render.SanitizedPayload(payload)
})
assert.Nil(t, sanitized.Labels)
})
t.Run("handles empty payload", func(t *testing.T) {
t.Parallel()
var sanitized types.MessagePayload
require.NotPanics(t, func() {
sanitized = render.SanitizedPayload(types.MessagePayload{})
})
assert.Equal(t, "", sanitized.UserName)
})
}
func TestSanitizeMarkdownInTemplateRendering(t *testing.T) {
t.Parallel()
// End-to-end: sanitize -> GoTemplate -> verify no injection in output
t.Run("malicious display name in body template", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
UserName: "Eve\n## URGENT\n[Click](https://evil.example)",
Labels: map[string]string{
"created_account_name": "eviluser",
"created_account_user_name": "Eve\n## URGENT\n[Click](https://evil.example)",
"initiator": "admin",
},
}
sanitized := render.SanitizedPayload(payload)
bodyTmpl := `New user account **{{.Labels.created_account_name}}** has been created.
This new user account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.`
body, err := render.GoTemplate(bodyTmpl, sanitized, nil)
require.NoError(t, err)
// The rendered markdown should not contain unescaped link/heading syntax
assert.NotContains(t, body, "[Click](")
assert.NotContains(t, body, "## URGENT")
// Should still contain escaped versions
assert.Contains(t, body, "\\[Click\\]")
})
t.Run("bold formatting in template still works", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
Labels: map[string]string{
"name": "my-workspace",
},
}
sanitized := render.SanitizedPayload(payload)
body, err := render.GoTemplate(
`Workspace **{{.Labels.name}}** has been deleted.`,
sanitized, nil)
require.NoError(t, err)
// Template-level bold markers should survive (they're in the template, not labels)
assert.Contains(t, body, "**my-workspace**")
})
t.Run("label with asterisks does not break bold", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
Labels: map[string]string{
"name": "workspace*test",
},
}
sanitized := render.SanitizedPayload(payload)
body, err := render.GoTemplate(
`Workspace **{{.Labels.name}}** updated.`,
sanitized, nil)
require.NoError(t, err)
// The escaped asterisk in the value should not interfere with template bold
assert.Contains(t, body, `**workspace\*test**`)
})
}
func TestHTMLFromMarkdownSafe(t *testing.T) {
t.Parallel()
// Import the render package for HTMLFromMarkdownSafe
// We test it indirectly through the notification render package
t.Run("sanitized payload produces no links in final HTML", func(t *testing.T) {
t.Parallel()
payload := types.MessagePayload{
Labels: map[string]string{
"name": "[evil](https://evil.example)",
},
}
sanitized := render.SanitizedPayload(payload)
body, err := render.GoTemplate(
`Account **{{.Labels.name}}** created.`,
sanitized, nil)
require.NoError(t, err)
// Render the sanitized body to HTML exactly as the SMTP dispatcher
// does, and assert the attacker link never becomes an <a> tag. The URL
// still appears as inert literal text, which is fine; what matters is
// that it is not a clickable link.
html := coderrender.HTMLFromMarkdownSafe(body)
assert.NotContains(t, html, "<a", "sanitized body must not produce a link")
assert.Contains(t, html, "<strong>", "legitimate template formatting should still render")
})
}
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task &#39;my-workspace&#39; completed</title>
<title>Task 'my-workspace' completed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Task &#39;my-workspace&#39; completed
Task 'my-workspace' completed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task &#39;my-workspace&#39; failed</title>
<title>Task 'my-workspace' failed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Task &#39;my-workspace&#39; failed
Task 'my-workspace' failed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task &#39;my-workspace&#39; is idle</title>
<title>Task 'my-workspace' is idle</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Task &#39;my-workspace&#39; is idle
Task 'my-workspace' is idle
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task &#39;my-task&#39; is paused</title>
<title>Task 'my-task' is paused</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Task &#39;my-task&#39; is paused
Task 'my-task' is paused
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task &#39;my-task&#39; has resumed</title>
<title>Task 'my-task' has resumed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Task &#39;my-task&#39; has resumed
Task 'my-task' has resumed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Task &#39;my-workspace&#39; is working</title>
<title>Task 'my-workspace' is working</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Task &#39;my-workspace&#39; is working
Task 'my-workspace' is working
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Template &#34;Bobby&#39;s Template&#34; deleted</title>
<title>Template "Bobby's Template" deleted</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -42,7 +42,7 @@ er 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;">
Template &#34;Bobby&#39;s Template&#34; deleted
Template "Bobby's Template" deleted
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -35,7 +35,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Template &#39;alpha&#39; has been deprecated</title>
<title>Template 'alpha' has been deprecated</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -50,7 +50,7 @@ er 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;">
Template &#39;alpha&#39; has been deprecated
Template 'alpha' has been deprecated
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>User account &#34;bobby&#34; activated</title>
<title>User account "bobby" activated</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
User account &#34;bobby&#34; activated
User account "bobby" activated
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>User account &#34;bobby&#34; created</title>
<title>User account "bobby" created</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
User account &#34;bobby&#34; created
User account "bobby" created
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>User account &#34;bobby&#34; deleted</title>
<title>User account "bobby" deleted</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
User account &#34;bobby&#34; deleted
User account "bobby" deleted
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>User account &#34;bobby&#34; suspended</title>
<title>User account "bobby" suspended</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -45,7 +45,7 @@ er 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;">
User account &#34;bobby&#34; suspended
User account "bobby" suspended
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -56,10 +56,10 @@ argin: 8px 0 32px; line-height: 1.5;">
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/reset-password/change?otp=3Dfad9020b-656=
2-4cdb-87f1-0486f1bea415&amp;email=3Dbobby%2Fdrop-table%2Buser%40coder.com"=
style=3D"display: inline-block; padding: 13px 24px; background-color: #020=
617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4=
px;">
2-4cdb-87f1-0486f1bea415&email=3Dbobby%2Fdrop-table%2Buser%40coder.com" sty=
le=3D"display: inline-block; padding: 13px 24px; background-color: #020617;=
color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;"=
>
Reset password
</a>
=20
@@ -30,8 +30,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; updated automatically</title=
>
<title>Workspace "bobby-workspace" updated automatically</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -46,7 +45,7 @@ er 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 &#34;bobby-workspace&#34; updated automatically
Workspace "bobby-workspace" updated automatically
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; autobuild failed</title>
<title>Workspace "bobby-workspace" autobuild failed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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 &#34;bobby-workspace&#34; autobuild failed
Workspace "bobby-workspace" autobuild failed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Your workspace &#34;bobby-workspace&#34; will stop soon</title>
<title>Your workspace "bobby-workspace" will stop soon</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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;">
Your workspace &#34;bobby-workspace&#34; will stop soon
Your workspace "bobby-workspace" will stop soon
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -28,7 +28,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#39;bobby-workspace&#39; has been created</title>
<title>Workspace 'bobby-workspace' has been created</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -43,7 +43,7 @@ er 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 &#39;bobby-workspace&#39; has been created
Workspace 'bobby-workspace' has been created
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; deleted</title>
<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=
@@ -46,7 +46,7 @@ er 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 &#34;bobby-workspace&#34; deleted
Workspace "bobby-workspace" deleted
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; deleted</title>
<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=
@@ -46,7 +46,7 @@ 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 &#34;bobby-workspace&#34; deleted
Workspace "bobby-workspace" deleted
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -34,7 +34,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; marked as dormant</title>
<title>Workspace "bobby-workspace" marked as dormant</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -49,7 +49,7 @@ er 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 &#34;bobby-workspace&#34; marked as dormant
Workspace "bobby-workspace" marked as dormant
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; marked as dormant</title>
<title>Workspace "bobby-workspace" marked as dormant</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -46,7 +46,7 @@ er 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 &#34;bobby-workspace&#34; marked as dormant
Workspace "bobby-workspace" marked as dormant
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; manual build failed</title>
<title>Workspace "bobby-workspace" manual build failed</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +44,7 @@ er 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 &#34;bobby-workspace&#34; manual build failed
Workspace "bobby-workspace" manual build failed
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -31,8 +31,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#39;bobby-workspace&#39; has been manually updated</t=
itle>
<title>Workspace 'bobby-workspace' has been manually updated</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -47,7 +46,7 @@ er 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 &#39;bobby-workspace&#39; has been manually updated
Workspace 'bobby-workspace' has been manually updated
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace &#34;bobby-workspace&#34; marked for deletion</title>
<title>Workspace "bobby-workspace" marked for deletion</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -46,7 +46,7 @@ er 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 &#34;bobby-workspace&#34; marked for deletion
Workspace "bobby-workspace" marked for deletion
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -27,8 +27,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Your workspace &#34;bobby-workspace&#34; is low on volume space<=
/title>
<title>Your workspace "bobby-workspace" is low on volume space</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -43,7 +42,7 @@ er 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;">
Your workspace &#34;bobby-workspace&#34; is low on volume space
Your workspace "bobby-workspace" is low on volume space
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -31,8 +31,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Your workspace &#34;bobby-workspace&#34; is low on volume space<=
/title>
<title>Your workspace "bobby-workspace" is low on volume space</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -47,7 +46,7 @@ er 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;">
Your workspace &#34;bobby-workspace&#34; is low on volume space
Your workspace "bobby-workspace" is low on volume space
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -28,8 +28,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Your workspace &#34;bobby-workspace&#34; is low on memory</title=
>
<title>Your workspace "bobby-workspace" is low on memory</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -44,7 +43,7 @@ er 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;">
Your workspace &#34;bobby-workspace&#34; is low on memory
Your workspace "bobby-workspace" is low on memory
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Your account &#34;bobby&#34; has been activated</title>
<title>Your account "bobby" has been activated</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -42,7 +42,7 @@ er 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;">
Your account &#34;bobby&#34; has been activated
Your account "bobby" has been activated
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
@@ -25,7 +25,7 @@ Content-Type: text/html; charset=UTF-8
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Your account &#34;bobby&#34; has been suspended</title>
<title>Your account "bobby" has been suspended</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -40,7 +40,7 @@ er 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;">
Your account &#34;bobby&#34; has been suspended
Your account "bobby" has been suspended
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>