package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html"
"os"
"strings"
"text/template"
)
// ---------- escaping ----------
// h escapes a single-line value for HTML.
func h(s string) string { return html.EscapeString(s) }
// esc escapes free text and keeps its line breaks (PHP's nl2br(htmlspecialchars())).
func esc(s string) string {
s = html.EscapeString(s)
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.ReplaceAll(s, "\n", "
\n")
}
// ---------- ordered JSON ----------
// OMap is a JSON object that remembers key order, so emails list fields
// in the order the sender wrote them.
type OMap struct {
Keys []string
M map[string]any
}
func (m *OMap) Get(k string) any {
if m == nil {
return nil
}
return m.M[k]
}
func (m *OMap) Len() int {
if m == nil {
return 0
}
return len(m.Keys)
}
// decodeJSON parses JSON into OMap / []any / string / json.Number / bool / nil.
func decodeJSON(b []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
v, err := decodeValue(dec)
if err != nil {
return nil, err
}
if _, err := dec.Token(); err == nil {
return nil, errors.New("trailing data after JSON value")
}
return v, nil
}
func decodeValue(dec *json.Decoder) (any, error) {
t, err := dec.Token()
if err != nil {
return nil, err
}
d, isDelim := t.(json.Delim)
if !isDelim {
return t, nil
}
switch d {
case '{':
om := &OMap{M: map[string]any{}}
for dec.More() {
kt, err := dec.Token()
if err != nil {
return nil, err
}
k, _ := kt.(string)
v, err := decodeValue(dec)
if err != nil {
return nil, err
}
if _, dup := om.M[k]; !dup {
om.Keys = append(om.Keys, k)
}
om.M[k] = v
}
_, err = dec.Token() // '}'
return om, err
case '[':
arr := []any{}
for dec.More() {
v, err := decodeValue(dec)
if err != nil {
return nil, err
}
arr = append(arr, v)
}
_, err = dec.Token() // ']'
return arr, err
}
return nil, fmt.Errorf("unexpected delimiter %v", d)
}
// asObject returns the value as an object, or nil.
func asObject(v any) *OMap {
m, _ := v.(*OMap)
return m
}
// isContainer reports whether v is an object or array (PHP's is_array on decoded JSON).
func isContainer(v any) bool {
switch v.(type) {
case *OMap, []any:
return true
}
return false
}
func containerLen(v any) int {
switch x := v.(type) {
case *OMap:
return x.Len()
case []any:
return len(x)
}
return 0
}
// str renders a scalar the way it should read in an email.
func str(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case json.Number:
return x.String()
case bool:
if x {
return "true"
}
return "false"
default:
b, _ := json.Marshal(x)
return string(b)
}
}
// strOr is PHP's `$x ?? $default`: only a missing/null value takes the default.
func strOr(v any, def string) string {
if v == nil {
return def
}
return str(v)
}
// get walks nested objects: get(payload, "repository", "full_name").
func get(v any, keys ...string) any {
for _, k := range keys {
m := asObject(v)
if m == nil {
return nil
}
v = m.M[k]
}
return v
}
func num(v any) float64 {
if n, ok := v.(json.Number); ok {
f, _ := n.Float64()
return f
}
return 0
}
// ---------- HTML building blocks ----------
const tableOpen = `
`
}
func section(title, inner string) string {
return `` + title + `
` + inner + `
`
}
func link(url string) string {
u := h(url)
return `` + u + ``
}
// renderDataAsTable renders arbitrary decoded JSON as nested tables (generic fallback).
func renderDataAsTable(data any, depth int) string {
if depth > 10 {
return `Max depth reached
`
}
var b strings.Builder
switch d := data.(type) {
case []any:
if len(d) == 0 {
return table()
}
for i, item := range d {
if m := asObject(item); m != nil {
b.WriteString(`Item ` + fmt.Sprint(i+1) + `
` + tableOpen)
for _, k := range m.Keys {
v := m.M[k]
if isContainer(v) {
b.WriteString(row(h(k), renderDataAsTable(v, depth+1)))
} else {
b.WriteString(row(h(k), esc(str(v))))
}
}
b.WriteString(`
`)
} else if isContainer(item) {
b.WriteString(renderDataAsTable(item, depth+1))
} else {
b.WriteString(`• ` + esc(str(item)) + `
`)
}
}
case *OMap:
var rows []string
flush := func() {
if len(rows) > 0 {
b.WriteString(table(rows...))
rows = nil
}
}
for _, k := range d.Keys {
v := d.M[k]
switch {
case isContainer(v) && containerLen(v) > 0:
flush()
b.WriteString(`` + h(k) + `
` + renderDataAsTable(v, depth+1) + `
`)
case isContainer(v):
rows = append(rows, row(h(k), `empty`))
default:
rows = append(rows, row(h(k), esc(str(v))))
}
}
flush()
default:
return `` + esc(str(data)) + `
`
}
return b.String()
}
// ---------- email shell ----------
var shellTmpl = template.Must(template.New("shell").Parse(shellSrc))
type shellData struct {
Host, Title, PlainTitle, Stamp, Body, Footer, Mono, Sans string
}
var hostName = func() string {
if v := os.Getenv("PUBLIC_HOST"); v != "" {
return v
}
return "webhooks.dixon.cx"
}()
// emailShell wraps a body of sections in the shared design: stylesheet, gradient header, footer.
// title and stamp must already be HTML-safe.
func emailShell(title, stamp, body, footer string) string {
if footer == "" {
footer = "Automatically forwarded to your inbox."
}
var out strings.Builder
_ = shellTmpl.Execute(&out, shellData{
Host: h(hostName),
Title: title,
PlainTitle: stripTags(title),
Stamp: stamp,
Body: body,
Footer: footer,
Mono: "ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace",
Sans: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif",
})
return out.String()
}
func stripTags(s string) string {
var b strings.Builder
in := false
for _, r := range s {
switch {
case r == '<':
in = true
case r == '>':
in = false
case !in:
b.WriteRune(r)
}
}
return b.String()
}
const shellSrc = `
{{.PlainTitle}}
|
Webhook › {{.Host}}
{{.Title}}
{{.Stamp}}
|
|
{{.Body}}
|
|
`