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
294 lines
17 KiB
HTML
294 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Webhook → Email | {{.Host}}</title>
|
||
<meta name="description" content="Point any webhook at a URL with your email in it. Get the whole request in your inbox. That's it. That's the product.">
|
||
<style>
|
||
:root{
|
||
--bg:#0b0b10;--bg2:#12121a;--panel:#161622;--line:#26263a;
|
||
--text:#ececf4;--muted:#9a9ab4;--dim:#6a6a86;
|
||
--a1:#8b5cf6;--a2:#ec4899;--a3:#f97316;
|
||
--grad:linear-gradient(110deg,var(--a1),var(--a2) 55%,var(--a3));
|
||
--mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,Consolas,monospace;
|
||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html{scroll-behavior:smooth}
|
||
body{
|
||
margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);
|
||
line-height:1.6;font-size:17px;-webkit-font-smoothing:antialiased;
|
||
background-image:
|
||
radial-gradient(900px 500px at 15% -10%,rgba(139,92,246,.22),transparent 60%),
|
||
radial-gradient(700px 400px at 90% 10%,rgba(236,72,153,.16),transparent 60%),
|
||
linear-gradient(var(--line) 1px,transparent 1px),
|
||
linear-gradient(90deg,var(--line) 1px,transparent 1px);
|
||
background-size:auto,auto,48px 48px,48px 48px;
|
||
background-attachment:fixed;
|
||
}
|
||
a{color:inherit}
|
||
.wrap{max-width:980px;margin:0 auto;padding:0 20px}
|
||
::selection{background:var(--a2);color:#fff}
|
||
|
||
/* nav */
|
||
nav{display:flex;align-items:center;justify-content:space-between;padding:22px 0;font-size:14px}
|
||
nav .brand{font-family:var(--mono);font-weight:700;letter-spacing:-.02em}
|
||
nav .brand span{background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||
nav ul{display:flex;gap:22px;list-style:none;margin:0;padding:0;color:var(--muted)}
|
||
nav a{text-decoration:none}nav a:hover{color:var(--text)}
|
||
@media(max-width:600px){nav ul{display:none}}
|
||
|
||
/* hero */
|
||
.hero{padding:70px 0 40px;position:relative}
|
||
.eyebrow{font-family:var(--mono);font-size:13px;color:var(--a2);letter-spacing:.12em;text-transform:uppercase;margin-bottom:18px}
|
||
h1{font-size:clamp(2.6rem,7vw,5.2rem);line-height:.98;letter-spacing:-.04em;margin:0 0 22px;font-weight:800}
|
||
h1 em{font-style:normal;background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||
.hero p{font-size:clamp(1.05rem,2vw,1.35rem);color:var(--muted);max-width:640px;margin:0 0 30px}
|
||
.hero p b{color:var(--text);font-weight:600}
|
||
|
||
/* url builder */
|
||
.builder{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px;box-shadow:0 30px 80px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.04)}
|
||
.builder label{display:block;font-size:12px;color:var(--dim);font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;margin-bottom:8px}
|
||
.builder input{
|
||
width:100%;font:inherit;font-size:1.1rem;padding:12px 14px;border-radius:10px;
|
||
background:var(--bg);border:1px solid var(--line);color:var(--text);outline:none;
|
||
}
|
||
.builder input:focus{border-color:var(--a1);box-shadow:0 0 0 3px rgba(139,92,246,.25)}
|
||
.url{margin-top:14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.url code{flex:1;min-width:0;font-family:var(--mono);font-size:.95rem;word-break:break-all;padding:12px 14px;border-radius:10px;background:var(--bg);border:1px dashed var(--line)}
|
||
.url code b{color:var(--a3);font-weight:600}
|
||
button.copy{font:inherit;font-size:.9rem;font-weight:600;padding:12px 18px;border-radius:10px;border:0;cursor:pointer;background:var(--grad);color:#fff;white-space:nowrap}
|
||
button.copy:active{transform:scale(.97)}
|
||
.hint{font-size:.85rem;color:var(--dim);margin:12px 0 0}
|
||
|
||
/* sections */
|
||
section{padding:56px 0;border-top:1px solid var(--line)}
|
||
h2{font-size:clamp(1.7rem,4vw,2.4rem);letter-spacing:-.03em;margin:0 0 8px;line-height:1.1}
|
||
.sub{color:var(--muted);margin:0 0 28px;max-width:640px}
|
||
h3{font-size:1.1rem;margin:28px 0 10px;letter-spacing:-.01em}
|
||
.tangent{color:var(--dim);font-size:.92rem;font-style:italic}
|
||
p code,li code{font-family:var(--mono);font-size:.88em;background:var(--panel);border:1px solid var(--line);padding:2px 6px;border-radius:6px;color:#fbbf24}
|
||
|
||
pre{
|
||
margin:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px;
|
||
font-family:var(--mono);font-size:.9rem;line-height:1.55;overflow-x:auto;color:#d6d6e6;
|
||
position:relative
|
||
}
|
||
pre::before{content:attr(data-lang);position:absolute;top:8px;right:12px;font-size:11px;color:var(--dim);letter-spacing:.1em;text-transform:uppercase}
|
||
pre .h{color:#f9a8d4}pre .s{color:#fcd34d}pre .k{color:#a5b4fc}
|
||
.stack{display:grid;gap:14px}
|
||
|
||
/* steps */
|
||
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;counter-reset:s}
|
||
.step{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;position:relative;counter-increment:s}
|
||
.step::before{content:"0" counter(s);font-family:var(--mono);font-size:2.4rem;font-weight:800;line-height:1;background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent;display:block;margin-bottom:12px}
|
||
.step b{display:block;font-size:1.05rem;margin-bottom:4px}
|
||
.step span{color:var(--muted);font-size:.95rem}
|
||
|
||
/* parsers */
|
||
.parsers{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
|
||
.parser{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;transition:transform .15s,border-color .15s}
|
||
.parser:hover{transform:translateY(-3px);border-color:var(--a1)}
|
||
.parser .ico{font-size:1.6rem;margin-bottom:8px}
|
||
.parser code{display:inline-block;font-family:var(--mono);font-size:.85rem;color:var(--a3);margin-bottom:8px}
|
||
.parser p{margin:0;color:var(--muted);font-size:.95rem}
|
||
|
||
/* inbox list */
|
||
.inbox{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;list-style:none;padding:0;margin:0}
|
||
.inbox li{padding:14px 16px;background:var(--panel);border:1px solid var(--line);border-radius:12px;font-size:.95rem}
|
||
.inbox li b{display:block;color:var(--text)}
|
||
.inbox li span{color:var(--muted)}
|
||
|
||
/* callouts */
|
||
.note{border-left:3px solid var(--a1);padding:14px 18px;background:rgba(139,92,246,.08);border-radius:0 12px 12px 0;margin:22px 0}
|
||
.note.warn{border-color:var(--a3);background:rgba(249,115,22,.08)}
|
||
.note b{color:var(--text)}
|
||
.note p{margin:0;color:var(--muted)}
|
||
|
||
/* chips */
|
||
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||
.chip{font-family:var(--mono);font-size:.8rem;padding:5px 11px;border:1px solid var(--line);border-radius:999px;color:var(--muted)}
|
||
|
||
footer{padding:50px 0 60px;border-top:1px solid var(--line);color:var(--dim);font-size:.9rem;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
||
footer a{color:var(--muted)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<nav>
|
||
<div class="brand"><span>webhook</span>→email</div>
|
||
<ul>
|
||
<li><a href="#start">Start</a></li>
|
||
<li><a href="#examples">Examples</a></li>
|
||
<li><a href="#parsers">Parsers</a></li>
|
||
<li><a href="#inbox">Inbox</a></li>
|
||
<li><a href="#faq">Caveats</a></li>
|
||
</ul>
|
||
</nav>
|
||
|
||
<div class="hero">
|
||
<div class="eyebrow">// no signup. no dashboard. no, seriously.</div>
|
||
<h1>Webhooks.<br>Straight to <em>your inbox.</em></h1>
|
||
<p>Put your email in the URL. Point a webhook at it. Every header, every byte of body, every superglobal shows up as an email. <b>Done. Go do the next thing.</b></p>
|
||
|
||
<div class="builder">
|
||
<label for="em">Type your email → get your URL</label>
|
||
<input id="em" type="email" placeholder="you@example.com" autocomplete="email" spellcheck="false">
|
||
<div class="url">
|
||
<code>https://{{.Host}}/<b id="out">you%40example.com</b></code>
|
||
<button class="copy" id="cp">Copy</button>
|
||
</div>
|
||
<p class="hint">Yes the <code>@</code> becomes <code>%40</code>. Yes that's the whole trick. Add <code>/github</code>, <code>/json</code>, etc. on the end for the fancy parsers — more on that below, if you scroll that far.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<section id="start">
|
||
<h2>Three steps. Two, really.</h2>
|
||
<p class="sub">The third one is just "wait for an email", which you were going to do anyway.</p>
|
||
<div class="steps">
|
||
<div class="step"><b>Encode your email</b><span><code>@</code> → <code>%40</code>. The box above already did it for you. Ok moving on.</span></div>
|
||
<div class="step"><b>Paste the URL into whatever</b><span>Stripe, GitHub, Grafana, that cron job, a curl you're about to forget you wrote.</span></div>
|
||
<div class="step"><b>Check your inbox</b><span>Full request, nicely formatted. Subject starts with ‼️ so it's filterable. You're welcome.</span></div>
|
||
</div>
|
||
|
||
<h3>The fastest possible test</h3>
|
||
<pre data-lang="bash">curl -X POST https://{{.Host}}/<span class="s" id="ex1">you%40example.com</span> \
|
||
-d '{"test": "hello, is this thing on"}'</pre>
|
||
<p class="tangent">(the email will arrive before you've alt-tabbed back. probably. depends on your mail server. anyway.)</p>
|
||
</section>
|
||
|
||
<section id="examples">
|
||
<h2>Examples, because reading is hard</h2>
|
||
<p class="sub">Any method, any content type. GET, POST, PUT, PATCH, DELETE, something you made up. JSON, form data, multipart, XML, plain text, a string of emoji. It all gets forwarded.</p>
|
||
<div class="stack">
|
||
<pre data-lang="bash"><span class="h"># JSON</span>
|
||
curl -X POST https://{{.Host}}/<span class="s ex">you%40example.com</span> \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"event": "user.created", "user_id": 12345}'
|
||
|
||
<span class="h"># GET with query string (yes, this works too)</span>
|
||
curl "https://{{.Host}}/<span class="s ex">you%40example.com</span>?event=test&id=123"</pre>
|
||
|
||
<pre data-lang="javascript"><span class="k">fetch</span>(<span class="s">'https://{{.Host}}/'</span> + <span class="k">encodeURIComponent</span>(<span class="s">'you@example.com'</span>), {
|
||
method: <span class="s">'POST'</span>,
|
||
headers: { <span class="s">'Content-Type'</span>: <span class="s">'application/json'</span> },
|
||
body: JSON.stringify({ event: <span class="s">'order.completed'</span>, order_id: <span class="s">'ORD-789'</span>, total: 99.99 })
|
||
});</pre>
|
||
|
||
<pre data-lang="python"><span class="k">import</span> requests
|
||
<span class="k">from</span> urllib.parse <span class="k">import</span> quote
|
||
|
||
requests.post(
|
||
<span class="s">f'https://{{.Host}}/{quote("you@example.com")}'</span>,
|
||
json={<span class="s">'event'</span>: <span class="s">'payment.received'</span>, <span class="s">'amount'</span>: 250.00},
|
||
headers={<span class="s">'X-Custom-Header'</span>: <span class="s">'MyValue'</span>},
|
||
)</pre>
|
||
</div>
|
||
<div class="note">
|
||
<p><b>Pro tip you'll ignore and then come back for:</b> use plus addressing. <code>you+stripe%40example.com</code>, <code>you+github%40example.com</code>. Now your inbox filters do the sorting so you don't have to.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="parsers">
|
||
<h2>Parsers. Ooh, shiny.</h2>
|
||
<p class="sub">Add one word to the end of the URL and the email stops being a wall of raw JSON and starts being something you can actually read at 3am. Parser doesn't understand the payload? It falls back to the raw format. Nothing gets lost.</p>
|
||
<div class="parsers">
|
||
<div class="parser"><div class="ico">🐙</div><code>/github</code><p>Push, pull request, issue and release events get their own layout with commits, authors, branches and links. Everything else still renders, just less fancy. Subjects like <em>📤 GitHub push - you/repo</em>.</p></div>
|
||
<div class="parser"><div class="ico">📈</div><code>/grafana</code><p>Grafana and Prometheus Alertmanager alerts. Firing, resolved, labels, annotations. Finally know <em>which</em> thing is on fire.</p></div>
|
||
<div class="parser"><div class="ico">🧩</div><code>/json</code><p>Any JSON at all, rendered as tables. Nested objects get split out into their own tables with path-based titles so you can find <code>data.items[3].sku</code> without squinting.</p></div>
|
||
<div class="parser"><div class="ico">💬</div><code>/wxinteract</code><p>Webex Interact SMS events. Inbound messages, delivery receipts, the lot, with the event type in the subject line.</p></div>
|
||
</div>
|
||
|
||
<h3>Setting up GitHub, since that's the one everyone asks about</h3>
|
||
<ol>
|
||
<li>Repo → Settings → Webhooks → Add webhook</li>
|
||
<li>Payload URL: <code>https://{{.Host}}/<span class="ex">you%40example.com</span>/github</code></li>
|
||
<li>Content type: <code>application/json</code></li>
|
||
<li>Pick events (or "Send me everything", live a little)</li>
|
||
<li>Save. Push something. Refresh inbox. Refresh again. There it is.</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section id="inbox">
|
||
<h2>What actually lands in your inbox</h2>
|
||
<p class="sub">One HTML email per request. Gmail-safe styling. All of the following, every time, whether you asked for it or not:</p>
|
||
<ul class="inbox">
|
||
<li><b>Timestamp</b><span>when it arrived, to the second</span></li>
|
||
<li><b>Source IP + method</b><span>who sent it and how</span></li>
|
||
<li><b>Content-Type</b><span>what they claimed it was</span></li>
|
||
<li><b>Every header</b><span>auth, signatures, user agents, the weird custom ones</span></li>
|
||
<li><b>Raw body</b><span>byte-for-byte, ready to paste into a test</span></li>
|
||
<li><b>Parsed superglobals</b><span>$_GET, $_POST, $_REQUEST, $_FILES</span></li>
|
||
</ul>
|
||
<h3>And the sender gets</h3>
|
||
<pre data-lang="http 200 · application/json">{
|
||
<span class="k">"ok"</span>: true,
|
||
<span class="k">"message"</span>: <span class="s">"Webhook received and forwarded to you@example.com"</span>,
|
||
<span class="k">"forwarded_to"</span>: <span class="s">"you@example.com"</span>,
|
||
<span class="k">"parser"</span>: <span class="s">"default"</span>,
|
||
<span class="k">"subject"</span>: <span class="s">"‼️ Webhook Request Received - 2026-09-11 17:15:42"</span>,
|
||
<span class="k">"received_at"</span>: <span class="s">"2026-09-11T17:15:42+00:00"</span>,
|
||
<span class="k">"request"</span>: { <span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="k">"content_type"</span>: <span class="s">"application/json"</span>, <span class="k">"ip"</span>: <span class="s">"203.0.113.7"</span>, <span class="k">"headers"</span>: 9, <span class="k">"body_bytes"</span>: 40 },
|
||
<span class="k">"data"</span>: {
|
||
<span class="k">"query"</span>: { <span class="k">"src"</span>: <span class="s">"curl"</span> },
|
||
<span class="k">"form"</span>: {},
|
||
<span class="k">"json"</span>: { <span class="k">"event"</span>: <span class="s">"user.created"</span>, <span class="k">"user_id"</span>: 12345 },
|
||
<span class="k">"files"</span>: []
|
||
}
|
||
}</pre>
|
||
<p class="tangent">JSON bodies get decoded and echoed back under <code>data.json</code>, so you can eyeball what the server actually understood. Form fields land in <code>form</code>, query string in <code>query</code>. No print_r in sight.</p>
|
||
<p class="tangent">Any 200 is enough for most services to mark the delivery as "succeeded" and stop retrying. Which is the point.</p>
|
||
</section>
|
||
|
||
<section id="faq">
|
||
<h2>Caveats, small print, that sort of thing</h2>
|
||
<div class="note warn">
|
||
<p><b>Anyone with the URL can email you.</b> There's no auth. There's no secret. It's your email address in a URL. Use it for debugging and testing. Do not point production payment data at it. Don't make me put a bigger warning here.</p>
|
||
</div>
|
||
<h3>Stuff that's true</h3>
|
||
<ul>
|
||
<li><b>Nothing is stored.</b> Request comes in, email goes out, request is forgotten. No logs of your payloads, no database, no "insights".</li>
|
||
<li><b>Output is escaped.</b> Everything is run through <code>htmlspecialchars()</code> before it hits the email, so a malicious payload renders as text, not as a thing.</li>
|
||
<li><b>Bad email → HTTP 400, as JSON.</b> <code>{"ok": false, "error": "invalid_email", ...}</code> with a hint about <code>%40</code>. Validated with PHP's <code>FILTER_VALIDATE_EMAIL</code>. Boring, correct.</li>
|
||
<li><b>Failed sends get logged.</b> On the server side, for the person running it. Not for you. Sorry.</li>
|
||
</ul>
|
||
<h3>Good for</h3>
|
||
<div class="chips">
|
||
<span class="chip">debugging a webhook you didn't write</span>
|
||
<span class="chip">seeing what Stripe actually sends</span>
|
||
<span class="chip">capturing real payloads for docs</span>
|
||
<span class="chip">testing before the handler exists</span>
|
||
<span class="chip">"is it even firing?"</span>
|
||
<span class="chip">alerts in your inbox without a whole thing</span>
|
||
</div>
|
||
</section>
|
||
|
||
<footer>
|
||
<div>Built with Go and ADHD medication. Not necessarily in that order.</div>
|
||
<div>© 2025–2026 Andy Dixon · <a href="https://{{.Host}}/">{{.Host}}</a></div>
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
(function(){
|
||
var em=document.getElementById('em'),out=document.getElementById('out'),cp=document.getElementById('cp');
|
||
var ex=document.querySelectorAll('.ex, #ex1');
|
||
function enc(v){return encodeURIComponent(v.trim()||'you@example.com');}
|
||
em.addEventListener('input',function(){
|
||
var e=enc(em.value);out.textContent=e;
|
||
ex.forEach(function(n){n.textContent=e;});
|
||
});
|
||
cp.addEventListener('click',function(){
|
||
var url=out.parentNode.textContent.replace(/\s+/g,'');
|
||
var done=function(){cp.textContent='Copied ✓';setTimeout(function(){cp.textContent='Copy';},1400);};
|
||
if(navigator.clipboard){navigator.clipboard.writeText(url).then(done);}
|
||
else{var r=document.createRange();r.selectNodeContents(out.parentNode);getSelection().removeAllRanges();getSelection().addRange(r);document.execCommand('copy');done();}
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|