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 row(label, valueHTML string) string { return `` } func table(rows ...string) string { return tableOpen + strings.Join(rows, "") + `` } 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 "webhook.me.uk" }() // emailShell wraps a body of sections in the shared design: stylesheet, 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}}
`