Skip to content

Commit 96a4319

Browse files
AchoArnoldCopilot
andauthored
feat(api): format webhook email payload (#955)
* docs: design webhook payload formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11 * docs: plan webhook payload formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11 * docs: record API baseline constraints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11 * feat(api): format webhook email payload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): render rich email dictionary values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11 * feat(api): highlight webhook email payload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11 * fix(api): restore webhook payload text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): harden webhook payload formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8010ce3 commit 96a4319

8 files changed

Lines changed: 1123 additions & 2 deletions
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package emails
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"html"
7+
"html/template"
8+
"strings"
9+
)
10+
11+
const (
12+
eventPayloadCodeBlockStyle = "margin:0;padding:12px;border:1px solid #D0D7DE;border-radius:6px;background:#F6F8FA;color:#24292F;font-family:Consolas,Monaco,'Courier New',monospace;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;"
13+
jsonKeyStyle = "color:#0550AE;font-weight:600;"
14+
jsonStringStyle = "color:#0A3069;"
15+
jsonNumberStyle = "color:#953800;"
16+
jsonLiteralStyle = "color:#8250DF;"
17+
)
18+
19+
func formatEventPayload(payload string) (string, template.HTML) {
20+
formattedPayload, isJSON := indentEventPayloadJSON(payload)
21+
content := html.EscapeString(formattedPayload)
22+
if isJSON {
23+
content = highlightEventPayloadJSON(formattedPayload)
24+
}
25+
26+
// Every payload token is escaped before this trusted wrapper is constructed.
27+
richPayload := template.HTML(`<pre style="` + eventPayloadCodeBlockStyle + `">` + content + `</pre>`)
28+
return formattedPayload, richPayload
29+
}
30+
31+
func indentEventPayloadJSON(payload string) (string, bool) {
32+
var formatted bytes.Buffer
33+
if err := json.Indent(&formatted, []byte(payload), "", " "); err != nil {
34+
return payload, false
35+
}
36+
37+
return formatted.String(), true
38+
}
39+
40+
func highlightEventPayloadJSON(payload string) string {
41+
var highlighted strings.Builder
42+
highlighted.Grow(len(payload))
43+
44+
for index := 0; index < len(payload); {
45+
switch {
46+
case payload[index] == '"':
47+
end := eventPayloadJSONStringEnd(payload, index)
48+
style := jsonStringStyle
49+
if eventPayloadNextNonSpace(payload, end) == ':' {
50+
style = jsonKeyStyle
51+
}
52+
writeEventPayloadToken(&highlighted, style, payload[index:end])
53+
index = end
54+
case payload[index] == '-' || isEventPayloadDigit(payload[index]):
55+
end := index + 1
56+
// json.Indent already validated this as JSON, so this continuation set only sees JSON number bytes.
57+
for end < len(payload) && isEventPayloadNumberCharacter(payload[end]) {
58+
end++
59+
}
60+
writeEventPayloadToken(&highlighted, jsonNumberStyle, payload[index:end])
61+
index = end
62+
case strings.HasPrefix(payload[index:], "true"):
63+
writeEventPayloadToken(&highlighted, jsonLiteralStyle, "true")
64+
index += len("true")
65+
case strings.HasPrefix(payload[index:], "false"):
66+
writeEventPayloadToken(&highlighted, jsonLiteralStyle, "false")
67+
index += len("false")
68+
case strings.HasPrefix(payload[index:], "null"):
69+
writeEventPayloadToken(&highlighted, jsonLiteralStyle, "null")
70+
index += len("null")
71+
default:
72+
highlighted.WriteString(html.EscapeString(payload[index : index+1]))
73+
index++
74+
}
75+
}
76+
77+
return highlighted.String()
78+
}
79+
80+
func eventPayloadJSONStringEnd(payload string, start int) int {
81+
escaped := false
82+
for index := start + 1; index < len(payload); index++ {
83+
switch {
84+
case escaped:
85+
escaped = false
86+
case payload[index] == '\\':
87+
escaped = true
88+
case payload[index] == '"':
89+
return index + 1
90+
}
91+
}
92+
93+
return len(payload)
94+
}
95+
96+
func eventPayloadNextNonSpace(payload string, start int) byte {
97+
for index := start; index < len(payload); index++ {
98+
switch payload[index] {
99+
case ' ', '\n', '\r', '\t':
100+
continue
101+
default:
102+
return payload[index]
103+
}
104+
}
105+
106+
return 0
107+
}
108+
109+
func isEventPayloadDigit(value byte) bool {
110+
return value >= '0' && value <= '9'
111+
}
112+
113+
func isEventPayloadNumberCharacter(value byte) bool {
114+
return isEventPayloadDigit(value) ||
115+
value == '-' ||
116+
value == '+' ||
117+
value == '.' ||
118+
value == 'e' ||
119+
value == 'E'
120+
}
121+
122+
func writeEventPayloadToken(builder *strings.Builder, style string, token string) {
123+
builder.WriteString(`<span style="`)
124+
builder.WriteString(style)
125+
builder.WriteString(`">`)
126+
builder.WriteString(html.EscapeString(token))
127+
builder.WriteString(`</span>`)
128+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package emails
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestFormatEventPayloadIndentsAndHighlightsJSON(t *testing.T) {
11+
payload := `{"message":"hello","count":2,"ratio":1.5,"enabled":true,"disabled":false,"missing":null,"nested":{"value":"ok"}}`
12+
13+
plain, rich := formatEventPayload(payload)
14+
html := string(rich)
15+
16+
assert.Equal(t, `{
17+
"message": "hello",
18+
"count": 2,
19+
"ratio": 1.5,
20+
"enabled": true,
21+
"disabled": false,
22+
"missing": null,
23+
"nested": {
24+
"value": "ok"
25+
}
26+
}`, plain)
27+
assert.Contains(t, html, `<span style="color:#0550AE;font-weight:600;">&#34;message&#34;</span>`)
28+
assert.Contains(t, html, `<span style="color:#0A3069;">&#34;hello&#34;</span>`)
29+
assert.Contains(t, html, `<span style="color:#953800;">2</span>`)
30+
assert.Contains(t, html, `<span style="color:#953800;">1.5</span>`)
31+
assert.Contains(t, html, `<span style="color:#8250DF;">true</span>`)
32+
assert.Contains(t, html, `<span style="color:#8250DF;">false</span>`)
33+
assert.Contains(t, html, `<span style="color:#8250DF;">null</span>`)
34+
assert.Contains(t, html, `white-space:pre-wrap`)
35+
}
36+
37+
func TestFormatEventPayloadEscapesPayloadHTML(t *testing.T) {
38+
plain, rich := formatEventPayload(`{"message":"<script>alert(\"x\")</script>&"}`)
39+
html := string(rich)
40+
41+
assert.Contains(t, plain, `<script>alert`)
42+
assert.NotContains(t, html, `<script>`)
43+
assert.Contains(t, html, `&lt;script&gt;`)
44+
assert.Contains(t, html, `&amp;`)
45+
}
46+
47+
func TestFormatEventPayloadPreservesInvalidJSONWithoutHighlighting(t *testing.T) {
48+
payload := "line one\n <strong>line two</strong>"
49+
50+
plain, rich := formatEventPayload(payload)
51+
html := string(rich)
52+
53+
assert.Equal(t, payload, plain)
54+
assert.Contains(t, html, "line one\n &lt;strong&gt;line two&lt;/strong&gt;")
55+
assert.NotContains(t, html, `<strong>`)
56+
assert.NotContains(t, html, `<span style="color:`)
57+
assert.Equal(t, 1, strings.Count(html, `<pre style=`))
58+
}
59+
60+
func TestFormatEventPayloadHandlesTopLevelPayloadShapes(t *testing.T) {
61+
tests := []struct {
62+
name string
63+
payload string
64+
wantPlain string
65+
wantHTMLContains []string
66+
wantHTMLNotContains []string
67+
}{
68+
{
69+
name: "empty payload falls back to unhighlighted block",
70+
payload: "",
71+
wantPlain: "",
72+
wantHTMLContains: []string{`<pre style="`, `</pre>`},
73+
wantHTMLNotContains: []string{`<span style="color:`},
74+
},
75+
{
76+
name: "top-level number stays valid JSON",
77+
payload: "42",
78+
wantPlain: "42",
79+
wantHTMLContains: []string{`<span style="color:#953800;">42</span>`},
80+
wantHTMLNotContains: []string{`color:#0550AE`},
81+
},
82+
{
83+
name: "json array stays readable and escaped",
84+
payload: `[{"message":"<b>safe</b>"},true,null,3]`,
85+
wantPlain: "[\n {\n \"message\": \"<b>safe</b>\"\n },\n true,\n null,\n 3\n]",
86+
wantHTMLContains: []string{
87+
"[\n {",
88+
`&#34;message&#34;`,
89+
`&lt;b&gt;safe&lt;/b&gt;`,
90+
`<span style="color:#953800;">3</span>`,
91+
},
92+
wantHTMLNotContains: []string{`<b>safe</b>`},
93+
},
94+
}
95+
96+
for _, tt := range tests {
97+
t.Run(tt.name, func(t *testing.T) {
98+
plain, rich := formatEventPayload(tt.payload)
99+
html := string(rich)
100+
101+
assert.Equal(t, tt.wantPlain, plain)
102+
assert.Equal(t, 1, strings.Count(html, `<pre style=`))
103+
104+
for _, want := range tt.wantHTMLContains {
105+
assert.Contains(t, html, want)
106+
}
107+
108+
for _, unwanted := range tt.wantHTMLNotContains {
109+
assert.NotContains(t, html, unwanted)
110+
}
111+
})
112+
}
113+
}

api/pkg/emails/hermes_notification_email_factory.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package emails
22

33
import (
44
"fmt"
5+
"strings"
56
"time"
67

78
"github.com/NdoleStudio/httpsms/pkg/events"
@@ -17,6 +18,8 @@ type hermesNotificationEmailFactory struct {
1718
generator hermes.Hermes
1819
}
1920

21+
const webhookSendFailedEventPayloadPlaceholder = "__HTTPSMS_WEBHOOK_SEND_FAILED_EVENT_PAYLOAD__"
22+
2023
// NewHermesNotificationEmailFactory creates a new instance of the UserEmailFactory
2124
func NewHermesNotificationEmailFactory(config *HermesGeneratorConfig) NotificationEmailFactory {
2225
return &hermesNotificationEmailFactory{
@@ -76,6 +79,8 @@ func (factory *hermesNotificationEmailFactory) DiscordSendFailed(user *entities.
7679
}
7780

7881
func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.User, payload *events.WebhookSendFailedPayload) (*Email, error) {
82+
formattedPayload, formattedPayloadHTML := formatEventPayload(payload.EventPayload)
83+
7984
email := hermes.Email{
8085
Body: hermes.Body{
8186
Title: "Hello",
@@ -89,7 +94,11 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
8994
{Key: "Phone Number", Value: factory.formatPhoneNumber(payload.Owner)},
9095
{Key: "HTTP Response Code", Value: factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)},
9196
{Key: "Error Message / HTTP Response", Value: payload.ErrorMessage},
92-
{Key: "Event Payload", Value: payload.EventPayload},
97+
{
98+
Key: "Event Payload",
99+
Value: webhookSendFailedEventPayloadPlaceholder,
100+
UnsafeValue: formattedPayloadHTML,
101+
},
93102
},
94103
Actions: []hermes.Action{
95104
{
@@ -118,6 +127,8 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
118127
if err != nil {
119128
return nil, stacktrace.Propagate(err, "cannot generate text email")
120129
}
130+
// Hermes/html2text collapses dictionary whitespace, so restore the payload after plain-text generation.
131+
text = replaceWebhookSendFailedEventPayloadPlaceholder(text, formattedPayload)
121132

122133
return &Email{
123134
ToEmail: user.Email,
@@ -127,6 +138,15 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
127138
}, nil
128139
}
129140

141+
func replaceWebhookSendFailedEventPayloadPlaceholder(text string, formattedPayload string) string {
142+
before, after, found := strings.Cut(text, webhookSendFailedEventPayloadPlaceholder)
143+
if !found {
144+
return text
145+
}
146+
147+
return before + formattedPayload + after
148+
}
149+
130150
func (factory *hermesNotificationEmailFactory) MessageExpired(user *entities.User, payload *events.MessageSendExpiredPayload) (*Email, error) {
131151
email := hermes.Email{
132152
Body: hermes.Body{

0 commit comments

Comments
 (0)