Skip to content

feat(api): format webhook email payload#955

Merged
AchoArnold merged 8 commits into
mainfrom
feature/webhook-email-payload-formatting
Jul 18, 2026
Merged

feat(api): format webhook email payload#955
AchoArnold merged 8 commits into
mainfrom
feature/webhook-email-payload-formatting

Conversation

@AchoArnold

Copy link
Copy Markdown
Member

Summary

  • pretty-print webhook Event Payload JSON in failure emails
  • add safe inline JSON highlighting for HTML and preserve payload whitespace in plain text
  • keep rich Hermes dictionary rendering opt-in and limited to Event Payload

Testing

  • go test ./pkg/emails -count=1
  • go test -vet=off for all API packages except pkg/handlers

go test ./... has pre-existing Go vet failures in stacktrace calls, and the handler package includes a localhost-dependent integration test.

AchoArnold and others added 8 commits July 18, 2026 11:09
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 48f6d946-ae22-4440-b7a1-44e939419b11
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 critical · 50 minor

Alerts:
⚠ 51 issues (≤ 0 issues of at least minor severity)

Results:
51 new issues

Category Results
Security 1 critical
CodeStyle 50 minor

View in Codacy

🟢 Metrics 46 complexity · 4 duplication

Metric Results
Complexity 46
Duplication 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds pretty-printed, syntax-highlighted JSON rendering to the webhook failure notification email's Event Payload field — HTML emails get a styled <pre> code block with escaped inline colour spans; plain-text emails get indented JSON via a post-generation placeholder replacement.

  • event_payload_formatter.go: New byte-level JSON tokenizer that produces a plain indented string and a safely-escaped template.HTML code block; all user tokens pass through html.EscapeString before the template.HTML cast, and invalid JSON falls back gracefully to an unhighlighted block.
  • hermes_theme.go: One-line change to the HTML dictionary template to render Entry.UnsafeValue when present, falling back to the escaped Value; the plain-text template is unchanged.
  • hermes_notification_email_factory.go: Wires formatEventPayload into WebhookSendFailed only; sets Value to a placeholder for plain-text post-processing, with replaceWebhookSendFailedEventPayloadPlaceholder substituting the formatted payload after hermes generation.

Confidence Score: 4/5

Safe to merge; the change is self-contained to the emails package with no interface or dependency modifications.

The formatter and theme change are correct and well-tested. The two minor gaps are the silent fallback to the raw placeholder string in plain-text if hermes output ever stops containing it, and the missing NotContains(email.HTML, placeholder) assertion that would catch a future empty-UnsafeValue regression.

api/pkg/emails/hermes_notification_email_factory.go and its test — the placeholder replacement pattern has no observable failure mode, and the test suite does not assert the placeholder is absent from the HTML output.

Important Files Changed

