The landing page and the email shell drop the dark gradient look for a white background, black text and a single Bristol blue accent. Copy is unchanged. Email footers and docs now point at webhook.me.uk; the dixon.cx hostnames keep working.
375 lines
11 KiB
Go
375 lines
11 KiB
Go
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", "<br>\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 = `<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">`
|
|
|
|
func row(label, valueHTML string) string {
|
|
return `<tr><td class="metadata-label">` + label + `</td><td class="metadata-value">` + valueHTML + `</td></tr>`
|
|
}
|
|
|
|
func table(rows ...string) string {
|
|
return tableOpen + strings.Join(rows, "") + `</table>`
|
|
}
|
|
|
|
func section(title, inner string) string {
|
|
return `<div class="section"><div class="section-title">` + title + `</div>` + inner + `</div>`
|
|
}
|
|
|
|
func link(url string) string {
|
|
u := h(url)
|
|
return `<a href="` + u + `">` + u + `</a>`
|
|
}
|
|
|
|
// renderDataAsTable renders arbitrary decoded JSON as nested tables (generic fallback).
|
|
func renderDataAsTable(data any, depth int) string {
|
|
if depth > 10 {
|
|
return `<div class="data-box">Max depth reached</div>`
|
|
}
|
|
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(`<div class="array-item"><div class="array-item-title">Item ` + fmt.Sprint(i+1) + `</div>` + 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(`</table></div>`)
|
|
} else if isContainer(item) {
|
|
b.WriteString(renderDataAsTable(item, depth+1))
|
|
} else {
|
|
b.WriteString(`<div class="array-item-simple">• ` + esc(str(item)) + `</div>`)
|
|
}
|
|
}
|
|
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(`<div class="subsection"><div class="subsection-title">` + h(k) + `</div>` + renderDataAsTable(v, depth+1) + `</div>`)
|
|
case isContainer(v):
|
|
rows = append(rows, row(h(k), `<em>empty</em>`))
|
|
default:
|
|
rows = append(rows, row(h(k), esc(str(v))))
|
|
}
|
|
}
|
|
flush()
|
|
default:
|
|
return `<div class="simple-value">` + esc(str(data)) + `</div>`
|
|
}
|
|
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 = `<!DOCTYPE html>
|
|
<html lang="en-GB">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="color-scheme" content="light">
|
|
<meta name="supported-color-schemes" content="light">
|
|
<title>{{.PlainTitle}}</title>
|
|
<style>
|
|
body { margin:0; padding:0; background:#f4f6fa; color:#0a0a0a; -webkit-font-smoothing:antialiased; font-family:{{.Sans}}; font-size:15px; line-height:1.6; }
|
|
a { color:#0047ab; }
|
|
table { border-collapse:separate; border-spacing:0; }
|
|
.frame { width:100%; max-width:660px; }
|
|
.hero { background:#ffffff; padding:30px 34px 22px; border-radius:12px 12px 0 0; border:1px solid #e3e7ef; border-bottom:0; border-top:4px solid #0047ab; }
|
|
.kicker { font-family:{{.Mono}}; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#0047ab; margin:0 0 10px; }
|
|
.hero h1 { margin:0 0 12px; font-size:24px; font-weight:800; letter-spacing:-.02em; line-height:1.25; color:#0a0a0a; }
|
|
.stamp { display:inline-block; font-family:{{.Mono}}; font-size:12px; letter-spacing:.04em; color:#4a4f5c; }
|
|
.card { background:#ffffff; padding:0 0 26px; border-radius:0 0 12px 12px; border:1px solid #e3e7ef; border-top:0; }
|
|
.section { padding:22px 34px 0; }
|
|
.section-title { font-family:{{.Mono}}; font-size:11px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:#0047ab; margin:0 0 10px; line-height:1.4; }
|
|
.subsection { margin:14px 0 0; }
|
|
.subsection-title { font-size:13px; font-weight:700; color:#0a0a0a; margin:16px 0 8px; }
|
|
table.metadata { width:100%; border:1px solid #e3e7ef; border-radius:8px; overflow:hidden; background:#ffffff; }
|
|
td.metadata-label, td.metadata-value { padding:10px 14px; border-bottom:1px solid #eef0f5; vertical-align:top; text-align:left; }
|
|
tr:last-child > td.metadata-label, tr:last-child > td.metadata-value { border-bottom:0; }
|
|
td.metadata-label { width:150px; max-width:170px; font-family:{{.Mono}}; font-size:12px; line-height:1.5; color:#4a4f5c; background:#f6f8fc; word-break:break-word; }
|
|
td.metadata-value { font-size:14px; color:#0a0a0a; word-break:break-word; }
|
|
td.metadata-value table.metadata { margin:2px 0; }
|
|
.data-box { margin:0; padding:16px 18px; background:#0a0a0a; color:#e8e8e8; border-radius:8px; font-family:{{.Mono}}; font-size:12.5px; line-height:1.65; white-space:pre-wrap; word-break:break-word; overflow-x:auto; }
|
|
.path-title { display:inline-block; margin:18px 0 8px; font-family:{{.Mono}}; font-size:12px; font-weight:700; color:#0047ab; }
|
|
.array-item { margin:0 0 12px; padding:16px; background:#f6f8fc; border:1px solid #e3e7ef; border-radius:8px; }
|
|
.array-item-title { font-size:14px; font-weight:800; margin:0 0 10px; color:#0a0a0a; letter-spacing:-.01em; }
|
|
.array-item-simple { margin:0 0 6px; padding:9px 14px; background:#f6f8fc; border:1px solid #e3e7ef; border-radius:8px; font-size:14px; }
|
|
.simple-value { font-size:14px; padding:4px 0; }
|
|
.commit-item { margin:0 0 10px; padding:12px 14px; background:#f6f8fc; border:1px solid #e3e7ef; border-radius:8px; }
|
|
.commit-item .sha { display:inline-block; font-family:{{.Mono}}; font-size:12px; font-weight:700; color:#0047ab; text-decoration:none; }
|
|
.commit-item .who { font-size:12px; color:#4a4f5c; margin-left:6px; }
|
|
.commit-item .msg { margin-top:6px; font-size:14px; line-height:1.5; color:#0a0a0a; }
|
|
.message-box { margin:12px 0; padding:14px 18px; background:#eef3fb; border-left:3px solid #0047ab; border-radius:0 8px 8px 0; font-size:16px; line-height:1.55; color:#0a0a0a; }
|
|
.error-box { margin:12px 0; padding:12px 14px; background:#fef2f2; border-left:3px solid #dc2626; border-radius:0 8px 8px 0; }
|
|
.footer { padding:20px 34px 8px; text-align:center; color:#7a8090; font-family:{{.Mono}}; font-size:11px; letter-spacing:.06em; line-height:1.8; }
|
|
.footer a { color:#0047ab; text-decoration:none; }
|
|
.footer .bolt { color:#0047ab; }
|
|
</style>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#f4f6fa;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6fa;">
|
|
<tr><td align="center" style="padding:36px 12px 28px;">
|
|
<table role="presentation" class="frame" cellpadding="0" cellspacing="0">
|
|
<tr><td class="hero">
|
|
<p class="kicker">Webhook › {{.Host}}</p>
|
|
<h1>{{.Title}}</h1>
|
|
<span class="stamp">{{.Stamp}}</span>
|
|
</td></tr>
|
|
<tr><td class="card">
|
|
{{.Body}}
|
|
</td></tr>
|
|
<tr><td class="footer">
|
|
{{.Footer}}<br>
|
|
<span class="bolt">⚡</span> <a href="https://{{.Host}}/">{{.Host}}</a>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`
|