The PHP app, Apache, and .htaccess are replaced by a single Go binary (standard library only). Routing, the JSON response, the landing page, the shared email design, and all four parsers are ported with the same output; JSON object keys are kept in sender order via an ordered decoder. - mail.go: SMTP delivery with quoted-printable body and RFC 2047 subject - main.go: routing, client IP from the proxy, /healthz, -check flag - render.go: email shell, escaping, table helpers, generic renderer - parser_*.go: github, grafana, json, wxinteract - main_test.go: handler, response shape, IP forwarding, every parser - Dockerfile: multi-stage build to distroless static, non-root, HEALTHCHECK - deploy/webhooks.dixon.cx.service: systemd unit for the container - README and PARSER_PROMPT updated for Go; IMPLEMENTATION_SUMMARY removed
326 lines
8.7 KiB
Go
326 lines
8.7 KiB
Go
// Webhook Email Forwarder
|
|
//
|
|
// Captures incoming webhook requests and forwards the complete request
|
|
// (headers, body, metadata) as an HTML email to the address given in the
|
|
// URL path: /you%40example.com[/parser]
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/mail"
|
|
"os"
|
|
"os/signal"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
//go:embed docs.html
|
|
var docsSrc string
|
|
|
|
var docsTmpl = template.Must(template.New("docs").Parse(docsSrc))
|
|
|
|
const maxBody = 10 << 20 // 10 MB, like PHP's post_max_size
|
|
|
|
// Meta is the request metadata handed to every parser.
|
|
type Meta struct {
|
|
Date string // Y-m-d H:i:s
|
|
IP string
|
|
Method string
|
|
ContentType string
|
|
}
|
|
|
|
// Parser turns a raw webhook into an email. ok=false means "fall back to the default email".
|
|
type Parser func(raw []byte, headers http.Header, meta Meta) (html, subject string, ok bool)
|
|
|
|
var parsers = map[string]Parser{
|
|
"github": githubParse,
|
|
"grafana": grafanaParse,
|
|
"json": jsonParse,
|
|
"wxinteract": wxinteractParse,
|
|
}
|
|
|
|
func main() {
|
|
listen := flag.String("listen", envOr("LISTEN", "127.0.0.1:8080"), "address to listen on")
|
|
check := flag.Bool("check", false, "health-check a running instance and exit")
|
|
flag.Parse()
|
|
|
|
if *check {
|
|
addr := *listen
|
|
if strings.HasPrefix(addr, ":") {
|
|
addr = "127.0.0.1" + addr
|
|
}
|
|
resp, err := http.Get("http://" + addr + "/healthz")
|
|
if err != nil || resp.StatusCode != 200 {
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: *listen,
|
|
Handler: http.HandlerFunc(handle),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
}
|
|
go func() {
|
|
log.Printf("listening on %s, smtp %s, from %s", *listen, smtpAddr, mailFrom)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatal(err)
|
|
}
|
|
}()
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
<-stop
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// ---------- HTTP ----------
|
|
|
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
|
|
|
func validEmail(s string) bool {
|
|
if !emailRe.MatchString(s) {
|
|
return false
|
|
}
|
|
a, err := mail.ParseAddress(s)
|
|
return err == nil && a.Address == s
|
|
}
|
|
|
|
func handle(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.Trim(r.URL.Path, "/")
|
|
if path == "" {
|
|
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
|
_ = docsTmpl.Execute(w, map[string]string{"Host": r.Host})
|
|
return
|
|
}
|
|
if path == "healthz" {
|
|
_, _ = io.WriteString(w, "ok\n")
|
|
return
|
|
}
|
|
|
|
parts := strings.SplitN(path, "/", 3)
|
|
email := parts[0] // r.URL.Path is already percent-decoded
|
|
parserName := ""
|
|
if len(parts) > 1 {
|
|
parserName = parts[1]
|
|
}
|
|
|
|
if !validEmail(email) {
|
|
writeJSON(w, 400, errResp{false, "invalid_email", "Invalid email address provided in URL path.", "Encode @ as %40, e.g. /you%40example.com"})
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
meta := Meta{
|
|
Date: now.Format("2006-01-02 15:04:05"),
|
|
IP: clientIP(r),
|
|
Method: r.Method,
|
|
ContentType: r.Header.Get("Content-Type"),
|
|
}
|
|
if meta.ContentType == "" {
|
|
meta.ContentType = "N/A"
|
|
}
|
|
|
|
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
|
if err != nil {
|
|
writeJSON(w, 413, errResp{false, "body_too_large", err.Error(), ""})
|
|
return
|
|
}
|
|
|
|
// Form fields and uploaded file names, the way PHP's $_POST / $_FILES saw them.
|
|
form := map[string]string{}
|
|
files := []string{}
|
|
ct := strings.ToLower(meta.ContentType)
|
|
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") {
|
|
r.Body = io.NopCloser(bytes.NewReader(raw))
|
|
if strings.HasPrefix(ct, "multipart/form-data") {
|
|
if err := r.ParseMultipartForm(maxBody); err == nil && r.MultipartForm != nil {
|
|
for name := range r.MultipartForm.File {
|
|
files = append(files, name)
|
|
}
|
|
sort.Strings(files)
|
|
}
|
|
} else {
|
|
_ = r.ParseForm()
|
|
}
|
|
for k, v := range r.PostForm {
|
|
form[k] = v[len(v)-1]
|
|
}
|
|
}
|
|
query := map[string]string{}
|
|
for k, v := range r.URL.Query() {
|
|
query[k] = v[len(v)-1]
|
|
}
|
|
|
|
// Every header, Host included, as the email and parsers see them.
|
|
headers := r.Header.Clone()
|
|
headers.Set("Host", r.Host)
|
|
|
|
var body, subject string
|
|
if p, found := parsers[parserName]; found {
|
|
body, subject, _ = p(raw, headers, meta)
|
|
}
|
|
if body == "" {
|
|
body, subject = defaultEmail(raw, headers, meta, query, form, files)
|
|
}
|
|
|
|
if err := sendMail(email, subject, body); err != nil {
|
|
log.Printf("mail to %s failed: %v", email, err)
|
|
writeJSON(w, 502, errResp{false, "mail_failed", "Webhook received but the email could not be sent.", ""})
|
|
return
|
|
}
|
|
|
|
var jsonEcho json.RawMessage
|
|
if len(raw) > 0 && strings.Contains(ct, "json") && json.Valid(raw) {
|
|
jsonEcho = raw
|
|
}
|
|
if parserName == "" {
|
|
parserName = "default"
|
|
}
|
|
writeJSON(w, 200, response{
|
|
OK: true,
|
|
Message: "Webhook received and forwarded to " + email,
|
|
ForwardedTo: email,
|
|
Parser: parserName,
|
|
Subject: subject,
|
|
ReceivedAt: now.Format("2006-01-02T15:04:05-07:00"),
|
|
Request: reqInfo{
|
|
Method: meta.Method,
|
|
ContentType: meta.ContentType,
|
|
IP: meta.IP,
|
|
Headers: len(headers),
|
|
BodyBytes: len(raw),
|
|
},
|
|
Data: reqData{Query: query, Form: form, JSON: jsonEcho, Files: files},
|
|
})
|
|
}
|
|
|
|
type errResp struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
Hint string `json:"hint,omitempty"`
|
|
}
|
|
|
|
type response struct {
|
|
OK bool `json:"ok"`
|
|
Message string `json:"message"`
|
|
ForwardedTo string `json:"forwarded_to"`
|
|
Parser string `json:"parser"`
|
|
Subject string `json:"subject"`
|
|
ReceivedAt string `json:"received_at"`
|
|
Request reqInfo `json:"request"`
|
|
Data reqData `json:"data"`
|
|
}
|
|
|
|
type reqInfo struct {
|
|
Method string `json:"method"`
|
|
ContentType string `json:"content_type"`
|
|
IP string `json:"ip"`
|
|
Headers int `json:"headers"`
|
|
BodyBytes int `json:"body_bytes"`
|
|
}
|
|
|
|
type reqData struct {
|
|
Query map[string]string `json:"query"`
|
|
Form map[string]string `json:"form"`
|
|
JSON json.RawMessage `json:"json"`
|
|
Files []string `json:"files"`
|
|
}
|
|
|
|
// writeJSON emits pretty-printed JSON with 4-space indent and no HTML escaping,
|
|
// preserving key order inside any RawMessage.
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
var out bytes.Buffer
|
|
_ = json.Indent(&out, bytes.TrimSpace(buf.Bytes()), "", " ")
|
|
out.WriteByte('\n')
|
|
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(out.Bytes())
|
|
}
|
|
|
|
// clientIP prefers the address nginx forwards, but only when the direct peer
|
|
// is private/loopback (i.e. a proxy), so public clients cannot spoof it.
|
|
func clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()) {
|
|
fwd := r.Header.Get("X-Real-IP")
|
|
if fwd == "" {
|
|
fwd = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
|
|
}
|
|
if net.ParseIP(fwd) != nil {
|
|
return fwd
|
|
}
|
|
}
|
|
return host
|
|
}
|
|
|
|
// ---------- default email ----------
|
|
|
|
func defaultEmail(raw []byte, headers http.Header, meta Meta, query, form map[string]string, files []string) (string, string) {
|
|
keys := make([]string, 0, len(headers))
|
|
for k := range headers {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var hb strings.Builder
|
|
for _, k := range keys {
|
|
for _, v := range headers[k] {
|
|
hb.WriteString(h(k) + ": " + h(v) + "\n")
|
|
}
|
|
}
|
|
|
|
vars, _ := json.MarshalIndent(map[string]any{
|
|
"GET Variables": query,
|
|
"POST Variables": form,
|
|
"Uploaded Files": files,
|
|
}, "", " ")
|
|
|
|
body := section("📋 Request Information", table(
|
|
row("IP Address", h(meta.IP)),
|
|
row("Method", h(meta.Method)),
|
|
row("Content-Type", h(meta.ContentType)),
|
|
)) +
|
|
section("📨 Request Headers", `<pre class="data-box">`+hb.String()+`</pre>`) +
|
|
section("📦 Request Body", `<pre class="data-box">`+h(string(raw))+`</pre>`) +
|
|
section("🔧 Parsed Variables", `<pre class="data-box">`+h(string(vars))+`</pre>`)
|
|
|
|
html := emailShell("⚡ Webhook Request Received", h(meta.Date), body,
|
|
"This webhook was automatically forwarded to your email address.")
|
|
return html, fmt.Sprintf("‼️ Webhook Request Received - %s", meta.Date)
|
|
}
|