Filename Overview
api/pkg/emails/event_payload_formatter.go New file. Byte-level JSON tokenizer producing indented plain text and an HTML-escaped syntax-highlighted pre-block; all user tokens pass through html.EscapeString before the template.HTML cast. No issues found.
api/pkg/emails/event_payload_formatter_test.go New test file covering XSS injection escaping, invalid JSON fallback, and top-level JSON scalars/arrays.
api/pkg/emails/hermes_theme.go Single-line change to the HTML dictionary template: renders UnsafeValue when present, otherwise falls back to the escaped Value. Plain-text template is unchanged.
api/pkg/emails/hermes_theme_test.go New tests verifying the opt-in UnsafeValue path renders raw HTML in HTML email and still uses Value in plain text.
api/pkg/emails/hermes_notification_email_factory.go Adds payload formatting to WebhookSendFailed using a placeholder for plain-text post-processing. Silently falls back to the raw placeholder if not found in the hermes output.
api/pkg/emails/hermes_notification_email_factory_test.go New factory tests covering JSON formatting and non-JSON fallback. Missing assertion that the HTML email never contains the placeholder.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant WF as WebhookSendFailed
    participant FEP as formatEventPayload
    participant IJ as indentEventPayloadJSON
    participant HJ as highlightEventPayloadJSON
    participant HG as hermes.GenerateHTML
    participant HT as hermes.GeneratePlainText
    participant RPP as replaceWebhookSendFailedEventPayloadPlaceholder

    WF->>FEP: payload.EventPayload (raw string)
    FEP->>IJ: payload string
    IJ-->>FEP: (formattedPayload, isJSON)
    alt "isJSON == true"
        FEP->>HJ: formattedPayload
        HJ-->>FEP: HTML with escaped spans
    else "isJSON == false"
        FEP->>FEP: html.EscapeString(formattedPayload)
    end
    FEP-->>WF: (formattedPayload, template.HTML pre-block)
    WF->>HG: hermes.Entry with placeholder Value and richHTML UnsafeValue
    HG-->>WF: HTML email with UnsafeValue rendered
    WF->>HT: hermes.Entry with placeholder Value
    HT-->>WF: plain text containing placeholder
    WF->>RPP: (plainText, formattedPayload)
    RPP-->>WF: before + formattedPayload + after
    WF-->>WF: "return Email{HTML, Text}"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant WF as WebhookSendFailed
    participant FEP as formatEventPayload
    participant IJ as indentEventPayloadJSON
    participant HJ as highlightEventPayloadJSON
    participant HG as hermes.GenerateHTML
    participant HT as hermes.GeneratePlainText
    participant RPP as replaceWebhookSendFailedEventPayloadPlaceholder

    WF->>FEP: payload.EventPayload (raw string)
    FEP->>IJ: payload string
    IJ-->>FEP: (formattedPayload, isJSON)
    alt "isJSON == true"
        FEP->>HJ: formattedPayload
        HJ-->>FEP: HTML with escaped spans
    else "isJSON == false"
        FEP->>FEP: html.EscapeString(formattedPayload)
    end
    FEP-->>WF: (formattedPayload, template.HTML pre-block)
    WF->>HG: hermes.Entry with placeholder Value and richHTML UnsafeValue
    HG-->>WF: HTML email with UnsafeValue rendered
    WF->>HT: hermes.Entry with placeholder Value
    HT-->>WF: plain text containing placeholder
    WF->>RPP: (plainText, formattedPayload)
    RPP-->>WF: before + formattedPayload + after
    WF-->>WF: "return Email{HTML, Text}"
Loading

Reviews (1): Last reviewed commit: "fix(api): harden webhook payload formatt..." | Re-trigger Greptile

