Send as webhook.me.uk, add favicon, share image and SEO tags

Mail now comes from no-reply@webhook.me.uk (DKIM, SPF and DMARC are
set up for it) and the Message-ID uses that domain instead of the
container hostname. The landing page gets a favicon, an Open Graph
image, canonical/OG/Twitter/JSON-LD tags, robots.txt that keeps
crawlers off email URLs, and a sitemap. Footer links to dixon.cx.
This commit is contained in:
Andy Dixon
2026-09-22 08:50:20 +01:00
parent 07701ba9cd
commit 680bfac014
11 changed files with 68 additions and 11 deletions
+1
View File
@@ -2,3 +2,4 @@
!go.mod !go.mod
!*.go !*.go
!docs.html !docs.html
!static
+2 -1
View File
@@ -2,11 +2,12 @@ FROM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod ./
COPY *.go docs.html ./ COPY *.go docs.html ./
COPY static ./static
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /webhooks . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /webhooks .
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /webhooks /webhooks COPY --from=build /webhooks /webhooks
ENV LISTEN=:8080 SMTP_ADDR=172.17.0.1:25 MAIL_FROM=no-reply@dixon.cx PUBLIC_HOST=webhook.me.uk ENV LISTEN=:8080 SMTP_ADDR=172.17.0.1:25 MAIL_FROM=no-reply@webhook.me.uk PUBLIC_HOST=webhook.me.uk
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/webhooks", "-check"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/webhooks", "-check"]
ENTRYPOINT ["/webhooks"] ENTRYPOINT ["/webhooks"]
+1 -1
View File
@@ -354,7 +354,7 @@ Real deliveries over HTTP/2 arrive with lowercase header names. `headers.Get` ha
|----------|---------|---------| |----------|---------|---------|
| `LISTEN` | `127.0.0.1:8080` | Address to listen on (`:8080` in the container) | | `LISTEN` | `127.0.0.1:8080` | Address to listen on (`:8080` in the container) |
| `SMTP_ADDR` | `127.0.0.1:25` | SMTP relay (`172.17.0.1:25` in the container, the host's postfix) | | `SMTP_ADDR` | `127.0.0.1:25` | SMTP relay (`172.17.0.1:25` in the container, the host's postfix) |
| `MAIL_FROM` | `no-reply@dixon.cx` | Envelope and From address | | `MAIL_FROM` | `no-reply@webhook.me.uk` | Envelope and From address |
| `PUBLIC_HOST` | `webhook.me.uk` | Host shown in email footers | | `PUBLIC_HOST` | `webhook.me.uk` | Host shown in email footers |
--- ---
+1 -1
View File
@@ -324,7 +324,7 @@ The image is a static binary on `distroless`, runs as non-root, listens on `:808
|----------|---------|---------| |----------|---------|---------|
| `LISTEN` | `127.0.0.1:8080` | Address to listen on (`:8080` in the container) | | `LISTEN` | `127.0.0.1:8080` | Address to listen on (`:8080` in the container) |
| `SMTP_ADDR` | `127.0.0.1:25` | SMTP relay, no auth or TLS (`172.17.0.1:25` in the container) | | `SMTP_ADDR` | `127.0.0.1:25` | SMTP relay, no auth or TLS (`172.17.0.1:25` in the container) |
| `MAIL_FROM` | `no-reply@dixon.cx` | Envelope and From address | | `MAIL_FROM` | `no-reply@webhook.me.uk` | Envelope and From address |
| `PUBLIC_HOST` | `webhook.me.uk` | Host shown in email footers | | `PUBLIC_HOST` | `webhook.me.uk` | Host shown in email footers |
`/healthz` returns `ok`, and `webhooks -check` exits non-zero if a running instance is unhealthy (used by the image's `HEALTHCHECK`). `/healthz` returns `ok`, and `webhooks -check` exits non-zero if a running instance is unhealthy (used by the image's `HEALTHCHECK`).
+27 -3
View File
@@ -1,10 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en-GB">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webhook → Email | {{.Host}}</title> <title>Webhook → Email: forward any webhook to your inbox | {{.Canonical}}</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."> <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.">
<link rel="canonical" href="https://{{.Canonical}}/">
<meta name="robots" content="index, follow">
<meta name="author" content="Andy Dixon">
<meta name="theme-color" content="#0047ab">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="webhook→email">
<meta property="og:title" content="Webhooks. Straight to your inbox.">
<meta property="og:description" content="Point any webhook at a URL with your email in it. Get the whole request in your inbox. No signup, no dashboard.">
<meta property="og:url" content="https://{{.Canonical}}/">
<meta property="og:image" content="https://{{.Canonical}}/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Webhooks. Straight to your inbox. https://{{.Canonical}}/you%40example.com">
<meta property="og:locale" content="en_GB">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Webhooks. Straight to your inbox.">
<meta name="twitter:description" content="Point any webhook at a URL with your email in it. Get the whole request in your inbox. No signup, no dashboard.">
<meta name="twitter:image" content="https://{{.Canonical}}/og.png">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebApplication","name":"webhook→email","url":"https://{{.Canonical}}/","description":"Forward any webhook to your inbox as a formatted email. Parsers for GitHub, Grafana, JSON and Webex Interact.","applicationCategory":"DeveloperApplication","operatingSystem":"Any","isAccessibleForFree":true,"offers":{"@type":"Offer","price":"0","priceCurrency":"GBP"},"author":{"@type":"Person","name":"Andy Dixon","url":"https://dixon.cx/"}}
</script>
<style> <style>
:root{ :root{
--bg:#ffffff;--panel:#f6f8fc;--line:#e3e7ef; --bg:#ffffff;--panel:#f6f8fc;--line:#e3e7ef;
@@ -250,7 +274,7 @@ requests.post(
<footer> <footer>
<div>Built with Go and ADHD medication. Not necessarily in that order.</div> <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> <div>© 2025–2026 Andy Dixon · <a href="https://dixon.cx/">dixon.cx</a></div>
</footer> </footer>
</div> </div>
+3 -2
View File
@@ -6,12 +6,13 @@ import (
"mime/quotedprintable" "mime/quotedprintable"
"net/smtp" "net/smtp"
"os" "os"
"strings"
"time" "time"
) )
var ( var (
smtpAddr = envOr("SMTP_ADDR", "127.0.0.1:25") smtpAddr = envOr("SMTP_ADDR", "127.0.0.1:25")
mailFrom = envOr("MAIL_FROM", "no-reply@dixon.cx") mailFrom = envOr("MAIL_FROM", "no-reply@webhook.me.uk")
) )
// sendMail is a variable so tests can capture messages instead of sending them. // sendMail is a variable so tests can capture messages instead of sending them.
@@ -43,7 +44,7 @@ func smtpSend(to, subject, htmlBody string) error {
} }
func buildMessage(to, subject, htmlBody string) []byte { func buildMessage(to, subject, htmlBody string) []byte {
host, _ := os.Hostname() host := mailFrom[strings.LastIndex(mailFrom, "@")+1:]
var b []byte var b []byte
b = fmt.Appendf(b, "From: Webhook Call <%s>\r\n", mailFrom) b = fmt.Appendf(b, "From: Webhook Call <%s>\r\n", mailFrom)
b = fmt.Appendf(b, "To: <%s>\r\n", to) b = fmt.Appendf(b, "To: <%s>\r\n", to)
+26 -3
View File
@@ -8,12 +8,13 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
_ "embed" "embed"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"io/fs"
"log" "log"
"net" "net"
"net/http" "net/http"
@@ -32,6 +33,15 @@ var docsSrc string
var docsTmpl = template.Must(template.New("docs").Parse(docsSrc)) var docsTmpl = template.Must(template.New("docs").Parse(docsSrc))
//go:embed static
var staticFS embed.FS
// assets serves favicons and the share image at the site root.
var assets = func() http.Handler {
sub, _ := fs.Sub(staticFS, "static")
return http.FileServerFS(sub)
}()
const maxBody = 10 << 20 // 10 MB, like PHP's post_max_size const maxBody = 10 << 20 // 10 MB, like PHP's post_max_size
// Meta is the request metadata handed to every parser. // Meta is the request metadata handed to every parser.
@@ -113,12 +123,25 @@ func handle(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(r.URL.Path, "/") path := strings.Trim(r.URL.Path, "/")
if path == "" { if path == "" {
w.Header().Set("Content-Type", "text/html; charset=UTF-8") w.Header().Set("Content-Type", "text/html; charset=UTF-8")
_ = docsTmpl.Execute(w, map[string]string{"Host": r.Host}) _ = docsTmpl.Execute(w, map[string]string{"Host": r.Host, "Canonical": hostName})
return return
} }
if path == "healthz" { switch path {
case "healthz":
_, _ = io.WriteString(w, "ok\n") _, _ = io.WriteString(w, "ok\n")
return return
case "favicon.ico", "favicon.svg", "apple-touch-icon.png", "og.png":
w.Header().Set("Cache-Control", "public, max-age=604800")
assets.ServeHTTP(w, r)
return
case "robots.txt":
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
fmt.Fprintf(w, "User-agent: *\nDisallow: /*%%40\nDisallow: /*@\nSitemap: https://%s/sitemap.xml\n", hostName)
return
case "sitemap.xml":
w.Header().Set("Content-Type", "application/xml; charset=UTF-8")
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+"\n"+`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://%s/</loc></url></urlset>`+"\n", hostName)
return
} }
parts := strings.SplitN(path, "/", 3) parts := strings.SplitN(path, "/", 3)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0047ab"/>
<rect x="10" y="18" width="38" height="28" rx="4" fill="#fff"/>
<path d="M12 21l17 13 17-13" fill="none" stroke="#0047ab" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="47" cy="45" r="12" fill="#0a0a0a" stroke="#0047ab" stroke-width="3"/>
<path d="M41 45h11m-4-4.5 4.5 4.5-4.5 4.5" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB