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:
@@ -2,3 +2,4 @@
|
||||
!go.mod
|
||||
!*.go
|
||||
!docs.html
|
||||
!static
|
||||
|
||||
+2
-1
@@ -2,11 +2,12 @@ FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY *.go docs.html ./
|
||||
COPY static ./static
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /webhooks .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
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
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/webhooks", "-check"]
|
||||
ENTRYPOINT ["/webhooks"]
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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) |
|
||||
| `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 |
|
||||
|
||||
`/healthz` returns `ok`, and `webhooks -check` exits non-zero if a running instance is unhealthy (used by the image's `HEALTHCHECK`).
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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.">
|
||||
<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>
|
||||
:root{
|
||||
--bg:#ffffff;--panel:#f6f8fc;--line:#e3e7ef;
|
||||
@@ -250,7 +274,7 @@ requests.post(
|
||||
|
||||
<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>
|
||||
<div>© 2025–2026 Andy Dixon · <a href="https://dixon.cx/">dixon.cx</a></div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"mime/quotedprintable"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
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.
|
||||
@@ -43,7 +44,7 @@ func smtpSend(to, subject, htmlBody string) error {
|
||||
}
|
||||
|
||||
func buildMessage(to, subject, htmlBody string) []byte {
|
||||
host, _ := os.Hostname()
|
||||
host := mailFrom[strings.LastIndex(mailFrom, "@")+1:]
|
||||
var b []byte
|
||||
b = fmt.Appendf(b, "From: Webhook Call <%s>\r\n", mailFrom)
|
||||
b = fmt.Appendf(b, "To: <%s>\r\n", to)
|
||||
|
||||
@@ -8,12 +8,13 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -32,6 +33,15 @@ var docsSrc string
|
||||
|
||||
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
|
||||
|
||||
// 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, "/")
|
||||
if path == "" {
|
||||
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
|
||||
}
|
||||
if path == "healthz" {
|
||||
switch path {
|
||||
case "healthz":
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user