Skip to content

Commit 9fa5b76

Browse files
AchoArnoldCopilot
andcommitted
fix(api): harden webhook email fallback
Regenerate plain text with the real payload if Hermes no longer preserves the formatting sentinel. Document the escaped template.HTML conversion for G203. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11
1 parent 96a4319 commit 9fa5b76

3 files changed

Lines changed: 40 additions & 4 deletions

File tree

api/pkg/emails/event_payload_formatter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func formatEventPayload(payload string) (string, template.HTML) {
2323
content = highlightEventPayloadJSON(formattedPayload)
2424
}
2525

26-
// Every payload token is escaped before this trusted wrapper is constructed.
26+
// #nosec G203 -- every dynamic payload token is escaped before the static wrapper is added.
2727
richPayload := template.HTML(`<pre style="` + eventPayloadCodeBlockStyle + `">` + content + `</pre>`)
2828
return formattedPayload, richPayload
2929
}

api/pkg/emails/hermes_notification_email_factory.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,14 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
123123
return nil, stacktrace.Propagate(err, "cannot generate html email")
124124
}
125125

126-
text, err := factory.generator.GeneratePlainText(email)
126+
eventPayloadEntryIndex := len(email.Body.Dictionary) - 1
127+
text, err := generateWebhookSendFailedPlainText(func(value string) (string, error) {
128+
email.Body.Dictionary[eventPayloadEntryIndex].Value = value
129+
return factory.generator.GeneratePlainText(email)
130+
}, formattedPayload)
127131
if err != nil {
128132
return nil, stacktrace.Propagate(err, "cannot generate text email")
129133
}
130-
// Hermes/html2text collapses dictionary whitespace, so restore the payload after plain-text generation.
131-
text = replaceWebhookSendFailedEventPayloadPlaceholder(text, formattedPayload)
132134

133135
return &Email{
134136
ToEmail: user.Email,
@@ -138,6 +140,21 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
138140
}, nil
139141
}
140142

143+
func generateWebhookSendFailedPlainText(generate func(string) (string, error), formattedPayload string) (string, error) {
144+
text, err := generate(webhookSendFailedEventPayloadPlaceholder)
145+
if err != nil {
146+
return "", err
147+
}
148+
149+
if strings.Contains(text, webhookSendFailedEventPayloadPlaceholder) {
150+
// Hermes/html2text collapses dictionary whitespace, so restore the payload after plain-text generation.
151+
return replaceWebhookSendFailedEventPayloadPlaceholder(text, formattedPayload), nil
152+
}
153+
154+
// Preserve the real payload if a Hermes change stops carrying the placeholder through unchanged.
155+
return generate(formattedPayload)
156+
}
157+
141158
func replaceWebhookSendFailedEventPayloadPlaceholder(text string, formattedPayload string) string {
142159
before, after, found := strings.Cut(text, webhookSendFailedEventPayloadPlaceholder)
143160
if !found {

api/pkg/emails/hermes_notification_email_factory_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func TestWebhookSendFailedFormatsOnlyEventPayload(t *testing.T) {
5050
assert.Contains(t, email.Text, `"retry": false`)
5151
assert.NotContains(t, email.Text, "<pre")
5252
assert.NotContains(t, email.Text, webhookSendFailedEventPayloadPlaceholder)
53+
assert.NotContains(t, email.HTML, webhookSendFailedEventPayloadPlaceholder)
5354
}
5455

5556
func TestWebhookSendFailedPreservesNonJSONEventPayload(t *testing.T) {
@@ -76,6 +77,24 @@ func TestWebhookSendFailedPreservesNonJSONEventPayload(t *testing.T) {
7677
assert.Contains(t, email.Text, "line one\n line two")
7778
}
7879

80+
func TestGenerateWebhookSendFailedPlainTextFallsBackToFormattedPayload(t *testing.T) {
81+
formattedPayload := "{\n \"message\": \"hello\"\n}"
82+
values := make([]string, 0, 2)
83+
generate := func(value string) (string, error) {
84+
values = append(values, value)
85+
if len(values) == 1 {
86+
return "plain text without the placeholder", nil
87+
}
88+
return value, nil
89+
}
90+
91+
text, err := generateWebhookSendFailedPlainText(generate, formattedPayload)
92+
93+
require.NoError(t, err)
94+
assert.Equal(t, formattedPayload, text)
95+
assert.Equal(t, []string{webhookSendFailedEventPayloadPlaceholder, formattedPayload}, values)
96+
}
97+
7998
func TestReplaceWebhookSendFailedEventPayloadPlaceholder(t *testing.T) {
8099
tests := []struct {
81100
name string

0 commit comments

Comments
 (0)