Comment on lines +141 to +148
func replaceWebhookSendFailedEventPayloadPlaceholder(text string, formattedPayload string) string {
before, after, found := strings.Cut(text, webhookSendFailedEventPayloadPlaceholder)
if !found {
return text
}

return before + formattedPayload + after
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Silent plain-text fallback exposes placeholder string

replaceWebhookSendFailedEventPayloadPlaceholder returns text unchanged when the placeholder is not found. At that point text is the hermes-generated plain-text output, which still contains __HTTPSMS_WEBHOOK_SEND_FAILED_EVENT_PAYLOAD__ as the literal Value of the dictionary entry. If hermes or the underlying html2text converter ever transforms or trims this token (unlikely today but possible on a dependency bump), users would receive a webhook failure email whose "Event Payload" line reads __HTTPSMS_WEBHOOK_SEND_FAILED_EVENT_PAYLOAD__ instead of the actual payload. An alternative would be to set Value to formattedPayload directly and accept that html2text may collapse its whitespace, or to emit an observable signal (log/metric) when !found.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9fa5b76. If Hermes no longer preserves the sentinel, plain-text generation now retries with the actual formatted payload instead of exposing the internal token.

Comment on lines +51 to +52
assert.NotContains(t, email.Text, "<pre")
assert.NotContains(t, email.Text, webhookSendFailedEventPayloadPlaceholder)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Adding the mirrored assertion makes the test fail-fast if UnsafeValue is ever accidentally empty and the template falls back to rendering the placeholder as the Value.

Suggested change
assert.NotContains(t, email.Text, "<pre")
assert.NotContains(t, email.Text, webhookSendFailedEventPayloadPlaceholder)
assert.NotContains(t, email.Text, "<pre")
assert.NotContains(t, email.Text, webhookSendFailedEventPayloadPlaceholder)
assert.NotContains(t, email.HTML, webhookSendFailedEventPayloadPlaceholder)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9fa5b76. The factory test now asserts the sentinel is absent from both HTML and plain-text output.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR improves readability of webhook failure notification emails in the API by pretty-printing the webhook Event Payload JSON and rendering it as a safe, whitespace-preserving code block in HTML (with lightweight syntax highlighting), while keeping plain-text emails readable and preserving existing rendering for all other dictionary entries.

Changes:

  • Added an Event Payload formatter that pretty-prints valid JSON and produces safely-escaped highlighted HTML wrapped in an email-friendly <pre> block.
  • Updated the Hermes HTML theme dictionary rendering to optionally use hermes.Entry.UnsafeValue when explicitly provided.
  • Wired the formatter into WebhookSendFailed only, including a placeholder-based restoration step to preserve payload whitespace in the generated plain-text output, plus targeted tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
docs/superpowers/specs/2026-07-18-webhook-email-payload-formatting-design.md Captures design decisions and security constraints for formatting only the webhook Event Payload.
docs/superpowers/plans/2026-07-18-webhook-email-payload-formatting.md Step-by-step implementation plan and intended test coverage for the formatter + Hermes theme + factory wiring.
api/pkg/emails/hermes_theme.go Enables opt-in HTML dictionary rendering via Entry.UnsafeValue while keeping default escaped Value behavior.
api/pkg/emails/hermes_theme_test.go Tests that only entries with UnsafeValue render unescaped in HTML and that plain text still uses Value.
api/pkg/emails/hermes_notification_email_factory.go Formats webhook Event Payload and injects rich HTML safely; restores plain-text payload formatting via placeholder replacement.
api/pkg/emails/hermes_notification_email_factory_test.go End-to-end tests verifying only Event Payload uses <pre>/highlighting and plain text does not leak HTML or placeholders.
api/pkg/emails/event_payload_formatter.go Implements JSON indentation + safe token-level highlighting and HTML escaping inside a styled <pre> wrapper.
api/pkg/emails/event_payload_formatter_test.go Covers valid/invalid JSON formatting, escaping, top-level JSON shapes, and ensures highlighting is applied only when appropriate.

@AchoArnold
AchoArnold merged commit 96a4319 into main Jul 18, 2026
13 of 14 checks passed
AchoArnold added a commit that referenced this pull request Jul 18, 2026
Integrate current origin/main (thread archive-view preservation #952,
unarchive-on-receive #954, webhook email payload formatting #955) with the
read-receipts feature set, preserving both.

Semantic resolutions:
- UpdateThread now resolves the per-phone unarchive decision and the inbound
  unread watermark in a single atomic repository.UpdateActivity call. Added an
  Unarchive flag to MessageThreadActivityUpdate so is_archived=false is applied
  in the same transaction as the read-state/last-message updates.
- Missed calls and inbound SMS both use MessageStatusReceived + MarksUnread, so
  they share the same unarchive + mark-unread rules.
- Combined the message thread service test suites (read-receipt stubs plus
  shouldCheckUnarchive coverage) and updated the service/handler test helpers to
  the 5-arg NewMessageThreadService signature (adds phoneRepository).
- Merged tests/README.md coverage entries for both E2E suites.
- Regenerated Swagger and web/shared/types/api.ts so both main's new fields and
  read-receipt fields are present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants