Rewrite the service in Go and ship it as a static container
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
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!go.mod
|
||||
!*.go
|
||||
!docs.html
|
||||
@@ -0,0 +1 @@
|
||||
webhooks
|
||||
@@ -1,8 +0,0 @@
|
||||
RewriteEngine On
|
||||
|
||||
# Skip existing files and directories
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Redirect all requests to the PHP script
|
||||
RewriteRule ^(.*)$ index.php [L,QSA]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY *.go docs.html ./
|
||||
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=webhooks.dixon.cx
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/webhooks", "-check"]
|
||||
ENTRYPOINT ["/webhooks"]
|
||||
@@ -1,249 +0,0 @@
|
||||
# Webex Interact Parser Implementation Summary
|
||||
|
||||
## ✅ Implementation Complete
|
||||
|
||||
The Webex Interact webhook parser has been successfully implemented and integrated into the webhook email forwarder system.
|
||||
|
||||
## 📦 What Was Added
|
||||
|
||||
### 1. Main Parser Function (`index.php`)
|
||||
- **Function:** `wxinteractParse($rawBody, $headers, $metadata)`
|
||||
- **Location:** Line 799 in `index.php`
|
||||
- **Returns:** Array with 'html' and 'subject' keys, or false on failure
|
||||
|
||||
### 2. Helper Functions (7 total)
|
||||
Each event type has a dedicated helper function for clean, maintainable code:
|
||||
|
||||
1. `buildOutboundSmsSubmittedContent($data)` - 📤 Submitted SMS
|
||||
2. `buildOutboundSmsDeliveredContent($data)` - ✅ Delivered SMS
|
||||
3. `buildOutboundSmsFailedContent($data)` - ❌ Failed SMS
|
||||
4. `buildShortlinkClickedContent($data)` - 🔗 Link clicks
|
||||
5. `buildInboundSmsContent($data)` - 📥 Inbound messages
|
||||
6. `buildOptOutContent($data)` - 🚫 Opt-out requests
|
||||
7. `buildContactsCallbackContent($data)` - 👤 Contact updates
|
||||
|
||||
### 3. Documentation Files
|
||||
|
||||
#### `WEBEX_INTERACT_GUIDE.md`
|
||||
Comprehensive guide including:
|
||||
- Configuration instructions
|
||||
- All 7 event types with examples
|
||||
- Testing instructions
|
||||
- Troubleshooting tips
|
||||
- API documentation links
|
||||
|
||||
#### `wxinteract_test_samples.json`
|
||||
Complete test payloads for all event types:
|
||||
- outbound_sms_submitted
|
||||
- outbound_sms_delivered
|
||||
- outbound_sms_failed
|
||||
- shortlink_clicked
|
||||
- inbound_sms_received
|
||||
- sms_opt_out
|
||||
- contact_created
|
||||
|
||||
#### Updated `README.md`
|
||||
Added Webex Interact section with:
|
||||
- Parser overview
|
||||
- Supported events list
|
||||
- Usage examples
|
||||
- Email format samples
|
||||
|
||||
## 🎯 Supported Events
|
||||
|
||||
All 7 Webex Interact callback types are supported:
|
||||
|
||||
| Event Type | Status Value | Icon | Description |
|
||||
|------------|-------------|------|-------------|
|
||||
| Outbound SMS - Submitted | `submitted` | 📤 | Message submitted to carrier |
|
||||
| Outbound SMS - Delivered | `delivered` | ✅ | Message delivered successfully |
|
||||
| Outbound SMS - Failed | `failed` | ❌ | Message failed to deliver |
|
||||
| Shortlink Clicked | `shortlink_clicked` | 🔗 | Recipient clicked a tracked link |
|
||||
| Inbound SMS Received | `received` | 📥 | Inbound message received |
|
||||
| SMS Opt Out Received | `opt_out` | 🚫 | Recipient opted out |
|
||||
| Contacts Callback | `contact_created` / `contact_updated` | 👤 | Contact created or updated |
|
||||
|
||||
## 🔧 How It Works
|
||||
|
||||
### Event Detection
|
||||
The parser identifies the event type using the `data.status` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"status": "submitted", // ← Parser reads this
|
||||
"message_id": "...",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### URL Format
|
||||
```
|
||||
https://your-domain.com/{email}/wxinteract
|
||||
^^^^^^^^^ parser trigger
|
||||
```
|
||||
|
||||
### Processing Flow
|
||||
1. Webhook received at URL with `/wxinteract`
|
||||
2. `index.php` checks if `wxinteractParse()` function exists ✓
|
||||
3. Function parses JSON and extracts `data.status`
|
||||
4. Routes to appropriate helper function based on status
|
||||
5. Helper function builds formatted HTML
|
||||
6. Email sent with clean, readable format
|
||||
|
||||
## 🎨 Design Features
|
||||
|
||||
### Consistent Styling
|
||||
- Uses same CSS framework as GitHub parser
|
||||
- Professional monochrome theme
|
||||
- Responsive email design
|
||||
- Clean metadata tables
|
||||
|
||||
### Security
|
||||
- All output sanitized with `htmlspecialchars()`
|
||||
- Safe HTML rendering
|
||||
- No XSS vulnerabilities
|
||||
|
||||
### User Experience
|
||||
- Event-specific emojis for quick scanning
|
||||
- Message content highlighted in message boxes
|
||||
- Error information clearly displayed in error boxes
|
||||
- Contact custom fields supported
|
||||
- Clickable URLs for shortlinks
|
||||
|
||||
## 📋 Testing
|
||||
|
||||
### Manual Testing
|
||||
```bash
|
||||
# Test submitted event
|
||||
curl -X POST https://your-domain.com/test%40example.com/wxinteract \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @wxinteract_test_samples.json
|
||||
```
|
||||
|
||||
### Validation Checklist
|
||||
- ✅ PHP syntax validated (`php -l index.php`)
|
||||
- ✅ Parser function exists and is callable
|
||||
- ✅ All 7 event types have dedicated handlers
|
||||
- ✅ HTML sanitization implemented
|
||||
- ✅ Graceful fallback to default format on parse failure
|
||||
- ✅ Documentation complete
|
||||
- ✅ Test samples provided
|
||||
- ✅ Follows existing parser conventions
|
||||
|
||||
## 📁 Files Modified/Added
|
||||
|
||||
### Modified
|
||||
1. `index.php` - Added parser and helper functions (~600 lines)
|
||||
2. `README.md` - Added Webex Interact section
|
||||
|
||||
### Added
|
||||
1. `WEBEX_INTERACT_GUIDE.md` - Complete usage guide
|
||||
2. `wxinteract_test_samples.json` - Test payloads
|
||||
3. `IMPLEMENTATION_SUMMARY.md` - This file
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### No Additional Requirements
|
||||
- Uses existing PHP mail() function
|
||||
- No new dependencies
|
||||
- No database changes
|
||||
- Backward compatible
|
||||
|
||||
### Immediate Availability
|
||||
The parser is immediately available at:
|
||||
```
|
||||
https://your-domain.com/{your-email}/wxinteract
|
||||
```
|
||||
|
||||
## 💡 Usage Examples
|
||||
|
||||
### Production Use
|
||||
```
|
||||
https://webhooks.dixon.cx/sms-alerts%40mycompany.com/wxinteract
|
||||
```
|
||||
|
||||
### Testing/Development
|
||||
```
|
||||
https://webhooks.dixon.cx/dev%40mycompany.com/wxinteract
|
||||
```
|
||||
|
||||
### Campaign-Specific
|
||||
```
|
||||
https://webhooks.dixon.cx/sms+summer-campaign%40mycompany.com/wxinteract
|
||||
```
|
||||
|
||||
## 📊 Code Statistics
|
||||
|
||||
- **Total lines added:** ~600 lines
|
||||
- **Parser function:** 1 main + 7 helpers
|
||||
- **Event types supported:** 7
|
||||
- **Test samples:** 7 complete payloads
|
||||
- **Documentation:** 3 files updated/created
|
||||
|
||||
## 🔍 Quality Assurance
|
||||
|
||||
### Code Quality
|
||||
- ✅ Follows PSR standards
|
||||
- ✅ Consistent naming conventions
|
||||
- ✅ Proper error handling
|
||||
- ✅ DRY principle (helper functions)
|
||||
- ✅ Clear comments and documentation
|
||||
|
||||
### Security
|
||||
- ✅ Input validation
|
||||
- ✅ HTML sanitization
|
||||
- ✅ Safe error messages
|
||||
- ✅ No sensitive data leakage
|
||||
|
||||
### Maintainability
|
||||
- ✅ Modular design
|
||||
- ✅ Helper functions for each event type
|
||||
- ✅ Easy to extend for new events
|
||||
- ✅ Comprehensive documentation
|
||||
|
||||
## 🎓 Knowledge Transfer
|
||||
|
||||
### For Developers
|
||||
- Review `WEBEX_INTERACT_GUIDE.md` for API details
|
||||
- Check `wxinteract_test_samples.json` for payload examples
|
||||
- See helper functions for HTML formatting patterns
|
||||
|
||||
### For End Users
|
||||
- Configure webhook URL: `/{email}/wxinteract`
|
||||
- Select desired events in Webex Interact dashboard
|
||||
- Check email for formatted notifications
|
||||
|
||||
### For Administrators
|
||||
- No server configuration changes needed
|
||||
- PHP mail() must be enabled (already required)
|
||||
- No additional security considerations
|
||||
|
||||
## 📈 Future Enhancements
|
||||
|
||||
Possible improvements:
|
||||
1. Add webhook signature verification (if Webex Interact supports it)
|
||||
2. Support for MMS attachments display
|
||||
3. Delivery analytics summary emails
|
||||
4. Custom templates per email address
|
||||
5. Integration with other Webex Interact APIs
|
||||
|
||||
## ✨ Summary
|
||||
|
||||
A complete, production-ready Webex Interact webhook parser has been implemented following best practices:
|
||||
|
||||
- **Secure:** All inputs sanitized
|
||||
- **Reliable:** Graceful error handling
|
||||
- **Maintainable:** Clean, modular code
|
||||
- **Documented:** Comprehensive guides
|
||||
- **Tested:** Sample payloads provided
|
||||
- **Consistent:** Matches existing parser style
|
||||
|
||||
The parser is ready for immediate use!
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date:** 2026-01-22
|
||||
**Version:** 2.1.0
|
||||
**Author:** GitHub Copilot CLI
|
||||
+187
-359
@@ -1,10 +1,10 @@
|
||||
# Webhook Parser Development Guide
|
||||
|
||||
This guide explains how to create custom webhook parsers for the Webhook Email Forwarder system.
|
||||
This guide explains how to create custom webhook parsers for the Webhook Email Forwarder.
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Parsers allow you to format webhook data from specific services (like GitHub, Stripe, GitLab, etc.) into beautifully formatted HTML emails, rather than receiving raw webhook data.
|
||||
Parsers format webhook data from specific services (GitHub, Grafana, Stripe, GitLab, etc.) into readable HTML emails instead of the raw request dump.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -23,14 +23,13 @@ https://your-domain.com/{email}/{parser-name}
|
||||
|
||||
### How It Works
|
||||
|
||||
1. User configures webhook URL with their email and parser name
|
||||
2. Service sends webhook to your URL
|
||||
3. System looks for `parsers/{parser-name}.php` and a `{parser-name}Parse()` function inside it
|
||||
4. Parser function processes the webhook data
|
||||
5. Formatted email is sent to the user
|
||||
6. The caller gets a JSON acknowledgement that includes the parser name and the email subject
|
||||
1. User configures the webhook URL with their email and parser name
|
||||
2. Service sends a webhook to that URL
|
||||
3. The server looks the parser name up in the `parsers` map in `main.go`
|
||||
4. The parser turns the request into an email body and subject
|
||||
5. The email is sent and the caller gets a JSON acknowledgement naming the parser and subject
|
||||
|
||||
If no parser is specified, the file doesn't exist, or the parser returns `false`, the system falls back to the default raw webhook format.
|
||||
If no parser is named, the name is unknown, or the parser returns `ok == false`, the default raw-request email is used instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,210 +37,173 @@ If no parser is specified, the file doesn't exist, or the parser returns `false`
|
||||
|
||||
### File and Function
|
||||
|
||||
Create `parsers/{servicename}.php`. The file is loaded on demand with `require_once`, and `parsers/helpers.php` is already loaded before it, so `emailShell()`, `esc()`, and `renderDataAsTable()` are available.
|
||||
Create `parser_{servicename}.go` in the package root with one function of this type:
|
||||
|
||||
Every parser must follow this exact signature:
|
||||
```go
|
||||
// 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)
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* {Service} Webhook Parser
|
||||
*
|
||||
* @param string $rawBody The raw request body
|
||||
* @param array $headers The request headers (associative array)
|
||||
* @param array $metadata Request metadata
|
||||
* @return array|false Array with 'html' and 'subject' keys, or false on failure
|
||||
*/
|
||||
function {servicename}Parse($rawBody, $headers, $metadata) {
|
||||
// Your parser logic here
|
||||
Then register it in `main.go`:
|
||||
|
||||
```go
|
||||
var parsers = map[string]Parser{
|
||||
"github": githubParse,
|
||||
"grafana": grafanaParse,
|
||||
"json": jsonParse,
|
||||
"wxinteract": wxinteractParse,
|
||||
"servicename": servicenameParse, // <- add yours
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters Explained
|
||||
### Parameters
|
||||
|
||||
#### `$rawBody` (string)
|
||||
The complete raw body of the webhook request as received from the service.
|
||||
#### `raw []byte`
|
||||
The complete request body as received.
|
||||
|
||||
```php
|
||||
// For JSON webhooks:
|
||||
$payload = json_decode($rawBody, true);
|
||||
|
||||
// For XML webhooks:
|
||||
$xml = simplexml_load_string($rawBody);
|
||||
|
||||
// For form data:
|
||||
parse_str($rawBody, $formData);
|
||||
```go
|
||||
v, err := decodeJSON(raw) // ordered JSON: *OMap, []any, string, json.Number, bool, nil
|
||||
payload := asObject(v) // nil if the body is not an object
|
||||
```
|
||||
|
||||
#### `$headers` (array)
|
||||
Associative array of all HTTP headers. Keys are header names, values are header values.
|
||||
`decodeJSON` keeps object keys in the order the sender wrote them, so tables read naturally. Use it rather than `encoding/json` into a `map`, which sorts keys.
|
||||
|
||||
**Header names arrive in whatever case the client and proxy used.** Over HTTP/2 they are all lowercase. Normalise before looking anything up:
|
||||
#### `headers http.Header`
|
||||
All request headers, including `Host`. Lookups through `headers.Get` are case-insensitive, which covers the lowercase names HTTP/2 delivers:
|
||||
|
||||
```php
|
||||
$headers = array_change_key_case($headers, CASE_LOWER);
|
||||
$eventType = $headers['x-event-type'] ?? 'unknown';
|
||||
$signature = $headers['x-hub-signature'] ?? null;
|
||||
```go
|
||||
event := headers.Get("X-GitHub-Event")
|
||||
```
|
||||
|
||||
#### `$metadata` (array)
|
||||
Additional request information:
|
||||
|
||||
```php
|
||||
[
|
||||
'date' => '2026-01-22 15:30:45', // When webhook was received (Y-m-d H:i:s)
|
||||
'ip' => '203.0.113.7', // Client IP (the address nginx forwards, not the proxy)
|
||||
'method' => 'POST', // HTTP method
|
||||
'contentType' => 'application/json' // Content-Type header
|
||||
]
|
||||
#### `meta Meta`
|
||||
```go
|
||||
type Meta struct {
|
||||
Date string // "2026-01-22 15:30:45"
|
||||
IP string // the client address nginx forwards, not the proxy
|
||||
Method string // "POST"
|
||||
ContentType string // "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
### Return Value
|
||||
|
||||
**Success:** Return an array with two keys:
|
||||
|
||||
```php
|
||||
return [
|
||||
'html' => $htmlEmailBody, // Complete HTML email (string)
|
||||
'subject' => $emailSubject // Email subject line (string)
|
||||
];
|
||||
```
|
||||
|
||||
**Failure:** Return `false` to fall back to default formatting:
|
||||
|
||||
```php
|
||||
if (!$payload || !isset($payload['required_field'])) {
|
||||
return false; // Will use default webhook email format
|
||||
}
|
||||
```
|
||||
- **Success:** the complete HTML document (from `emailShell`), the subject line, and `true`
|
||||
- **Failure:** `"", "", false` to fall back to the default email
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Building the Email
|
||||
|
||||
You do not write a full HTML document. Build the **body** out of the shared building blocks, then hand it to `emailShell()`, which adds the stylesheet, the gradient header, and the footer. Every parser looks the same this way and design changes happen in one place.
|
||||
Never write a full HTML document by hand. Compose the body from the helpers in `render.go` and wrap it with `emailShell`, which adds the stylesheet, gradient header, and footer. Every parser looks the same this way and design changes happen in one place.
|
||||
|
||||
```php
|
||||
$html = emailShell($title, $timestamp, $body, $footerSentence);
|
||||
```go
|
||||
html := emailShell(title, stamp, body, footerSentence)
|
||||
```
|
||||
|
||||
| Argument | What it is |
|
||||
|----------|------------|
|
||||
| `$title` | Header text, already HTML-safe. Start with an emoji. |
|
||||
| `$timestamp` | Usually `$metadata['date']`, escaped. Shown as a pill under the title. |
|
||||
| `$body` | Your sections (see below). |
|
||||
| `$footerSentence` | Optional. One line, e.g. "This Stripe webhook was automatically forwarded to your email address." |
|
||||
| `title` | Header text, already HTML-safe. Start with an emoji. |
|
||||
| `stamp` | Usually `h(meta.Date)`. Shown as a pill under the title. |
|
||||
| `body` | Your sections. |
|
||||
| `footerSentence` | One line, e.g. "This Stripe webhook was automatically forwarded to your email address." Empty string for the generic default. |
|
||||
|
||||
### Building Blocks
|
||||
|
||||
**Section** with a title:
|
||||
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">💳 Payment Details</div>
|
||||
...
|
||||
</div>
|
||||
```go
|
||||
section(title, innerHTML string) string // <div class="section"> with a title
|
||||
table(rows ...string) string // a real <table class="metadata">
|
||||
row(label, valueHTML string) string // one label/value <tr>
|
||||
link(url string) string // escaped <a href>
|
||||
kvTable(m *OMap) string // every key/value of an object as one table
|
||||
renderDataAsTable(v any, depth int) string // arbitrary nested JSON as tables (generic fallback)
|
||||
```
|
||||
|
||||
**Metadata table.** Always a real `<table>`; that is what keeps the value column full-width in every mail client:
|
||||
Example:
|
||||
|
||||
```html
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Amount</td>
|
||||
<td class="metadata-value">$amountSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
```go
|
||||
body := section("💳 Payment Details", table(
|
||||
row("Event", h(event)),
|
||||
row("Amount", h(amount)+" "+h(currency)),
|
||||
row("Customer", h(customer)),
|
||||
)) + section("📝 Note", `<div class="message-box">`+esc(note)+`</div>`)
|
||||
```
|
||||
|
||||
**Other classes you can use:**
|
||||
**Other classes you can use inside a section:**
|
||||
|
||||
| Class | Use for |
|
||||
|-------|---------|
|
||||
| `.subsection-title` | A bold sub-heading inside a section |
|
||||
| `.subsection-title` | A bold sub-heading |
|
||||
| `.data-box` | A `<pre>` for raw JSON, headers, or code (dark block) |
|
||||
| `.message-box` | Quoted free text such as an SMS body |
|
||||
| `.error-box` | Error details |
|
||||
| `.array-item` / `.array-item-title` | A card for one item of a list, e.g. one alert or one commit |
|
||||
| `.array-item` / `.array-item-title` | A card for one item of a list (an alert, a commit) |
|
||||
| `.path-title` | A breadcrumb pill above a nested table, e.g. `order->items->[0]` |
|
||||
| `.commit-item` with `.sha`, `.who`, `.msg` | The GitHub commit card layout, reusable for any "id + author + message" list |
|
||||
| `.commit-item` with `.sha`, `.who`, `.msg` | The GitHub commit card, reusable for any id + author + message list |
|
||||
|
||||
**Dump an arbitrary array as tables** with the helper, which handles nesting for you:
|
||||
### Reading values
|
||||
|
||||
```php
|
||||
$body .= renderDataAsTable($payload['details']);
|
||||
```go
|
||||
str(v) // scalar as text ("" for null, "true"/"false" for bools)
|
||||
strOr(v, "N/A") // PHP's `?? 'N/A'`: only missing/null takes the default
|
||||
get(payload, "repository", "full_name") // nested lookup, nil if any step is missing
|
||||
asObject(v) // *OMap or nil
|
||||
isContainer(v) // object or array?
|
||||
num(v) // float64 for a json.Number, else 0
|
||||
```
|
||||
|
||||
### Escaping
|
||||
|
||||
Two helpers, use the right one:
|
||||
|
||||
- `htmlspecialchars($s, ENT_QUOTES, 'UTF-8')` for anything that goes into an attribute, a subject line, or a single-line value.
|
||||
- `esc($s)` for free text that may contain line breaks (commit messages, descriptions, SMS bodies). It escapes **and** converts newlines to `<br>`.
|
||||
- `h(s)` for anything that goes into an attribute or a single-line value.
|
||||
- `esc(s)` for free text that may contain line breaks (commit messages, descriptions, SMS bodies). It escapes **and** converts newlines to `<br>`.
|
||||
|
||||
Subjects are plain text, never escaped.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Example: Simple Parser
|
||||
|
||||
A minimal `parsers/simplepay.php` for a fictional "SimplePay" service:
|
||||
`parser_simplepay.go` for a fictional "SimplePay" service:
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* SimplePay Webhook Parser
|
||||
*/
|
||||
function simplepayParse($rawBody, $headers, $metadata) {
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!is_array($payload)) {
|
||||
return false; // Invalid JSON, use default format
|
||||
}
|
||||
```go
|
||||
package main
|
||||
|
||||
// Extract data with fallbacks
|
||||
$eventType = $payload['event'] ?? 'Unknown';
|
||||
$amount = $payload['amount'] ?? 'N/A';
|
||||
$currency = $payload['currency'] ?? 'USD';
|
||||
$customer = $payload['customer_email'] ?? 'Unknown';
|
||||
$note = $payload['note'] ?? '';
|
||||
import "net/http"
|
||||
|
||||
// Sanitise for HTML (IMPORTANT!)
|
||||
$eventSafe = htmlspecialchars($eventType, ENT_QUOTES, 'UTF-8');
|
||||
$amountSafe = htmlspecialchars($amount, ENT_QUOTES, 'UTF-8');
|
||||
$currencySafe = htmlspecialchars($currency, ENT_QUOTES, 'UTF-8');
|
||||
$customerSafe = htmlspecialchars($customer, ENT_QUOTES, 'UTF-8');
|
||||
$noteSafe = esc($note); // may be multi-line
|
||||
$dateSafe = htmlspecialchars($metadata['date'], ENT_QUOTES, 'UTF-8');
|
||||
$ipSafe = htmlspecialchars($metadata['ip'], ENT_QUOTES, 'UTF-8');
|
||||
func simplepayParse(raw []byte, headers http.Header, meta Meta) (string, string, bool) {
|
||||
payload := asObject(mustDecode(raw))
|
||||
if payload == nil {
|
||||
return "", "", false // not a JSON object: use the default email
|
||||
}
|
||||
|
||||
$body = <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">💳 Payment Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr><td class="metadata-label">Event</td><td class="metadata-value">$eventSafe</td></tr>
|
||||
<tr><td class="metadata-label">Amount</td><td class="metadata-value">$amountSafe $currencySafe</td></tr>
|
||||
<tr><td class="metadata-label">Customer</td><td class="metadata-value">$customerSafe</td></tr>
|
||||
<tr><td class="metadata-label">IP Address</td><td class="metadata-value">$ipSafe</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
event := strOr(payload.Get("event"), "Unknown")
|
||||
amount := strOr(payload.Get("amount"), "N/A")
|
||||
currency := strOr(payload.Get("currency"), "USD")
|
||||
customer := strOr(payload.Get("customer_email"), "Unknown")
|
||||
note := strOr(payload.Get("note"), "")
|
||||
|
||||
if ($noteSafe !== '') {
|
||||
$body .= <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">📝 Note</div>
|
||||
<div class="message-box">$noteSafe</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
body := section("💳 Payment Details", table(
|
||||
row("Event", h(event)),
|
||||
row("Amount", h(amount)+" "+h(currency)),
|
||||
row("Customer", h(customer)),
|
||||
row("IP Address", h(meta.IP)),
|
||||
))
|
||||
if note != "" {
|
||||
body += section("📝 Note", `<div class="message-box">`+esc(note)+`</div>`)
|
||||
}
|
||||
|
||||
$html = emailShell("💳 SimplePay: $eventSafe", $dateSafe, $body,
|
||||
'This SimplePay webhook was automatically forwarded to your email address.');
|
||||
html := emailShell("💳 SimplePay: "+h(event), h(meta.Date), body,
|
||||
"This SimplePay webhook was automatically forwarded to your email address.")
|
||||
return html, "💳 SimplePay " + event + " - " + amount + " " + currency, true
|
||||
}
|
||||
|
||||
$subject = "💳 SimplePay $eventType - $amount $currency";
|
||||
|
||||
return [
|
||||
'html' => $html,
|
||||
'subject' => $subject
|
||||
];
|
||||
func mustDecode(raw []byte) any {
|
||||
v, err := decodeJSON(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
@@ -251,16 +213,15 @@ HTML;
|
||||
|
||||
The shell owns colours, fonts, and spacing. Parsers only choose structure and words.
|
||||
|
||||
1. **Sections**: one `.section` per logical group, each with a `.section-title`
|
||||
2. **Tables**: `table.metadata` for label/value pairs, never bare divs
|
||||
1. **Sections**: one `section` per logical group
|
||||
2. **Tables**: `table(row(...))` for label/value pairs, never hand-written divs
|
||||
3. **Lists**: one `.array-item` card per item (alerts, commits, line items)
|
||||
4. **Raw data**: `.data-box` for anything the reader might want to copy
|
||||
5. **Free text**: `.message-box`, escaped with `esc()`
|
||||
6. **Links**: plain `<a href>`; the shell styles them
|
||||
5. **Free text**: `.message-box`, escaped with `esc`
|
||||
6. **Links**: `link(url)`
|
||||
|
||||
### Emojis
|
||||
|
||||
Use relevant emojis to make emails scannable:
|
||||
- 💳 Payment events
|
||||
- 🚀 Deployments/releases
|
||||
- 📤 Push/upload events
|
||||
@@ -275,278 +236,145 @@ Put the same emoji at the start of the subject line so inbox filters can key on
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### 1. Always Sanitise HTML Output
|
||||
### 1. Escape everything dynamic
|
||||
|
||||
**CRITICAL:** Escape ALL dynamic content:
|
||||
|
||||
```php
|
||||
// ❌ DANGEROUS - XSS vulnerability
|
||||
$html = "<div>{$payload['user_input']}</div>";
|
||||
```go
|
||||
// ❌ DANGEROUS - XSS in the email
|
||||
body += "<div>" + str(payload.Get("user_input")) + "</div>"
|
||||
|
||||
// ✅ SAFE (single line)
|
||||
$safe = htmlspecialchars($payload['user_input'], ENT_QUOTES, 'UTF-8');
|
||||
body += "<div>" + h(str(payload.Get("user_input"))) + "</div>"
|
||||
|
||||
// ✅ SAFE (multi-line free text)
|
||||
$safe = esc($payload['user_message']);
|
||||
body += `<div class="message-box">` + esc(str(payload.Get("message"))) + `</div>`
|
||||
```
|
||||
|
||||
### 2. Validate Input
|
||||
### 2. Validate input
|
||||
|
||||
Check that required data exists before using it:
|
||||
|
||||
```php
|
||||
if (!is_array($payload) || !isset($payload['event_type'])) {
|
||||
return false; // Fail safely
|
||||
```go
|
||||
if payload == nil || payload.Get("event_type") == nil {
|
||||
return "", "", false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle Errors Gracefully
|
||||
### 3. Verify signatures (optional)
|
||||
|
||||
Return `false` if parsing fails. The system falls back to the default format and the sender still gets a `200`.
|
||||
|
||||
### 4. Verify Signatures (Optional)
|
||||
|
||||
If the service provides webhook signatures, validate them:
|
||||
|
||||
```php
|
||||
$headers = array_change_key_case($headers, CASE_LOWER);
|
||||
$signature = $headers['x-service-signature'] ?? null;
|
||||
if ($signature) {
|
||||
$expectedSignature = hash_hmac('sha256', $rawBody, $secret);
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
return false; // Invalid signature
|
||||
}
|
||||
```go
|
||||
sig := headers.Get("X-Service-Signature")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(raw)
|
||||
if sig == "" || !hmac.Equal([]byte(sig), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||
return "", "", false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Parser Examples by Service Type
|
||||
|
||||
### JSON API Webhooks (Most Common)
|
||||
|
||||
```php
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!is_array($payload)) return false;
|
||||
```
|
||||
|
||||
### XML Webhooks
|
||||
|
||||
```php
|
||||
$xml = simplexml_load_string($rawBody);
|
||||
if (!$xml) return false;
|
||||
$event = (string)$xml->event;
|
||||
```
|
||||
|
||||
### Form Data Webhooks
|
||||
|
||||
```php
|
||||
parse_str($rawBody, $formData);
|
||||
if (empty($formData)) return false;
|
||||
$event = $formData['event'] ?? 'Unknown';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Your Parser
|
||||
|
||||
### 1. Lint it
|
||||
### 1. Unit test
|
||||
|
||||
`main_test.go` has a `capture(t)` helper that swaps the mailer for an in-memory one and a `do(...)` helper that drives the handler. Add a test in the same style:
|
||||
|
||||
```go
|
||||
func TestSimplePay(t *testing.T) {
|
||||
c := capture(t)
|
||||
_, out := do(t, "POST", "/t%40e.com/simplepay", `{"event":"paid","amount":"9.99"}`, nil)
|
||||
if out["subject"] != "💳 SimplePay paid - 9.99 USD" || !strings.Contains(c.html, "9.99") {
|
||||
t.Fatalf("%v", out["subject"])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
php -l parsers/yourservice.php
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### 2. Run locally without sending mail
|
||||
|
||||
PHP's built-in server plus a `sendmail_path` override captures every email as a file instead of sending it:
|
||||
Point the server at any SMTP sink (the repo's tests don't need one, but for eyeballing the HTML):
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/mail
|
||||
php -S 127.0.0.1:8099 -d sendmail_path="sh -c 'cat > /tmp/mail/\$\$.eml'" index.php
|
||||
```
|
||||
|
||||
Then post to it:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8099/test%40example.com/yourservice \
|
||||
LISTEN=127.0.0.1:8098 SMTP_ADDR=127.0.0.1:2525 go run .
|
||||
curl -X POST http://127.0.0.1:8098/test%40example.com/simplepay \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Service-Event: payment.completed" \
|
||||
-d '{"amount": 99.99, "currency": "USD"}'
|
||||
-d '{"event":"paid","amount":"9.99","currency":"USD"}'
|
||||
```
|
||||
|
||||
The JSON response tells you which parser ran and what subject was used. Open the `.eml` file in `/tmp/mail` (strip the headers above the first blank line) in a browser to check the layout.
|
||||
The JSON response tells you which parser ran and what subject was used.
|
||||
|
||||
### 3. Test the Failure Case
|
||||
### 3. Test the failure case
|
||||
|
||||
Send invalid data to ensure your parser returns `false` correctly:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8099/test%40example.com/yourservice \
|
||||
-H "Content-Type: application/json" \
|
||||
-d 'invalid json{'
|
||||
```
|
||||
|
||||
You should get the default webhook format email.
|
||||
Send invalid data and confirm the response shows the default subject (`‼️ Webhook Request Received`).
|
||||
|
||||
### 4. Test a lowercase header
|
||||
|
||||
Real deliveries over HTTP/2 arrive with lowercase header names. Send one that way and make sure your parser still identifies the event:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8099/test%40example.com/yourservice \
|
||||
-H "x-service-event: payment.completed" -d '{}'
|
||||
```
|
||||
|
||||
### 5. Check the Email
|
||||
|
||||
- ✅ Subject line is descriptive and includes an emoji
|
||||
- ✅ Value cells fill the table width
|
||||
- ✅ Multi-line text keeps its line breaks
|
||||
- ✅ No raw HTML from the payload is rendered
|
||||
- ✅ Links (if any) are clickable
|
||||
Real deliveries over HTTP/2 arrive with lowercase header names. `headers.Get` handles it, but if you read `headers[...]` directly you will miss them.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Parser Checklist
|
||||
|
||||
Before submitting a parser, verify:
|
||||
|
||||
- [ ] File is `parsers/{servicename}.php` and the function is `{servicename}Parse` (lowercase)
|
||||
- [ ] Function signature matches exactly: `($rawBody, $headers, $metadata)`
|
||||
- [ ] Returns `array` with 'html' and 'subject' keys on success
|
||||
- [ ] File is `parser_{servicename}.go` and the function matches the `Parser` type
|
||||
- [ ] Registered in the `parsers` map in `main.go`
|
||||
- [ ] Returns `false` on failure/invalid data
|
||||
- [ ] Header lookups are case-insensitive
|
||||
- [ ] All dynamic content is escaped (`htmlspecialchars()` or `esc()`)
|
||||
- [ ] Body uses the shared classes and is wrapped with `emailShell()`
|
||||
- [ ] Metadata is rendered with `table.metadata`, not divs
|
||||
- [ ] Header lookups use `headers.Get`
|
||||
- [ ] All dynamic content goes through `h` or `esc`
|
||||
- [ ] Body uses `section` / `table` / `row` and is wrapped with `emailShell`
|
||||
- [ ] Subject line includes an emoji and key information
|
||||
- [ ] Tested with valid, invalid, and lowercase-header requests
|
||||
- [ ] Documented any special requirements (API keys, signature validation, etc.)
|
||||
- [ ] Unit test added and `go test ./...` passes
|
||||
- [ ] README section added for the parser
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing Parsers
|
||||
|
||||
To contribute a parser:
|
||||
|
||||
1. **Add `parsers/{servicename}.php`**
|
||||
2. **Test Thoroughly** with real webhook data
|
||||
3. **Document Usage** - Add a section to README.md with:
|
||||
- Service name
|
||||
- URL format
|
||||
- Supported events
|
||||
- Example webhook configuration
|
||||
4. **Submit Pull Request** with:
|
||||
- Parser file
|
||||
- Documentation updates
|
||||
- Example webhook payload (in PR description)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips & Tricks
|
||||
|
||||
### Handle Multiple Event Types
|
||||
|
||||
Use a `switch` statement for different event types:
|
||||
|
||||
```php
|
||||
switch ($eventType) {
|
||||
case 'payment.completed':
|
||||
$icon = '💳';
|
||||
break;
|
||||
case 'refund.created':
|
||||
$icon = '↩️';
|
||||
break;
|
||||
default:
|
||||
$icon = '🔔';
|
||||
}
|
||||
```
|
||||
|
||||
### Pretty Print JSON
|
||||
|
||||
For a debugging section:
|
||||
|
||||
```php
|
||||
$jsonSafe = htmlspecialchars(
|
||||
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
ENT_QUOTES,
|
||||
'UTF-8'
|
||||
);
|
||||
$body .= '<div class="section"><div class="section-title">📦 Raw Payload</div><pre class="data-box">' . $jsonSafe . '</pre></div>';
|
||||
```
|
||||
|
||||
### Truncate Long Content
|
||||
|
||||
```php
|
||||
$message = mb_substr($payload['message'], 0, 500);
|
||||
if (mb_strlen($payload['message']) > 500) {
|
||||
$message .= '…';
|
||||
}
|
||||
```
|
||||
|
||||
### Include Links
|
||||
|
||||
```php
|
||||
$url = htmlspecialchars($payload['url'], ENT_QUOTES, 'UTF-8');
|
||||
$body .= "<a href=\"$url\">$url</a>";
|
||||
```
|
||||
1. Add `parser_{servicename}.go` and register it
|
||||
2. Add a test
|
||||
3. Document usage in README.md (service name, URL format, supported events)
|
||||
4. Submit a pull request with an example payload in the description
|
||||
|
||||
---
|
||||
|
||||
## 📚 Reference
|
||||
|
||||
### Existing Parsers
|
||||
### Existing parsers
|
||||
|
||||
Study these in `parsers/`:
|
||||
- `github.php` - Multiple event types, commit cards, case-insensitive headers
|
||||
- `grafana.php` - Nested alert cards with label and annotation tables
|
||||
- `json.php` - Recursive rendering with path breadcrumbs
|
||||
- `wxinteract.php` - Many event types split into helper functions
|
||||
- `helpers.php` - `emailShell()`, `esc()`, `renderDataAsTable()`
|
||||
- `parser_github.go` - Multiple event types, commit cards, generic fallback
|
||||
- `parser_grafana.go` - Nested alert cards with label and annotation tables
|
||||
- `parser_json.go` - Recursive rendering with path breadcrumbs
|
||||
- `parser_wxinteract.go` - Many event types in one switch
|
||||
- `render.go` - `emailShell`, `esc`, `h`, `decodeJSON`, `renderDataAsTable`, table helpers
|
||||
- `main.go` - Routing, JSON response, default email, parser registry
|
||||
- `mail.go` - SMTP delivery
|
||||
|
||||
### Useful PHP Functions
|
||||
### Configuration
|
||||
|
||||
- `json_decode($string, true)` - Parse JSON to array
|
||||
- `simplexml_load_string($string)` - Parse XML
|
||||
- `parse_str($string, $output)` - Parse URL-encoded data
|
||||
- `array_change_key_case($headers, CASE_LOWER)` - Normalise header names
|
||||
- `htmlspecialchars($string, ENT_QUOTES, 'UTF-8')` - Escape HTML
|
||||
- `esc($string)` - Escape HTML and keep line breaks
|
||||
- `hash_hmac()` / `hash_equals()` - Verify signatures
|
||||
| Variable | Default | Meaning |
|
||||
|----------|---------|---------|
|
||||
| `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 |
|
||||
| `PUBLIC_HOST` | `webhooks.dixon.cx` | Host shown in email footers |
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: Can I use external libraries?**
|
||||
A: No, parsers should use only PHP built-in functions to avoid dependencies.
|
||||
A: The project has no dependencies beyond the standard library. Keep it that way unless there is a very good reason.
|
||||
|
||||
**Q: Can I make API calls in a parser?**
|
||||
A: Not recommended - parsers should be fast and not depend on external services.
|
||||
A: Not recommended. Parsers should be fast and not depend on external services.
|
||||
|
||||
**Q: What if my service sends different content types?**
|
||||
A: Check `$metadata['contentType']` and handle accordingly.
|
||||
|
||||
**Q: Can I store data in a database?**
|
||||
A: No, parsers should only format and return email content.
|
||||
|
||||
**Q: How do I handle webhook signatures?**
|
||||
A: Validate in your parser and return `false` if invalid.
|
||||
A: Check `meta.ContentType` and parse accordingly.
|
||||
|
||||
**Q: Can I create multiple parsers for one service?**
|
||||
A: Yes! Use names like `githubissues`, `githubpush`, etc. Each is its own file.
|
||||
A: Yes. Register as many names as you like.
|
||||
|
||||
**Q: Can I change the colours or fonts?**
|
||||
A: Change them in `emailShell()` in `parsers/helpers.php` and every email picks them up.
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or help developing parsers:
|
||||
- GitHub Issues: https://github.com/andydixon/webhook/issues
|
||||
- Review existing parsers in `parsers/`
|
||||
- Check this guide for examples
|
||||
A: Change `shellSrc` in `render.go` and every email picks it up.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Webhook Email Forwarder
|
||||
|
||||
A simple, elegant PHP solution for debugging webhooks by forwarding them directly to your email.
|
||||
A single-binary Go service for debugging webhooks by forwarding them directly to your email.
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
@@ -176,7 +176,7 @@ See also the [Webex Interact Guide](WEBEX_INTERACT_GUIDE.md).
|
||||
|
||||
### Creating Your Own Parser
|
||||
|
||||
A parser is one file in `parsers/` exposing `{name}Parse($rawBody, $headers, $metadata)` that returns `['html' => ..., 'subject' => ...]` or `false`. Build the body from `.section`, `table.metadata`, and `.data-box` blocks, escape free text with `esc()`, and wrap it with `emailShell()` from `parsers/helpers.php` so it picks up the shared design. See the [Parser Development Guide](PARSER_PROMPT.md) for details.
|
||||
A parser is one Go function of type `Parser` in `parser_{name}.go`, registered in the `parsers` map in `main.go`. Build the body with `section`, `table`, and `row`, escape free text with `esc`, and wrap it with `emailShell` so it picks up the shared design. See the [Parser Development Guide](PARSER_PROMPT.md) for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -298,23 +298,41 @@ Hello! Your appointment is confirmed for tomorrow at 2 PM.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. Clone the repository:
|
||||
### Run the binary
|
||||
|
||||
```bash
|
||||
git clone https://github.com/andydixon/webhook.git
|
||||
cd webhook
|
||||
go build -o webhooks .
|
||||
LISTEN=127.0.0.1:8080 SMTP_ADDR=127.0.0.1:25 MAIL_FROM=no-reply@example.com ./webhooks
|
||||
```
|
||||
|
||||
2. Upload the files to your web server (ensure PHP is installed and configured)
|
||||
Put a reverse proxy (nginx, Caddy) in front for TLS. The service reads `X-Real-IP` / `X-Forwarded-For` from a private-address proxy to report the real client IP.
|
||||
|
||||
3. Ensure the web server has permission to send emails (PHP `mail()` function)
|
||||
### Run the container
|
||||
|
||||
4. Access the root URL to view the documentation page
|
||||
```bash
|
||||
docker build -t webhooks.dixon.cx:latest .
|
||||
docker run --rm -p 127.0.0.1:29593:8080 webhooks.dixon.cx:latest
|
||||
```
|
||||
|
||||
The image is a static binary on `distroless`, runs as non-root, listens on `:8080`, and relays mail to `172.17.0.1:25` (the Docker host's SMTP server) by default. `deploy/webhooks.dixon.cx.service` is the systemd unit used in production.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|----------|---------|---------|
|
||||
| `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 |
|
||||
| `PUBLIC_HOST` | `webhooks.dixon.cx` | 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`).
|
||||
|
||||
## 🔧 Requirements
|
||||
|
||||
- PHP 8.0 or higher
|
||||
- Web server (Apache, Nginx, etc.)
|
||||
- PHP `mail()` function enabled
|
||||
- Outbound SMTP/email capability
|
||||
- Go 1.24 or newer to build (no third-party dependencies), or Docker
|
||||
- An SMTP server that accepts mail from the service's address
|
||||
|
||||
## 📧 What You Receive
|
||||
|
||||
@@ -331,7 +349,7 @@ The standard (no parser) email contains:
|
||||
## 🔒 Security Features
|
||||
|
||||
- **Email Validation**: Only valid email addresses are accepted
|
||||
- **XSS Protection**: All output is escaped with `htmlspecialchars()`; free-text values go through `esc()`, which also preserves line breaks
|
||||
- **XSS Protection**: All output is HTML-escaped; free-text values go through `esc`, which also preserves line breaks
|
||||
- **No Data Storage**: Webhook data is forwarded immediately and not stored
|
||||
- **Error Logging**: Failed email attempts are logged for monitoring
|
||||
|
||||
@@ -388,6 +406,12 @@ If the email address in the URL is invalid or missing, you get a `400 Bad Reques
|
||||
### Unknown Parser or Unparseable Payload
|
||||
The request still succeeds. The email uses the standard raw format and the response reports the parser name you asked for.
|
||||
|
||||
### Mail Delivery Failure
|
||||
If the SMTP relay rejects the message you get a `502` with `"error": "mail_failed"`, so the sending service will retry rather than believe the webhook was delivered.
|
||||
|
||||
### Body Too Large
|
||||
Bodies over 10 MB get a `413` with `"error": "body_too_large"`.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
@@ -408,6 +432,14 @@ Give a ⭐️ if this project helped you!
|
||||
|
||||
## 📜 Version History
|
||||
|
||||
- **3.0.0** - Go Rewrite
|
||||
- ♻️ The whole service is now a single Go binary with no dependencies; PHP, Apache, and `.htaccess` are gone
|
||||
- 🐳 Static `distroless` image, non-root, built-in health check
|
||||
- 📧 Mail goes over SMTP with a quoted-printable body and an RFC 2047 subject, so emoji subjects survive every relay
|
||||
- 🔁 A failed send returns `502 mail_failed` instead of a false `200`
|
||||
- 🧪 `go test ./...` covers routing, the JSON response, IP forwarding, and every parser
|
||||
- Email output and the JSON response are byte-for-byte compatible with 2.1.0, with JSON object keys kept in sender order
|
||||
|
||||
- **2.1.0** - JSON Response and Email Redesign
|
||||
- ✨ **NEW**: Structured JSON response for every request, including errors
|
||||
- ✨ **NEW**: Grafana / Prometheus Alertmanager parser (`/grafana`)
|
||||
@@ -432,4 +464,4 @@ Give a ⭐️ if this project helped you!
|
||||
|
||||
---
|
||||
|
||||
Built with PHP and ADHD medication 💊
|
||||
Built with Go and ADHD medication 💊
|
||||
|
||||
@@ -336,7 +336,7 @@ If you're receiving raw webhook data instead of formatted emails:
|
||||
|
||||
1. **Check URL:** Ensure `/wxinteract` is at the end of the URL
|
||||
2. **Check payload structure:** Parser expects `{"data": {"status": "...", ...}}`
|
||||
3. **Check PHP logs:** Look for any parser errors in your server logs
|
||||
3. **Check the container logs:** `docker logs webhooks.dixon.cx` shows mail failures and parser errors
|
||||
|
||||
### Missing Fields
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=webhooks.dixon.cx (docker container)
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=0
|
||||
ExecStartPre=-/usr/bin/docker rm -f webhooks.dixon.cx
|
||||
ExecStart=/usr/bin/docker run --rm --memory 256m --cpus 1.0 --pids-limit 128 --name webhooks.dixon.cx -p 127.0.0.1:29593:8080 webhooks.dixon.cx:latest
|
||||
ExecStop=/usr/bin/docker stop -t 30 webhooks.dixon.cx
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+10
-10
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Webhook → Email | <?php echo $_SERVER['HTTP_HOST']; ?></title>
|
||||
<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{
|
||||
@@ -137,7 +137,7 @@ footer a{color:var(--muted)}
|
||||
<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://<?php echo $_SERVER['HTTP_HOST']; ?>/<b id="out">you%40example.com</b></code>
|
||||
<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>
|
||||
@@ -154,7 +154,7 @@ footer a{color:var(--muted)}
|
||||
</div>
|
||||
|
||||
<h3>The fastest possible test</h3>
|
||||
<pre data-lang="bash">curl -X POST https://<?php echo $_SERVER['HTTP_HOST']; ?>/<span class="s" id="ex1">you%40example.com</span> \
|
||||
<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>
|
||||
@@ -164,14 +164,14 @@ footer a{color:var(--muted)}
|
||||
<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://<?php echo $_SERVER['HTTP_HOST']; ?>/<span class="s ex">you%40example.com</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://<?php echo $_SERVER['HTTP_HOST']; ?>/<span class="s ex">you%40example.com</span>?event=test&id=123"</pre>
|
||||
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://<?php echo $_SERVER['HTTP_HOST']; ?>/'</span> + <span class="k">encodeURIComponent</span>(<span class="s">'you@example.com'</span>), {
|
||||
<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 })
|
||||
@@ -181,7 +181,7 @@ curl "https://<?php echo $_SERVER['HTTP_HOST']; ?>/<span class="s ex">you%40exam
|
||||
<span class="k">from</span> urllib.parse <span class="k">import</span> quote
|
||||
|
||||
requests.post(
|
||||
<span class="s">f'https://<?php echo $_SERVER['HTTP_HOST']; ?>/{quote("you@example.com")}'</span>,
|
||||
<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>
|
||||
@@ -204,7 +204,7 @@ requests.post(
|
||||
<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://<?php echo $_SERVER['HTTP_HOST']; ?>/<span class="ex">you%40example.com</span>/github</code></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>
|
||||
@@ -266,8 +266,8 @@ requests.post(
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div>Built with PHP and ADHD medication. Not necessarily in that order.</div>
|
||||
<div>© 2025–2026 Andy Dixon · <a href="https://<?php echo $_SERVER['HTTP_HOST']; ?>/"><?php echo $_SERVER['HTTP_HOST']; ?></a></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>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -1,250 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Email Forwarder
|
||||
*
|
||||
* This script captures incoming webhook requests and forwards the complete request details
|
||||
* (headers, body, metadata) to an email address specified in the URL path.
|
||||
*
|
||||
* Usage: <?php echo $_SERVER['HTTP_HOST']; ?>/email%40domain.com
|
||||
*
|
||||
* @author Dixon
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
// Prevent any output buffering issues
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Retrieve the complete request URI from the server superglobal
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
|
||||
// Parse the URL to extract only the path component, excluding any query strings
|
||||
// This ensures that any GET parameters don't interfere with email extraction
|
||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||
|
||||
// Remove leading and trailing forward slashes from the path
|
||||
// Example: '/email%40domain.com/' or '/email%40domain.com/github'
|
||||
$pathParts = explode('/', trim($path, '/'));
|
||||
|
||||
// Extract email and optional parser
|
||||
$emailEncoded = $pathParts[0] ?? '';
|
||||
$parserName = $pathParts[1] ?? null;
|
||||
|
||||
// Decode the URL-encoded email address (e.g., %40 becomes @)
|
||||
// Example: 'email%40domain.com' becomes 'email@domain.com'
|
||||
$email = urldecode($emailEncoded);
|
||||
|
||||
if(empty($email)) {
|
||||
include'docs.htm';
|
||||
die();
|
||||
}
|
||||
|
||||
// Validate the extracted email address using PHP's built-in filter
|
||||
// This ensures the email is in a valid format before proceeding
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
// Set HTTP response code to 400 Bad Request
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_email',
|
||||
'message' => 'Invalid email address provided in URL path.',
|
||||
'hint' => 'Encode @ as %40, e.g. /you%40example.com',
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Capture the current date and time when the webhook request was received
|
||||
// Format: YYYY-MM-DD HH:MM:SS (24-hour format)
|
||||
$dateReceived = date('Y-m-d H:i:s');
|
||||
|
||||
// Retrieve the IP address of the client making the request
|
||||
// Note: This may be a proxy IP if behind a load balancer or CDN
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
|
||||
// Behind nginx the peer is the Docker bridge; use the address nginx forwards.
|
||||
// Only trust the proxy headers when the peer itself is a private/loopback address.
|
||||
if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
$forwarded = $_SERVER['HTTP_X_REAL_IP'] ?? trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')[0]);
|
||||
if (filter_var($forwarded, FILTER_VALIDATE_IP)) {
|
||||
$ipAddress = $forwarded;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the HTTP method used for this request (GET, POST, PUT, DELETE, etc.)
|
||||
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'Unknown';
|
||||
|
||||
// Retrieve the Content-Type header sent by the client
|
||||
// Use null coalescing operator to provide a default if not set
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? 'N/A';
|
||||
|
||||
// Retrieve all HTTP headers sent with the request
|
||||
// getallheaders() returns an associative array of all headers
|
||||
$headersArray = getallheaders();
|
||||
|
||||
// Initialize an empty string to store formatted headers
|
||||
$headersText = '';
|
||||
|
||||
// Iterate through each header and format it as "Key: Value"
|
||||
foreach ($headersArray as $key => $value) {
|
||||
// Concatenate each header on a new line
|
||||
$headersText .= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') . ': ' .
|
||||
htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . "\n";
|
||||
}
|
||||
|
||||
// Read the raw request body from the input stream
|
||||
// php://input allows access to the raw POST data regardless of Content-Type
|
||||
$rawBody = file_get_contents('php://input');
|
||||
|
||||
// Sanitise the raw body for HTML output to prevent XSS attacks
|
||||
$rawBodySafe = htmlspecialchars($rawBody, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Compile all PHP superglobal variables into an array for debugging purposes
|
||||
// This provides visibility of all data received with the request
|
||||
$variables = [
|
||||
'GET Variables' => $_GET,
|
||||
'POST Variables' => $_POST,
|
||||
'Raw Request Body' => $_REQUEST,
|
||||
'Uploaded Files' => $_FILES,
|
||||
];
|
||||
|
||||
// Convert the variables array to a human-readable string format
|
||||
// The second parameter (true) makes print_r return the output instead of printing it
|
||||
$variablesText = json_encode($variables, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// Sanitise the variables text for HTML output
|
||||
$variablesTextSafe = htmlspecialchars($variablesText, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Sanitise individual metadata fields for HTML output
|
||||
$dateReceivedSafe = htmlspecialchars($dateReceived, ENT_QUOTES, 'UTF-8');
|
||||
$ipAddressSafe = htmlspecialchars($ipAddress, ENT_QUOTES, 'UTF-8');
|
||||
$requestMethodSafe = htmlspecialchars($requestMethod, ENT_QUOTES, 'UTF-8');
|
||||
$contentTypeSafe = htmlspecialchars($contentType, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Load parser helpers
|
||||
require_once __DIR__ . '/parsers/helpers.php';
|
||||
|
||||
// Check if a parser is specified and load it
|
||||
$html = '';
|
||||
$subject = '';
|
||||
if ($parserName) {
|
||||
$parserFile = __DIR__ . '/parsers/' . $parserName . '.php';
|
||||
if (file_exists($parserFile)) {
|
||||
require_once $parserFile;
|
||||
$parserFunction = $parserName . 'Parse';
|
||||
|
||||
if (function_exists($parserFunction)) {
|
||||
$parsed = $parserFunction($rawBody, $headersArray, [
|
||||
'date' => $dateReceived,
|
||||
'ip' => $ipAddress,
|
||||
'method' => $requestMethod,
|
||||
'contentType' => $contentType
|
||||
]);
|
||||
|
||||
if ($parsed !== false) {
|
||||
$html = $parsed['html'];
|
||||
$subject = $parsed['subject'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no parser or parser failed, use default format
|
||||
if (empty($html)) {
|
||||
// Construct the HTML email body using a heredoc string for better readability
|
||||
// This creates a styled HTML email with all webhook details
|
||||
$emailTitle = "⚡ Webhook Request Received";
|
||||
$emailStamp = "$dateReceivedSafe";
|
||||
$body = <<<HTML
|
||||
|
||||
<!-- Request Information Section -->
|
||||
<div class="section">
|
||||
<div class="section-title">📋 Request Information</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">IP Address</td>
|
||||
<td class="metadata-value">$ipAddressSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Method</td>
|
||||
<td class="metadata-value">$requestMethodSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Content-Type</td>
|
||||
<td class="metadata-value">$contentTypeSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Request Headers Section -->
|
||||
<div class="section">
|
||||
<div class="section-title">📨 Request Headers</div>
|
||||
<pre class="data-box">$headersText</pre>
|
||||
</div>
|
||||
|
||||
<!-- Request Body Section -->
|
||||
<div class="section">
|
||||
<div class="section-title">📦 Request Body</div>
|
||||
<pre class="data-box">$rawBodySafe</pre>
|
||||
</div>
|
||||
|
||||
<!-- PHP Variables Section -->
|
||||
<div class="section">
|
||||
<div class="section-title">🔧 Parsed Variables</div>
|
||||
<pre class="data-box">$variablesTextSafe</pre>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
HTML;
|
||||
$html = emailShell($emailTitle, $emailStamp, $body, "This webhook was automatically forwarded to your email address.");
|
||||
|
||||
// Construct the email subject line with an attention-grabbing emoji and timestamp
|
||||
$subject = "‼️ Webhook Request Received - $dateReceived";
|
||||
}
|
||||
|
||||
// Build email headers to ensure proper HTML rendering and sender information
|
||||
// MIME-Version declares email format capabilities
|
||||
$emailHeaders = "MIME-Version: 1.0\r\n";
|
||||
// Content-type specifies HTML email with UTF-8 character encoding
|
||||
$emailHeaders .= "Content-type: text/html; charset=UTF-8\r\n";
|
||||
// Keep both the visible sender and SMTP envelope sender on the public domain.
|
||||
$envelopeSender = 'no-reply@dixon.cx';
|
||||
$emailHeaders .= "From: Webhook Call <$envelopeSender>\r\n";
|
||||
|
||||
// Send the email using PHP's built-in mail function
|
||||
// The -f option prevents sendmail from deriving www-data@<container-hostname>.
|
||||
$mailSent = mail($email, $subject, $html, $emailHeaders, '-f' . $envelopeSender);
|
||||
|
||||
// Check if the email was sent successfully
|
||||
if (!$mailSent) {
|
||||
// Log error or handle failure (note: mail() return value is unreliable on some systems)
|
||||
error_log("Failed to send webhook email to: $email");
|
||||
}
|
||||
|
||||
// JSON acknowledgement back to the webhook sender
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
// Decode JSON bodies so the caller sees what we understood, not a raw string
|
||||
$bodyJson = ($rawBody !== '' && str_contains($contentType, 'json')) ? json_decode($rawBody, true) : null;
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'message' => "Webhook received and forwarded to $email",
|
||||
'forwarded_to' => $email,
|
||||
'parser' => $parserName ?: 'default',
|
||||
'subject' => $subject,
|
||||
'received_at' => date('c'),
|
||||
'request' => [
|
||||
'method' => $requestMethod,
|
||||
'content_type' => $contentType,
|
||||
'ip' => $ipAddress,
|
||||
'headers' => count($headersArray),
|
||||
'body_bytes' => strlen($rawBody),
|
||||
],
|
||||
'data' => [
|
||||
'query' => (object) $_GET,
|
||||
'form' => (object) $_POST,
|
||||
'json' => $bodyJson,
|
||||
'files' => array_keys($_FILES),
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
smtpAddr = envOr("SMTP_ADDR", "127.0.0.1:25")
|
||||
mailFrom = envOr("MAIL_FROM", "no-reply@dixon.cx")
|
||||
)
|
||||
|
||||
// sendMail is a variable so tests can capture messages instead of sending them.
|
||||
var sendMail = smtpSend
|
||||
|
||||
func smtpSend(to, subject, htmlBody string) error {
|
||||
c, err := smtp.Dial(smtpAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Mail(mailFrom); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(buildMessage(to, subject, htmlBody)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
func buildMessage(to, subject, htmlBody string) []byte {
|
||||
host, _ := os.Hostname()
|
||||
var b []byte
|
||||
b = fmt.Appendf(b, "From: Webhook Call <%s>\r\n", mailFrom)
|
||||
b = fmt.Appendf(b, "To: <%s>\r\n", to)
|
||||
b = fmt.Appendf(b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
b = fmt.Appendf(b, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
|
||||
b = fmt.Appendf(b, "Message-ID: <%d.%d@%s>\r\n", time.Now().UnixNano(), os.Getpid(), host)
|
||||
b = append(b, "MIME-Version: 1.0\r\n"...)
|
||||
b = append(b, "Content-Type: text/html; charset=UTF-8\r\n"...)
|
||||
b = append(b, "Content-Transfer-Encoding: quoted-printable\r\n\r\n"...)
|
||||
qp := quotedprintable.NewWriter(sliceWriter{&b})
|
||||
_, _ = qp.Write([]byte(htmlBody))
|
||||
_ = qp.Close()
|
||||
return b
|
||||
}
|
||||
|
||||
type sliceWriter struct{ b *[]byte }
|
||||
|
||||
func (s sliceWriter) Write(p []byte) (int, error) {
|
||||
*s.b = append(*s.b, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// 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)
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type captured struct{ to, subject, html string }
|
||||
|
||||
func capture(t *testing.T) *captured {
|
||||
t.Helper()
|
||||
c := &captured{}
|
||||
old := sendMail
|
||||
sendMail = func(to, subject, html string) error { *c = captured{to, subject, html}; return nil }
|
||||
t.Cleanup(func() { sendMail = old })
|
||||
return c
|
||||
}
|
||||
|
||||
func do(t *testing.T, method, path, body string, hdr map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.RemoteAddr = "172.17.0.1:5555"
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handle(rec, req)
|
||||
var out map[string]any
|
||||
if strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("bad json: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
}
|
||||
return rec, out
|
||||
}
|
||||
|
||||
func TestDocsAndHealth(t *testing.T) {
|
||||
rec, _ := do(t, "GET", "/", "", nil)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "example.com/") {
|
||||
t.Fatalf("docs page: %d", rec.Code)
|
||||
}
|
||||
rec, _ = do(t, "GET", "/healthz", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("healthz: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidEmail(t *testing.T) {
|
||||
rec, out := do(t, "POST", "/notanemail", "{}", nil)
|
||||
if rec.Code != 400 || out["error"] != "invalid_email" {
|
||||
t.Fatalf("got %d %v", rec.Code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultJSONResponse(t *testing.T) {
|
||||
c := capture(t)
|
||||
rec, out := do(t, "POST", "/t%40e.com?src=curl", `{"event":"user.created","user_id":12345}`,
|
||||
map[string]string{"Content-Type": "application/json", "X-Real-IP": "203.0.113.7"})
|
||||
if rec.Code != 200 || out["ok"] != true || out["parser"] != "default" {
|
||||
t.Fatalf("got %d %v", rec.Code, out)
|
||||
}
|
||||
if out["request"].(map[string]any)["ip"] != "203.0.113.7" {
|
||||
t.Fatalf("forwarded ip not used: %v", out["request"])
|
||||
}
|
||||
data := out["data"].(map[string]any)
|
||||
if data["query"].(map[string]any)["src"] != "curl" || data["json"].(map[string]any)["event"] != "user.created" {
|
||||
t.Fatalf("data echo wrong: %v", data)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Body.String(), "{\n \"ok\": true,") {
|
||||
t.Fatalf("not pretty-printed with ok first:\n%s", rec.Body.String())
|
||||
}
|
||||
if c.to != "t@e.com" || !strings.HasPrefix(c.subject, "‼️ Webhook Request Received") || !strings.Contains(c.html, "user.created") {
|
||||
t.Fatalf("mail wrong: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicPeerCannotSpoofIP(t *testing.T) {
|
||||
capture(t)
|
||||
req := httptest.NewRequest("POST", "/t%40e.com", nil)
|
||||
req.RemoteAddr = "198.51.100.9:1234"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
rec := httptest.NewRecorder()
|
||||
handle(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), `"ip": "198.51.100.9"`) {
|
||||
t.Fatalf("spoofed:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormFields(t *testing.T) {
|
||||
c := capture(t)
|
||||
_, out := do(t, "POST", "/t%40e.com", "a=1&b=two", map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
|
||||
form := out["data"].(map[string]any)["form"].(map[string]any)
|
||||
if form["a"] != "1" || form["b"] != "two" || out["data"].(map[string]any)["json"] != nil {
|
||||
t.Fatalf("form: %v", out["data"])
|
||||
}
|
||||
if !strings.Contains(c.html, `"b": "two"`) {
|
||||
t.Fatalf("variables block missing form field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubLowercaseHeader(t *testing.T) {
|
||||
c := capture(t)
|
||||
body := `{"ref":"refs/heads/main","repository":{"full_name":"andy/webhooks"},"sender":{"login":"andy"},
|
||||
"commits":[{"id":"abc1234def","message":"Line one\n\n- <b>two</b>","author":{"name":"Andy"},"url":"https://github.com/andy/webhooks/commit/abc1234"}]}`
|
||||
_, out := do(t, "POST", "/t%40e.com/github", body, map[string]string{"Content-Type": "application/json", "x-github-event": "push"})
|
||||
if out["parser"] != "github" || out["subject"] != "📤 GitHub push - andy/webhooks" {
|
||||
t.Fatalf("subject: %v", out["subject"])
|
||||
}
|
||||
for _, want := range []string{`class="sha" href="https://github.com/andy/webhooks/commit/abc1234">abc1234<`, "Line one<br>\n<br>\n- <b>two</b>"} {
|
||||
if !strings.Contains(c.html, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, c.html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubPullRequestAction(t *testing.T) {
|
||||
capture(t)
|
||||
body := `{"action":"opened","pull_request":{"number":42,"title":"Epic","state":"open","html_url":"https://x/pr/42","head":{"ref":"f"},"base":{"ref":"main"}},"repository":{"full_name":"a/b"},"sender":{"login":"andy"}}`
|
||||
_, out := do(t, "POST", "/t%40e.com/github", body, map[string]string{"X-GitHub-Event": "pull_request"})
|
||||
if out["subject"] != "🔀 GitHub pull_request (opened) - a/b" {
|
||||
t.Fatalf("subject: %v", out["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrafana(t *testing.T) {
|
||||
c := capture(t)
|
||||
body := `{"receiver":"email","status":"firing","alerts":[{"status":"firing","labels":{"alertname":"HighCPU","severity":"critical"},"annotations":{"summary":"CPU over 90%"},"startsAt":"2026-09-11T17:00:00Z"},{"status":"resolved","labels":{"alertname":"Disk"}}],"groupLabels":{"alertname":"HighCPU"},"externalURL":"https://g.example.com","truncatedAlerts":0}`
|
||||
_, out := do(t, "POST", "/t%40e.com/grafana", body, nil)
|
||||
if out["subject"] != "🚨 Grafana Alert: firing - 2 alerts" {
|
||||
t.Fatalf("subject: %v", out["subject"])
|
||||
}
|
||||
if !strings.Contains(c.html, "Alert 2") || strings.Contains(c.html, "Truncated") || !strings.Contains(c.html, `href="https://g.example.com"`) {
|
||||
t.Fatalf("grafana html wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONParserBreadcrumbsAndScalars(t *testing.T) {
|
||||
c := capture(t)
|
||||
body := `{"order":{"id":"ORD-789","items":[{"sku":"A1","tags":["red","xl"]}],"notes":"l1\nl2"},"colours":["cyan"],"n":99.99,"ok":true,"none":null}`
|
||||
do(t, "POST", "/t%40e.com/json", body, map[string]string{"Content-Type": "application/json"})
|
||||
for _, want := range []string{
|
||||
`path-title">order<`, `path-title">order->items->[0]<`, `path-title">order->items->[0]->tags<`, `path-title">colours<`,
|
||||
"l1<br>\nl2", `>99.99<`, `>true<`, `<td class="metadata-value"></td>`,
|
||||
} {
|
||||
if !strings.Contains(c.html, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// key order preserved: "order" table before "colours"
|
||||
if strings.Index(c.html, `path-title">order<`) > strings.Index(c.html, `path-title">colours<`) {
|
||||
t.Fatal("key order not preserved")
|
||||
}
|
||||
|
||||
_, out := do(t, "POST", "/t%40e.com/json", `"just a string"`, map[string]string{"Content-Type": "application/json"})
|
||||
if out["parser"] != "json" || !strings.Contains(c.html, `metadata-label">value</td><td class="metadata-value">just a string<`) {
|
||||
t.Fatalf("scalar body not handled: %v", out["subject"])
|
||||
}
|
||||
|
||||
_, out = do(t, "POST", "/t%40e.com/json", `not json`, nil)
|
||||
if !strings.HasPrefix(out["subject"].(string), "‼️") {
|
||||
t.Fatalf("invalid json should fall back: %v", out["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebexSamples(t *testing.T) {
|
||||
raw, err := os.ReadFile("wxinteract_test_samples.json")
|
||||
if err != nil {
|
||||
t.Skip("no samples")
|
||||
}
|
||||
var samples map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &samples); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := capture(t)
|
||||
for name, s := range samples {
|
||||
_, out := do(t, "POST", "/t%40e.com/wxinteract", string(s), nil)
|
||||
if out["parser"] != "wxinteract" || !strings.Contains(out["subject"].(string), "Webex Interact:") {
|
||||
t.Fatalf("%s: %v", name, out["subject"])
|
||||
}
|
||||
if !strings.Contains(c.html, "Event Information") {
|
||||
t.Fatalf("%s: html missing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailFailureIsReported(t *testing.T) {
|
||||
old := sendMail
|
||||
sendMail = func(string, string, string) error { return os.ErrClosed }
|
||||
t.Cleanup(func() { sendMail = old })
|
||||
rec, out := do(t, "POST", "/t%40e.com", "", nil)
|
||||
if rec.Code != 502 || out["error"] != "mail_failed" {
|
||||
t.Fatalf("got %d %v", rec.Code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageEncoding(t *testing.T) {
|
||||
msg := string(buildMessage("t@e.com", "‼️ Subject", "<p>héllo</p>"))
|
||||
if !strings.Contains(msg, "Subject: =?utf-8?q?") || !strings.Contains(msg, "Content-Transfer-Encoding: quoted-printable") || !strings.Contains(msg, "h=C3=A9llo") {
|
||||
t.Fatalf("message:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = http.StatusOK
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// githubParse formats GitHub webhook events.
|
||||
func githubParse(raw []byte, headers http.Header, meta Meta) (string, string, bool) {
|
||||
v, err := decodeJSON(raw)
|
||||
payload := asObject(v)
|
||||
if err != nil || payload.Len() == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// http.Header lookups are case-insensitive, which covers lowercase HTTP/2 names.
|
||||
event := headers.Get("X-GitHub-Event")
|
||||
if event == "" {
|
||||
event = "Unknown"
|
||||
}
|
||||
|
||||
action := strOr(payload.Get("action"), "")
|
||||
repo := strOr(get(payload, "repository", "full_name"), "Unknown")
|
||||
sender := strOr(get(payload, "sender", "login"), "Unknown")
|
||||
|
||||
eventSafe := h(event)
|
||||
repoSafe := h(repo)
|
||||
senderSafe := h(sender)
|
||||
|
||||
icon := "🔔"
|
||||
var content string
|
||||
|
||||
switch event {
|
||||
case "push":
|
||||
icon = "📤"
|
||||
ref := h(strOr(payload.Get("ref"), "Unknown"))
|
||||
commits, _ := payload.Get("commits").([]any)
|
||||
content = section("Push Details", table(
|
||||
row("Branch", ref),
|
||||
row("Commits", fmt.Sprint(len(commits))),
|
||||
row("Pusher", senderSafe),
|
||||
))
|
||||
if len(commits) > 0 {
|
||||
var b strings.Builder
|
||||
for i, c := range commits {
|
||||
if i == 10 {
|
||||
break
|
||||
}
|
||||
msg := esc(strOr(get(c, "message"), ""))
|
||||
author := h(strOr(get(c, "author", "name"), "Unknown"))
|
||||
id := strOr(get(c, "id"), "")
|
||||
if len(id) > 7 {
|
||||
id = id[:7]
|
||||
}
|
||||
sha := h(id)
|
||||
url := strOr(get(c, "url"), "")
|
||||
shaHTML := `<span class="sha">` + sha + `</span>`
|
||||
if url != "" {
|
||||
shaHTML = `<a class="sha" href="` + h(url) + `">` + sha + `</a>`
|
||||
}
|
||||
b.WriteString(`<div class="commit-item">` + shaHTML + ` <span class="who">` + author + `</span><div class="msg">` + msg + `</div></div>`)
|
||||
}
|
||||
content += section("Commits", b.String())
|
||||
}
|
||||
|
||||
case "pull_request":
|
||||
icon = "🔀"
|
||||
pr := payload.Get("pull_request")
|
||||
content = section("Pull Request Details", table(
|
||||
row("Number", "#"+h(strOr(get(pr, "number"), "N/A"))),
|
||||
row("Title", h(strOr(get(pr, "title"), "N/A"))),
|
||||
row("State", h(strOr(get(pr, "state"), "N/A"))),
|
||||
row("Branch", h(strOr(get(pr, "head", "ref"), "N/A"))+" → "+h(strOr(get(pr, "base", "ref"), "N/A"))),
|
||||
row("URL", link(strOr(get(pr, "html_url"), "#"))),
|
||||
))
|
||||
|
||||
case "issues":
|
||||
icon = "📝"
|
||||
is := payload.Get("issue")
|
||||
content = section("Issue Details", table(
|
||||
row("Number", "#"+h(strOr(get(is, "number"), "N/A"))),
|
||||
row("Title", h(strOr(get(is, "title"), "N/A"))),
|
||||
row("State", h(strOr(get(is, "state"), "N/A"))),
|
||||
row("URL", link(strOr(get(is, "html_url"), "#"))),
|
||||
))
|
||||
|
||||
case "release":
|
||||
icon = "🚀"
|
||||
rel := payload.Get("release")
|
||||
content = section("Release Details", table(
|
||||
row("Tag", h(strOr(get(rel, "tag_name"), "N/A"))),
|
||||
row("Name", h(strOr(get(rel, "name"), "N/A"))),
|
||||
row("URL", link(strOr(get(rel, "html_url"), "#"))),
|
||||
))
|
||||
|
||||
default:
|
||||
content = section("Event Details", renderDataAsTable(payload, 0))
|
||||
}
|
||||
|
||||
actionDisplay := ""
|
||||
if action != "" {
|
||||
actionDisplay = " (" + h(action) + ")"
|
||||
}
|
||||
|
||||
body := section("📋 Repository Information", table(
|
||||
row("Repository", repoSafe),
|
||||
row("Event", eventSafe),
|
||||
row("Sender", senderSafe),
|
||||
row("IP Address", h(meta.IP)),
|
||||
)) + content
|
||||
|
||||
html := emailShell(icon+" GitHub: "+eventSafe+actionDisplay, h(meta.Date), body,
|
||||
"This GitHub webhook was automatically forwarded to your email address.")
|
||||
subject := fmt.Sprintf("%s GitHub %s%s - %s", icon, event, strings.ReplaceAll(actionDisplay, "'", "'"), repo)
|
||||
return html, subject, true
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// grafanaParse formats Grafana / Prometheus Alertmanager webhook payloads.
|
||||
func grafanaParse(raw []byte, headers http.Header, meta Meta) (string, string, bool) {
|
||||
v, err := decodeJSON(raw)
|
||||
payload := asObject(v)
|
||||
if err != nil || payload.Len() == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
status := strOr(payload.Get("status"), "unknown")
|
||||
receiver := strOr(payload.Get("receiver"), "Unknown")
|
||||
externalURL := strOr(payload.Get("externalURL"), "")
|
||||
alerts, _ := payload.Get("alerts").([]any)
|
||||
truncated := num(payload.Get("truncatedAlerts"))
|
||||
|
||||
icon, color := statusStyle(status, "🚨")
|
||||
|
||||
// Summary
|
||||
rows := []string{
|
||||
row("Status", `<strong style="color: `+color+`;">`+icon+" "+h(status)+`</strong>`),
|
||||
row("Receiver", h(receiver)),
|
||||
row("Alert Count", fmt.Sprint(len(alerts))),
|
||||
}
|
||||
if truncated > 0 {
|
||||
rows = append(rows, row("Truncated Alerts", fmt.Sprint(int(truncated))))
|
||||
}
|
||||
if externalURL != "" {
|
||||
rows = append(rows, row("Alertmanager URL", link(externalURL)))
|
||||
}
|
||||
body := section("Alert Summary", table(rows...))
|
||||
|
||||
// Labels
|
||||
groupLabels := asObject(payload.Get("groupLabels"))
|
||||
commonLabels := asObject(payload.Get("commonLabels"))
|
||||
if groupLabels.Len() > 0 || commonLabels.Len() > 0 {
|
||||
var b strings.Builder
|
||||
if groupLabels.Len() > 0 {
|
||||
b.WriteString(`<div class="subsection-title">Group Labels</div>` + kvTable(groupLabels))
|
||||
}
|
||||
if commonLabels.Len() > 0 {
|
||||
b.WriteString(`<div class="subsection-title">Common Labels</div>` + kvTable(commonLabels))
|
||||
}
|
||||
body += section("Labels", b.String())
|
||||
}
|
||||
|
||||
// Common annotations
|
||||
if ca := asObject(payload.Get("commonAnnotations")); ca.Len() > 0 {
|
||||
body += section("Common Annotations", kvTable(ca))
|
||||
}
|
||||
|
||||
// Individual alerts
|
||||
if len(alerts) > 0 {
|
||||
var b strings.Builder
|
||||
for i, a := range alerts {
|
||||
al := asObject(a)
|
||||
aStatus := strOr(al.Get("status"), "unknown")
|
||||
aIcon, aColor := statusStyle(aStatus, "🔥")
|
||||
b.WriteString(`<div class="array-item"><div class="array-item-title">` + aIcon + " Alert " + fmt.Sprint(i+1) +
|
||||
` - <span style="color: ` + aColor + `;">` + h(aStatus) + `</span></div>`)
|
||||
if l := asObject(al.Get("labels")); l.Len() > 0 {
|
||||
b.WriteString(`<div class="subsection-title">Labels</div>` + kvTable(l))
|
||||
}
|
||||
if an := asObject(al.Get("annotations")); an.Len() > 0 {
|
||||
b.WriteString(`<div class="subsection-title">Annotations</div>` + kvTable(an))
|
||||
}
|
||||
var extra []string
|
||||
if s := strOr(al.Get("startsAt"), ""); s != "" {
|
||||
extra = append(extra, row("Started At", h(s)))
|
||||
}
|
||||
if s := strOr(al.Get("endsAt"), ""); s != "" {
|
||||
extra = append(extra, row("Ended At", h(s)))
|
||||
}
|
||||
if s := strOr(al.Get("generatorURL"), ""); s != "" {
|
||||
extra = append(extra, row("Generator URL", link(s)))
|
||||
}
|
||||
if s := strOr(al.Get("fingerprint"), ""); s != "" {
|
||||
extra = append(extra, row("Fingerprint", h(s)))
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
b.WriteString(table(extra...))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
body += section("Alerts", b.String())
|
||||
}
|
||||
|
||||
body += section("📋 Request Information", table(
|
||||
row("Receiver", h(receiver)),
|
||||
row("IP Address", h(meta.IP)),
|
||||
))
|
||||
|
||||
html := emailShell(icon+" Grafana Alert: "+h(status), h(meta.Date), body,
|
||||
"This Grafana/Prometheus alert was automatically forwarded to your email address.")
|
||||
count := "1 alert"
|
||||
if len(alerts) != 1 {
|
||||
count = fmt.Sprintf("%d alerts", len(alerts))
|
||||
}
|
||||
return html, fmt.Sprintf("%s Grafana Alert: %s - %s", icon, status, count), true
|
||||
}
|
||||
|
||||
func statusStyle(status, firingIcon string) (icon, color string) {
|
||||
switch status {
|
||||
case "firing":
|
||||
return firingIcon, "#dc3545"
|
||||
case "resolved":
|
||||
return "✅", "#28a745"
|
||||
}
|
||||
return "🔔", "#666666"
|
||||
}
|
||||
|
||||
// kvTable renders an object of scalar values as a label/value table.
|
||||
func kvTable(m *OMap) string {
|
||||
rows := make([]string, 0, m.Len())
|
||||
for _, k := range m.Keys {
|
||||
rows = append(rows, row(h(k), esc(str(m.M[k]))))
|
||||
}
|
||||
return table(rows...)
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jsonParse renders any JSON body as tables, splitting nested objects into
|
||||
// their own tables under path breadcrumbs like order->items->[0]->tags.
|
||||
func jsonParse(raw []byte, headers http.Header, meta Meta) (string, string, bool) {
|
||||
v, err := decodeJSON(raw)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
body := section("📋 Request Information", table(
|
||||
row("IP Address", h(meta.IP)),
|
||||
row("Method", h(meta.Method)),
|
||||
row("Content-Type", h(meta.ContentType)),
|
||||
)) + section("📦 JSON Data", renderJSON(v, ""))
|
||||
|
||||
html := emailShell("📊 JSON Webhook Data", h(meta.Date), body, "JSON data automatically parsed and formatted.")
|
||||
return html, "📊 JSON Webhook - " + meta.Date, true
|
||||
}
|
||||
|
||||
func pathTitle(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return `<div class="path-title">` + h(path) + `</div>`
|
||||
}
|
||||
|
||||
func renderJSON(data any, path string) string {
|
||||
var b strings.Builder
|
||||
|
||||
switch d := data.(type) {
|
||||
case []any:
|
||||
if len(d) == 0 {
|
||||
return ""
|
||||
}
|
||||
allScalar := true
|
||||
for _, it := range d {
|
||||
if isContainer(it) {
|
||||
allScalar = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allScalar {
|
||||
// Array of plain values: one table under a single breadcrumb
|
||||
rows := make([]string, 0, len(d))
|
||||
for i, it := range d {
|
||||
rows = append(rows, row("Item "+fmt.Sprint(i+1), esc(str(it))))
|
||||
}
|
||||
return pathTitle(path) + table(rows...)
|
||||
}
|
||||
for i, item := range d {
|
||||
itemPath := fmt.Sprintf("[%d]", i)
|
||||
if path != "" {
|
||||
itemPath = path + "->" + itemPath
|
||||
}
|
||||
if m := asObject(item); m != nil {
|
||||
var simple []string
|
||||
var sub []string
|
||||
for _, k := range m.Keys {
|
||||
if isContainer(m.M[k]) {
|
||||
sub = append(sub, k)
|
||||
} else {
|
||||
simple = append(simple, row(h(k), esc(str(m.M[k]))))
|
||||
}
|
||||
}
|
||||
if len(simple) > 0 {
|
||||
b.WriteString(pathTitle(path2(path, itemPath)))
|
||||
b.WriteString(`<div class="array-item"><div class="array-item-title">Item ` + fmt.Sprint(i+1) + `</div>` + table(simple...) + `</div>`)
|
||||
}
|
||||
for _, k := range sub {
|
||||
b.WriteString(renderJSON(m.M[k], itemPath+"->"+k))
|
||||
}
|
||||
} else if isContainer(item) {
|
||||
b.WriteString(renderJSON(item, itemPath))
|
||||
} else {
|
||||
b.WriteString(table(row("Item "+fmt.Sprint(i+1), esc(str(item)))))
|
||||
}
|
||||
}
|
||||
|
||||
case *OMap:
|
||||
var simple []string
|
||||
var sub []string
|
||||
for _, k := range d.Keys {
|
||||
if isContainer(d.M[k]) {
|
||||
sub = append(sub, k)
|
||||
} else {
|
||||
simple = append(simple, row(h(k), esc(str(d.M[k]))))
|
||||
}
|
||||
}
|
||||
if len(simple) > 0 {
|
||||
b.WriteString(pathTitle(path) + table(simple...))
|
||||
}
|
||||
for _, k := range sub {
|
||||
subPath := k
|
||||
if path != "" {
|
||||
subPath = path + "->" + k
|
||||
}
|
||||
b.WriteString(renderJSON(d.M[k], subPath))
|
||||
}
|
||||
|
||||
default:
|
||||
// Scalar (or null): one-row table so it lines up with everything else
|
||||
label := path
|
||||
if label == "" {
|
||||
label = "value"
|
||||
}
|
||||
val := str(data)
|
||||
if data == nil {
|
||||
val = "null"
|
||||
}
|
||||
return table(row(h(label), esc(val)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// path2 mirrors the PHP behaviour: an item breadcrumb is only shown when nested under a path.
|
||||
func path2(parent, itemPath string) string {
|
||||
if parent == "" {
|
||||
return ""
|
||||
}
|
||||
return itemPath
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// wxinteractParse formats Webex Interact SMS webhook events.
|
||||
func wxinteractParse(raw []byte, headers http.Header, meta Meta) (string, string, bool) {
|
||||
v, err := decodeJSON(raw)
|
||||
payload := asObject(v)
|
||||
if err != nil || payload.Len() == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
data := asObject(payload.Get("data"))
|
||||
if data == nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
status := strOr(data.Get("status"), "Unknown")
|
||||
f := func(key string) string { return h(strOr(data.Get(key), "N/A")) }
|
||||
|
||||
icon, title := "📱", "SMS Event"
|
||||
var content string
|
||||
|
||||
switch status {
|
||||
case "submitted":
|
||||
icon, title = "📤", "Outbound SMS - Submitted"
|
||||
content = section("📤 SMS Details", table(
|
||||
row("Message ID", f("message_id")),
|
||||
row("To", f("to")),
|
||||
row("From", f("from")),
|
||||
row("Campaign ID", f("campaign_id")),
|
||||
row("Submitted At", f("submitted_at")),
|
||||
)) + section("💬 Message Content", `<div class="message-box">`+esc(strOr(data.Get("message"), "N/A"))+`</div>`)
|
||||
|
||||
case "delivered":
|
||||
icon, title = "✅", "Outbound SMS - Delivered"
|
||||
content = section("✅ Delivery Details", table(
|
||||
row("Message ID", f("message_id")),
|
||||
row("To", f("to")),
|
||||
row("From", f("from")),
|
||||
row("Campaign ID", f("campaign_id")),
|
||||
row("Delivered At", f("delivered_at")),
|
||||
))
|
||||
|
||||
case "failed":
|
||||
icon, title = "❌", "Outbound SMS - Failed"
|
||||
content = section("❌ Failure Details", table(
|
||||
row("Message ID", f("message_id")),
|
||||
row("To", f("to")),
|
||||
row("From", f("from")),
|
||||
row("Campaign ID", f("campaign_id")),
|
||||
row("Failed At", f("failed_at")),
|
||||
)) + section("⚠️ Error Information", `<div class="error-box"><strong>Error Code:</strong> `+f("error_code")+
|
||||
`<br><strong>Error Message:</strong> `+esc(strOr(data.Get("error_message"), "N/A"))+`</div>`)
|
||||
|
||||
case "shortlink_clicked":
|
||||
icon, title = "🔗", "Shortlink Clicked"
|
||||
content = section("🔗 Click Details", table(
|
||||
row("Shortlink", link(strOr(data.Get("shortlink"), "N/A"))),
|
||||
row("Original URL", link(strOr(data.Get("original_url"), "N/A"))),
|
||||
row("Phone Number", f("phone_number")),
|
||||
row("Message ID", f("message_id")),
|
||||
row("Campaign ID", f("campaign_id")),
|
||||
row("Clicked At", f("clicked_at")),
|
||||
row("IP Address", f("ip_address")),
|
||||
)) + section("🌐 Browser Information", `<div class="data-box">`+f("user_agent")+`</div>`)
|
||||
|
||||
case "received":
|
||||
icon, title = "📥", "Inbound SMS Received"
|
||||
content = section("📥 Inbound SMS Details", table(
|
||||
row("Message ID", f("message_id")),
|
||||
row("From", f("from")),
|
||||
row("To", f("to")),
|
||||
row("Keyword", f("keyword")),
|
||||
row("Received At", f("received_at")),
|
||||
)) + section("💬 Message Content", `<div class="message-box">`+esc(strOr(data.Get("message"), "N/A"))+`</div>`)
|
||||
|
||||
case "opt_out":
|
||||
icon, title = "🚫", "SMS Opt Out Received"
|
||||
content = section("🚫 Opt Out Details", table(
|
||||
row("Phone Number", f("phone_number")),
|
||||
row("From Number", f("from")),
|
||||
row("Keyword", f("keyword")),
|
||||
row("Message ID", f("message_id")),
|
||||
row("Opt Out At", f("opt_out_at")),
|
||||
))
|
||||
|
||||
case "contact_created", "contact_updated":
|
||||
icon, title = "👤", "Contacts Callback"
|
||||
content = section("👤 Contact Details", table(
|
||||
row("Contact ID", f("contact_id")),
|
||||
row("Phone Number", f("phone_number")),
|
||||
row("First Name", f("first_name")),
|
||||
row("Last Name", f("last_name")),
|
||||
row("Email", f("email")),
|
||||
row("Status", f("status")),
|
||||
row("Created At", f("created_at")),
|
||||
row("Updated At", f("updated_at")),
|
||||
))
|
||||
if cf := asObject(data.Get("custom_fields")); cf.Len() > 0 {
|
||||
content += section("🏷️ Custom Fields", kvTable(cf))
|
||||
}
|
||||
|
||||
default:
|
||||
title = "SMS Event: " + status
|
||||
content = section("Event Details", renderDataAsTable(data, 0))
|
||||
}
|
||||
|
||||
body := section("📋 Event Information", table(
|
||||
row("Event Type", h(status)),
|
||||
row("IP Address", h(meta.IP)),
|
||||
)) + content
|
||||
|
||||
html := emailShell(icon+" Webex Interact: "+h(title), h(meta.Date), body,
|
||||
"This Webex Interact webhook was automatically forwarded to your email address.")
|
||||
return html, icon + " Webex Interact: " + title, true
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* GitHub Webhook Parser
|
||||
* Parses GitHub webhook events into a formatted email
|
||||
*
|
||||
* @param string $rawBody The raw request body
|
||||
* @param array $headers The request headers
|
||||
* @param array $metadata Request metadata (date, ip, method, contentType)
|
||||
* @return array|false Array with 'html' and 'subject' keys, or false on failure
|
||||
*/
|
||||
function githubParse($rawBody, $headers, $metadata) {
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Header names arrive lowercase over HTTP/2, so match case-insensitively
|
||||
$headers = array_change_key_case($headers, CASE_LOWER);
|
||||
$event = $headers['x-github-event'] ?? 'Unknown';
|
||||
$delivery = $headers['x-github-delivery'] ?? 'Unknown';
|
||||
|
||||
// Extract common data
|
||||
$action = $payload['action'] ?? null;
|
||||
$repository = $payload['repository']['full_name'] ?? 'Unknown';
|
||||
$sender = $payload['sender']['login'] ?? 'Unknown';
|
||||
|
||||
// Sanitize for HTML
|
||||
$eventSafe = htmlspecialchars($event, ENT_QUOTES, 'UTF-8');
|
||||
$actionSafe = $action ? htmlspecialchars($action, ENT_QUOTES, 'UTF-8') : null;
|
||||
$repoSafe = htmlspecialchars($repository, ENT_QUOTES, 'UTF-8');
|
||||
$senderSafe = htmlspecialchars($sender, ENT_QUOTES, 'UTF-8');
|
||||
$dateSafe = htmlspecialchars($metadata['date'], ENT_QUOTES, 'UTF-8');
|
||||
$ipSafe = htmlspecialchars($metadata['ip'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Build event-specific content
|
||||
$eventContent = '';
|
||||
$eventIcon = '🔔';
|
||||
|
||||
switch ($event) {
|
||||
case 'push':
|
||||
$eventIcon = '📤';
|
||||
$ref = htmlspecialchars($payload['ref'] ?? 'Unknown', ENT_QUOTES, 'UTF-8');
|
||||
$commits = $payload['commits'] ?? [];
|
||||
$commitCount = count($commits);
|
||||
|
||||
$eventContent = <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">Push Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Branch</td>
|
||||
<td class="metadata-value">$ref</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Commits</td>
|
||||
<td class="metadata-value">$commitCount</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Pusher</td>
|
||||
<td class="metadata-value">$senderSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
if ($commitCount > 0) {
|
||||
$eventContent .= '<div class="section"><div class="section-title">Commits</div>';
|
||||
foreach (array_slice($commits, 0, 10) as $commit) {
|
||||
$message = esc($commit['message'] ?? '');
|
||||
$author = htmlspecialchars($commit['author']['name'] ?? 'Unknown', ENT_QUOTES, 'UTF-8');
|
||||
$sha = htmlspecialchars(substr($commit['id'] ?? '', 0, 7), ENT_QUOTES, 'UTF-8');
|
||||
$url = htmlspecialchars($commit['url'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$shaHtml = $url ? "<a class=\"sha\" href=\"$url\">$sha</a>" : "<span class=\"sha\">$sha</span>";
|
||||
$eventContent .= "<div class=\"commit-item\">$shaHtml <span class=\"who\">$author</span><div class=\"msg\">$message</div></div>";
|
||||
}
|
||||
$eventContent .= '</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pull_request':
|
||||
$eventIcon = '🔀';
|
||||
$pr = $payload['pull_request'] ?? [];
|
||||
$prNumber = htmlspecialchars($pr['number'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$prTitle = htmlspecialchars($pr['title'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$prUrl = htmlspecialchars($pr['html_url'] ?? '#', ENT_QUOTES, 'UTF-8');
|
||||
$prState = htmlspecialchars($pr['state'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$prBase = htmlspecialchars($pr['base']['ref'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$prHead = htmlspecialchars($pr['head']['ref'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$eventContent = <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">Pull Request Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Number</td>
|
||||
<td class="metadata-value">#$prNumber</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Title</td>
|
||||
<td class="metadata-value">$prTitle</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">State</td>
|
||||
<td class="metadata-value">$prState</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Branch</td>
|
||||
<td class="metadata-value">$prHead → $prBase</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">URL</td>
|
||||
<td class="metadata-value"><a href="$prUrl" style="color: #7c3aed;">$prUrl</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
break;
|
||||
|
||||
case 'issues':
|
||||
$eventIcon = '📝';
|
||||
$issue = $payload['issue'] ?? [];
|
||||
$issueNumber = htmlspecialchars($issue['number'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$issueTitle = htmlspecialchars($issue['title'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$issueUrl = htmlspecialchars($issue['html_url'] ?? '#', ENT_QUOTES, 'UTF-8');
|
||||
$issueState = htmlspecialchars($issue['state'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$eventContent = <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">Issue Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Number</td>
|
||||
<td class="metadata-value">#$issueNumber</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Title</td>
|
||||
<td class="metadata-value">$issueTitle</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">State</td>
|
||||
<td class="metadata-value">$issueState</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">URL</td>
|
||||
<td class="metadata-value"><a href="$issueUrl" style="color: #7c3aed;">$issueUrl</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
break;
|
||||
|
||||
case 'release':
|
||||
$eventIcon = '🚀';
|
||||
$release = $payload['release'] ?? [];
|
||||
$tagName = htmlspecialchars($release['tag_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$releaseName = htmlspecialchars($release['name'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$releaseUrl = htmlspecialchars($release['html_url'] ?? '#', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$eventContent = <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">Release Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Tag</td>
|
||||
<td class="metadata-value">$tagName</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Name</td>
|
||||
<td class="metadata-value">$releaseName</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">URL</td>
|
||||
<td class="metadata-value"><a href="$releaseUrl" style="color: #7c3aed;">$releaseUrl</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic event display - render as structured tables
|
||||
$eventContent = '<div class="section"><div class="section-title">Event Details</div>';
|
||||
$eventContent .= renderDataAsTable($payload);
|
||||
$eventContent .= '</div>';
|
||||
}
|
||||
|
||||
$actionDisplay = $actionSafe ? " ($actionSafe)" : '';
|
||||
|
||||
$emailTitle = "$eventIcon GitHub: $eventSafe$actionDisplay";
|
||||
$emailStamp = "$dateSafe";
|
||||
$body = <<<HTML
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📋 Repository Information</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Repository</td>
|
||||
<td class="metadata-value">$repoSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Event</td>
|
||||
<td class="metadata-value">$eventSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Sender</td>
|
||||
<td class="metadata-value">$senderSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">IP Address</td>
|
||||
<td class="metadata-value">$ipSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
$eventContent
|
||||
HTML;
|
||||
$html = emailShell($emailTitle, $emailStamp, $body, "This GitHub webhook was automatically forwarded to your email address.");
|
||||
|
||||
$subject = "$eventIcon GitHub $eventSafe$actionDisplay - $repoSafe";
|
||||
|
||||
return [
|
||||
'html' => $html,
|
||||
'subject' => $subject
|
||||
];
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Grafana/Prometheus Alertmanager Webhook Parser
|
||||
* Parses Grafana/Prometheus Alertmanager webhook events into a formatted email
|
||||
*
|
||||
* @param string $rawBody The raw request body
|
||||
* @param array $headers The request headers
|
||||
* @param array $metadata Request metadata (date, ip, method, contentType)
|
||||
* @return array|false Array with 'html' and 'subject' keys, or false on failure
|
||||
*/
|
||||
function grafanaParse($rawBody, $headers, $metadata) {
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract common data
|
||||
$status = $payload['status'] ?? 'unknown';
|
||||
$receiver = $payload['receiver'] ?? 'Unknown';
|
||||
$groupKey = $payload['groupKey'] ?? 'Unknown';
|
||||
$externalURL = $payload['externalURL'] ?? null;
|
||||
$alerts = $payload['alerts'] ?? [];
|
||||
$alertCount = count($alerts);
|
||||
$truncatedAlerts = $payload['truncatedAlerts'] ?? 0;
|
||||
$groupLabels = $payload['groupLabels'] ?? [];
|
||||
$commonLabels = $payload['commonLabels'] ?? [];
|
||||
$commonAnnotations = $payload['commonAnnotations'] ?? [];
|
||||
|
||||
// Sanitize for HTML
|
||||
$statusSafe = htmlspecialchars($status, ENT_QUOTES, 'UTF-8');
|
||||
$receiverSafe = htmlspecialchars($receiver, ENT_QUOTES, 'UTF-8');
|
||||
$dateSafe = htmlspecialchars($metadata['date'], ENT_QUOTES, 'UTF-8');
|
||||
$ipSafe = htmlspecialchars($metadata['ip'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Choose icon based on status
|
||||
$statusIcon = '🔔';
|
||||
$statusColor = '#666666';
|
||||
if ($status === 'firing') {
|
||||
$statusIcon = '🚨';
|
||||
$statusColor = '#dc3545';
|
||||
} elseif ($status === 'resolved') {
|
||||
$statusIcon = '✅';
|
||||
$statusColor = '#28a745';
|
||||
}
|
||||
|
||||
// Build alert summary section
|
||||
$alertSummary = <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">Alert Summary</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Status</td>
|
||||
<td class="metadata-value"><strong style="color: $statusColor;">$statusIcon $statusSafe</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Receiver</td>
|
||||
<td class="metadata-value">$receiverSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Alert Count</td>
|
||||
<td class="metadata-value">$alertCount</td>
|
||||
</tr>
|
||||
HTML;
|
||||
|
||||
if ($truncatedAlerts > 0) {
|
||||
$alertSummary .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">Truncated Alerts</td>
|
||||
<td class="metadata-value">$truncatedAlerts</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
if ($externalURL) {
|
||||
$externalURLSafe = htmlspecialchars($externalURL, ENT_QUOTES, 'UTF-8');
|
||||
$alertSummary .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">Alertmanager URL</td>
|
||||
<td class="metadata-value"><a href="$externalURLSafe" style="color: #7c3aed;">$externalURLSafe</a></td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$alertSummary .= <<<HTML
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
// Build common labels section
|
||||
$labelsContent = '';
|
||||
if (!empty($groupLabels) || !empty($commonLabels)) {
|
||||
$labelsContent .= '<div class="section"><div class="section-title">Labels</div>';
|
||||
|
||||
if (!empty($groupLabels)) {
|
||||
$labelsContent .= '<div class="subsection-title">Group Labels</div><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($groupLabels as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$labelsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">$keySafe</td>
|
||||
<td class="metadata-value">$valueSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
$labelsContent .= '</table>';
|
||||
}
|
||||
|
||||
if (!empty($commonLabels)) {
|
||||
$labelsContent .= '<div class="subsection-title">Common Labels</div><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($commonLabels as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$labelsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">$keySafe</td>
|
||||
<td class="metadata-value">$valueSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
$labelsContent .= '</table>';
|
||||
}
|
||||
|
||||
$labelsContent .= '</div>';
|
||||
}
|
||||
|
||||
// Build common annotations section
|
||||
$annotationsContent = '';
|
||||
if (!empty($commonAnnotations)) {
|
||||
$annotationsContent .= '<div class="section"><div class="section-title">Common Annotations</div><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($commonAnnotations as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$annotationsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">$keySafe</td>
|
||||
<td class="metadata-value">$valueSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
$annotationsContent .= '</table></div>';
|
||||
}
|
||||
|
||||
// Build individual alerts section
|
||||
$alertsContent = '';
|
||||
if (!empty($alerts)) {
|
||||
$alertsContent .= '<div class="section"><div class="section-title">Alerts</div>';
|
||||
|
||||
foreach ($alerts as $index => $alert) {
|
||||
$alertStatus = $alert['status'] ?? 'unknown';
|
||||
$alertLabels = $alert['labels'] ?? [];
|
||||
$alertAnnotations = $alert['annotations'] ?? [];
|
||||
$startsAt = $alert['startsAt'] ?? null;
|
||||
$endsAt = $alert['endsAt'] ?? null;
|
||||
$generatorURL = $alert['generatorURL'] ?? null;
|
||||
$fingerprint = $alert['fingerprint'] ?? null;
|
||||
|
||||
$alertStatusSafe = htmlspecialchars($alertStatus, ENT_QUOTES, 'UTF-8');
|
||||
$alertNumber = $index + 1;
|
||||
|
||||
// Choose alert icon
|
||||
$alertIcon = '🔔';
|
||||
$alertColor = '#666666';
|
||||
if ($alertStatus === 'firing') {
|
||||
$alertIcon = '🔥';
|
||||
$alertColor = '#dc3545';
|
||||
} elseif ($alertStatus === 'resolved') {
|
||||
$alertIcon = '✅';
|
||||
$alertColor = '#28a745';
|
||||
}
|
||||
|
||||
$alertsContent .= <<<HTML
|
||||
<div class="array-item">
|
||||
<div class="array-item-title">$alertIcon Alert $alertNumber - <span style="color: $alertColor;">$alertStatusSafe</span></div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
HTML;
|
||||
|
||||
// Add alert labels
|
||||
if (!empty($alertLabels)) {
|
||||
$alertsContent .= '</table><div class="subsection-title">Labels</div><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($alertLabels as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$alertsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">$keySafe</td>
|
||||
<td class="metadata-value">$valueSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
$alertsContent .= '</table><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
}
|
||||
|
||||
// Add alert annotations
|
||||
if (!empty($alertAnnotations)) {
|
||||
$alertsContent .= '</table><div class="subsection-title">Annotations</div><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($alertAnnotations as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$alertsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">$keySafe</td>
|
||||
<td class="metadata-value">$valueSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
$alertsContent .= '</table><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
}
|
||||
|
||||
// Add timing and metadata
|
||||
if ($startsAt) {
|
||||
$startsAtSafe = htmlspecialchars($startsAt, ENT_QUOTES, 'UTF-8');
|
||||
$alertsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">Started At</td>
|
||||
<td class="metadata-value">$startsAtSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
if ($endsAt) {
|
||||
$endsAtSafe = htmlspecialchars($endsAt, ENT_QUOTES, 'UTF-8');
|
||||
$alertsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">Ended At</td>
|
||||
<td class="metadata-value">$endsAtSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
if ($generatorURL) {
|
||||
$generatorURLSafe = htmlspecialchars($generatorURL, ENT_QUOTES, 'UTF-8');
|
||||
$alertsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">Generator URL</td>
|
||||
<td class="metadata-value"><a href="$generatorURLSafe" style="color: #7c3aed;">$generatorURLSafe</a></td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
if ($fingerprint) {
|
||||
$fingerprintSafe = htmlspecialchars($fingerprint, ENT_QUOTES, 'UTF-8');
|
||||
$alertsContent .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">Fingerprint</td>
|
||||
<td class="metadata-value">$fingerprintSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$alertsContent .= '</table></div>';
|
||||
}
|
||||
|
||||
$alertsContent .= '</div>';
|
||||
}
|
||||
|
||||
$emailTitle = "$statusIcon Grafana Alert: $statusSafe";
|
||||
$emailStamp = "$dateSafe";
|
||||
$body = <<<HTML
|
||||
|
||||
$alertSummary
|
||||
|
||||
$labelsContent
|
||||
|
||||
$annotationsContent
|
||||
|
||||
$alertsContent
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📋 Request Information</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Receiver</td>
|
||||
<td class="metadata-value">$receiverSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">IP Address</td>
|
||||
<td class="metadata-value">$ipSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
$html = emailShell($emailTitle, $emailStamp, $body, "This Grafana/Prometheus alert was automatically forwarded to your email address.");
|
||||
|
||||
// Create subject line
|
||||
$alertCountText = $alertCount === 1 ? '1 alert' : "$alertCount alerts";
|
||||
$subject = "$statusIcon Grafana Alert: $statusSafe - $alertCountText";
|
||||
|
||||
return [
|
||||
'html' => $html,
|
||||
'subject' => $subject
|
||||
];
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Helper functions for webhook parsers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a free-text value for an HTML cell, keeping line breaks.
|
||||
*/
|
||||
function esc($value) {
|
||||
return nl2br(htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render nested arrays/objects as HTML tables
|
||||
*
|
||||
* @param array $data The data to render
|
||||
* @param int $depth Current recursion depth
|
||||
* @return string HTML representation of the data
|
||||
*/
|
||||
function renderDataAsTable($data, $depth = 0) {
|
||||
if ($depth > 10) {
|
||||
return '<div class="data-box">Max depth reached</div>';
|
||||
}
|
||||
|
||||
$html = '';
|
||||
|
||||
if (!is_array($data)) {
|
||||
$safe = esc($data);
|
||||
return "<div class=\"simple-value\">$safe</div>";
|
||||
}
|
||||
|
||||
// Check if it's an indexed array of similar items (like commits)
|
||||
$isIndexedArray = array_keys($data) === range(0, count($data) - 1);
|
||||
|
||||
if ($isIndexedArray && count($data) > 0) {
|
||||
// Render as a list of items
|
||||
foreach ($data as $index => $item) {
|
||||
if (is_array($item)) {
|
||||
$html .= '<div class="array-item">';
|
||||
$html .= '<div class="array-item-title">Item ' . ($index + 1) . '</div>';
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($item as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
if (is_array($value)) {
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">' . $keySafe . '</td>';
|
||||
$html .= '<td class="metadata-value">';
|
||||
$html .= renderDataAsTable($value, $depth + 1);
|
||||
$html .= '</td>';
|
||||
$html .= '</tr>';
|
||||
} else {
|
||||
$valueSafe = esc($value);
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">' . $keySafe . '</td>';
|
||||
$html .= '<td class="metadata-value">' . $valueSafe . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
}
|
||||
$html .= '</table>';
|
||||
$html .= '</div>';
|
||||
} else {
|
||||
$valueSafe = esc($item);
|
||||
$html .= '<div class="array-item-simple">• ' . $valueSafe . '</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Render as key-value table
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($data as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
if (is_array($value) && !empty($value)) {
|
||||
// Close current table and start a subsection
|
||||
$html .= '</table>';
|
||||
$html .= '<div class="subsection">';
|
||||
$html .= '<div class="subsection-title">' . $keySafe . '</div>';
|
||||
$html .= renderDataAsTable($value, $depth + 1);
|
||||
$html .= '</div>';
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
} else if (is_array($value) && empty($value)) {
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">' . $keySafe . '</td>';
|
||||
$html .= '<td class="metadata-value"><em>empty</em></td>';
|
||||
$html .= '</tr>';
|
||||
} else {
|
||||
$valueSafe = esc($value);
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">' . $keySafe . '</td>';
|
||||
$html .= '<td class="metadata-value">' . $valueSafe . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
}
|
||||
$html .= '</table>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared email chrome: one stylesheet, one header, one footer, for every template.
|
||||
*
|
||||
* @param string $title Header text (already HTML-safe)
|
||||
* @param string $stamp Timestamp line (already HTML-safe)
|
||||
* @param string $body Inner HTML made of .section / .metadata / .data-box blocks
|
||||
* @param string $footer Footer sentence
|
||||
* @return string Complete HTML document
|
||||
*/
|
||||
function emailShell($title, $stamp, $body, $footer = 'Automatically forwarded to your inbox.') {
|
||||
$host = htmlspecialchars($_SERVER['HTTP_HOST'] ?? 'webhook', ENT_QUOTES, 'UTF-8');
|
||||
$plainTitle = strip_tags($title);
|
||||
$mono = "ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace";
|
||||
$sans = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif";
|
||||
return <<<HTML
|
||||
<!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:#0f0f17; color:#15151f; -webkit-font-smoothing:antialiased; font-family:$sans; font-size:15px; line-height:1.6; }
|
||||
a { color:#7c3aed; }
|
||||
table { border-collapse:separate; border-spacing:0; }
|
||||
.frame { width:100%; max-width:660px; }
|
||||
.hero { background:#7c3aed; background-image:linear-gradient(120deg,#8b5cf6 0%,#ec4899 55%,#f97316 100%); padding:32px 34px 28px; border-radius:20px 20px 0 0; }
|
||||
.kicker { font-family:$mono; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:rgba(255,255,255,.75); margin:0 0 10px; }
|
||||
.hero h1 { margin:0 0 14px; font-size:26px; font-weight:800; letter-spacing:-.02em; line-height:1.2; color:#ffffff; }
|
||||
.stamp { display:inline-block; font-family:$mono; font-size:12px; letter-spacing:.06em; color:#ffffff; background:rgba(15,15,23,.28); padding:6px 12px; border-radius:999px; }
|
||||
.card { background:#ffffff; padding:8px 0 14px; border-radius:0 0 20px 20px; }
|
||||
.section { padding:24px 34px 0; }
|
||||
.section-title { font-family:$mono; font-size:11px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:#7c3aed; margin:0 0 12px; padding-left:10px; border-left:3px solid #ec4899; line-height:1.4; }
|
||||
.subsection { margin:14px 0 0; }
|
||||
.subsection-title { font-size:13px; font-weight:700; color:#15151f; margin:16px 0 8px; }
|
||||
table.metadata { width:100%; border:1px solid #e6e3f2; border-radius:12px; overflow:hidden; background:#ffffff; }
|
||||
td.metadata-label, td.metadata-value { padding:11px 14px; border-bottom:1px solid #efedf7; 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:#6b6b80; background:#f8f7fd; word-break:break-word; }
|
||||
td.metadata-value { font-size:14px; color:#15151f; word-break:break-word; }
|
||||
td.metadata-value table.metadata { margin:2px 0; }
|
||||
.data-box { margin:0; padding:16px 18px; background:#15151f; color:#e6e6f0; border-radius:12px; border:1px solid #2a2a3a; 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; padding:5px 12px; font-family:$mono; font-size:12px; font-weight:700; color:#be185d; background:#fdf2f8; border:1px solid #fbcfe8; border-radius:999px; }
|
||||
.array-item { margin:0 0 12px; padding:16px; background:#f8f7fd; border:1px solid #e6e3f2; border-radius:14px; }
|
||||
.array-item-title { font-size:14px; font-weight:800; margin:0 0 10px; color:#15151f; letter-spacing:-.01em; }
|
||||
.array-item-simple { margin:0 0 6px; padding:9px 14px; background:#f8f7fd; border:1px solid #e6e3f2; border-radius:10px; font-size:14px; }
|
||||
.simple-value { font-size:14px; padding:4px 0; }
|
||||
.commit-item { margin:0 0 10px; padding:12px 14px; background:#f8f7fd; border:1px solid #e6e3f2; border-radius:12px; }
|
||||
.commit-item .sha { display:inline-block; font-family:$mono; font-size:12px; font-weight:700; color:#be185d; background:#fdf2f8; border:1px solid #fbcfe8; border-radius:999px; padding:2px 9px; text-decoration:none; }
|
||||
.commit-item .who { font-size:12px; color:#6b6b80; margin-left:6px; }
|
||||
.commit-item .msg { margin-top:6px; font-size:14px; line-height:1.5; color:#15151f; }
|
||||
.message-box { margin:12px 0; padding:16px 18px 16px 20px; background:#f8f7fd; border-left:4px solid #8b5cf6; border-radius:0 12px 12px 0; font-size:16px; line-height:1.55; color:#15151f; }
|
||||
.error-box { margin:12px 0; padding:12px 14px; background:#fef2f2; border-left:4px solid #ef4444; border-radius:0 10px 10px 0; }
|
||||
.footer { padding:22px 34px 8px; text-align:center; color:#6a6a86; font-family:$mono; font-size:11px; letter-spacing:.06em; line-height:1.8; }
|
||||
.footer a { color:#a78bfa; text-decoration:none; }
|
||||
.footer .bolt { color:#f97316; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0f0f17;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f17;">
|
||||
<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>
|
||||
HTML;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* JSON Webhook Parser
|
||||
* Parses JSON payloads and displays them as formatted tables
|
||||
* Sub-arrays are split into separate tables with path-based titles
|
||||
*
|
||||
* @param string $rawBody The raw request body
|
||||
* @param array $headers The request headers
|
||||
* @param array $metadata Request metadata (date, ip, method, contentType)
|
||||
* @return array|false Array with 'html' and 'subject' keys, or false on failure
|
||||
*/
|
||||
function jsonParse($rawBody, $headers, $metadata) {
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sanitize metadata for HTML
|
||||
$dateSafe = htmlspecialchars($metadata['date'], ENT_QUOTES, 'UTF-8');
|
||||
$ipSafe = htmlspecialchars($metadata['ip'], ENT_QUOTES, 'UTF-8');
|
||||
$methodSafe = htmlspecialchars($metadata['method'], ENT_QUOTES, 'UTF-8');
|
||||
$contentTypeSafe = htmlspecialchars($metadata['contentType'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Start building HTML
|
||||
$emailTitle = "📊 JSON Webhook Data";
|
||||
$emailStamp = "$dateSafe";
|
||||
$body = <<<HTML
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📋 Request Information</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">IP Address</td>
|
||||
<td class="metadata-value">$ipSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Method</td>
|
||||
<td class="metadata-value">$methodSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Content-Type</td>
|
||||
<td class="metadata-value">$contentTypeSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📦 JSON Data</div>
|
||||
|
||||
HTML;
|
||||
|
||||
// Process the JSON data
|
||||
$body .= renderJsonData($payload);
|
||||
|
||||
$body .= <<<HTML
|
||||
</div>
|
||||
HTML;
|
||||
$html = emailShell($emailTitle, $emailStamp, $body, "JSON data automatically parsed and formatted.");
|
||||
|
||||
$subject = "📊 JSON Webhook - " . $metadata['date'];
|
||||
|
||||
return [
|
||||
'html' => $html,
|
||||
'subject' => $subject
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render JSON data as tables
|
||||
* Sub-arrays are extracted into separate tables with path-based titles
|
||||
*
|
||||
* @param mixed $data The data to render
|
||||
* @param string $path The current path (e.g., "Foo->Bar->Baz")
|
||||
* @return string HTML representation
|
||||
*/
|
||||
function renderJsonData($data, $path = '') {
|
||||
$html = '';
|
||||
|
||||
if (!is_array($data)) {
|
||||
// Scalar (or null): one-row table so it lines up with everything else
|
||||
$label = htmlspecialchars($path ?: 'value', ENT_QUOTES, 'UTF-8');
|
||||
$safe = esc(is_string($data) ? $data : json_encode($data));
|
||||
return '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">'
|
||||
. '<tr><td class="metadata-label">' . $label . '</td><td class="metadata-value">' . $safe . '</td></tr></table>';
|
||||
}
|
||||
|
||||
// Check if it's an indexed array (numeric keys in sequence)
|
||||
$isIndexedArray = array_keys($data) === range(0, count($data) - 1);
|
||||
|
||||
if ($isIndexedArray && count($data) > 0) {
|
||||
// Array of plain values: one table under a single breadcrumb
|
||||
if (!array_filter($data, 'is_array')) {
|
||||
if ($path) {
|
||||
$html .= '<div class="path-title">' . htmlspecialchars($path, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
}
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($data as $index => $item) {
|
||||
$html .= '<tr><td class="metadata-label">Item ' . ($index + 1) . '</td><td class="metadata-value">' . esc($item) . '</td></tr>';
|
||||
}
|
||||
return $html . '</table>';
|
||||
}
|
||||
|
||||
// Array of objects
|
||||
foreach ($data as $index => $item) {
|
||||
$itemPath = $path ? $path . '->[' . $index . ']' : '[' . $index . ']';
|
||||
|
||||
if (is_array($item)) {
|
||||
// Check if this item has any sub-arrays
|
||||
$subArrays = [];
|
||||
$simpleData = [];
|
||||
|
||||
foreach ($item as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$subArrays[$key] = $value;
|
||||
} else {
|
||||
$simpleData[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Render simple data for this item
|
||||
if (!empty($simpleData)) {
|
||||
if ($path) {
|
||||
$html .= '<div class="path-title">' . htmlspecialchars($itemPath, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
}
|
||||
|
||||
$html .= '<div class="array-item">';
|
||||
$html .= '<div class="array-item-title">Item ' . ($index + 1) . '</div>';
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
|
||||
foreach ($simpleData as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">' . $keySafe . '</td>';
|
||||
$html .= '<td class="metadata-value">' . $valueSafe . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
// Recursively render sub-arrays as separate tables
|
||||
foreach ($subArrays as $key => $value) {
|
||||
$subPath = $itemPath . '->' . $key;
|
||||
$html .= renderJsonData($value, $subPath);
|
||||
}
|
||||
} else {
|
||||
// Simple value in array
|
||||
$valueSafe = esc($item);
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">Item ' . ($index + 1) . '</td>';
|
||||
$html .= '<td class="metadata-value">' . $valueSafe . '</td>';
|
||||
$html .= '</tr>';
|
||||
$html .= '</table>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's an associative array (object-like)
|
||||
// Separate simple values from arrays
|
||||
$subArrays = [];
|
||||
$simpleData = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$subArrays[$key] = $value;
|
||||
} else {
|
||||
$simpleData[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Render simple key-value pairs
|
||||
if (!empty($simpleData)) {
|
||||
if ($path) {
|
||||
$html .= '<div class="path-title">' . htmlspecialchars($path, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
}
|
||||
|
||||
$html .= '<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
|
||||
foreach ($simpleData as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$html .= '<tr>';
|
||||
$html .= '<td class="metadata-label">' . $keySafe . '</td>';
|
||||
$html .= '<td class="metadata-value">' . $valueSafe . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
}
|
||||
|
||||
// Recursively render sub-arrays as separate tables with path titles
|
||||
foreach ($subArrays as $key => $value) {
|
||||
$subPath = $path ? $path . '->' . $key : $key;
|
||||
$html .= renderJsonData($value, $subPath);
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Webex Interact Webhook Parser
|
||||
* Parses Webex Interact SMS webhook events into a formatted email
|
||||
*
|
||||
* @param string $rawBody The raw request body
|
||||
* @param array $headers The request headers
|
||||
* @param array $metadata Request metadata (date, ip, method, contentType)
|
||||
* @return array|false Array with 'html' and 'subject' keys, or false on failure
|
||||
*/
|
||||
function wxinteractParse($rawBody, $headers, $metadata) {
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!$payload || !isset($payload['data'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $payload['data'];
|
||||
$status = $data['status'] ?? 'Unknown';
|
||||
|
||||
// Sanitize common fields
|
||||
$statusSafe = htmlspecialchars($status, ENT_QUOTES, 'UTF-8');
|
||||
$dateSafe = htmlspecialchars($metadata['date'], ENT_QUOTES, 'UTF-8');
|
||||
$ipSafe = htmlspecialchars($metadata['ip'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Determine event type and icon
|
||||
$eventIcon = '📱';
|
||||
$eventTitle = 'SMS Event';
|
||||
$eventContent = '';
|
||||
|
||||
switch ($status) {
|
||||
case 'submitted':
|
||||
$eventIcon = '📤';
|
||||
$eventTitle = 'Outbound SMS - Submitted';
|
||||
$eventContent = buildOutboundSmsSubmittedContent($data);
|
||||
break;
|
||||
|
||||
case 'delivered':
|
||||
$eventIcon = '✅';
|
||||
$eventTitle = 'Outbound SMS - Delivered';
|
||||
$eventContent = buildOutboundSmsDeliveredContent($data);
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
$eventIcon = '❌';
|
||||
$eventTitle = 'Outbound SMS - Failed';
|
||||
$eventContent = buildOutboundSmsFailedContent($data);
|
||||
break;
|
||||
|
||||
case 'shortlink_clicked':
|
||||
$eventIcon = '🔗';
|
||||
$eventTitle = 'Shortlink Clicked';
|
||||
$eventContent = buildShortlinkClickedContent($data);
|
||||
break;
|
||||
|
||||
case 'received':
|
||||
$eventIcon = '📥';
|
||||
$eventTitle = 'Inbound SMS Received';
|
||||
$eventContent = buildInboundSmsContent($data);
|
||||
break;
|
||||
|
||||
case 'opt_out':
|
||||
$eventIcon = '🚫';
|
||||
$eventTitle = 'SMS Opt Out Received';
|
||||
$eventContent = buildOptOutContent($data);
|
||||
break;
|
||||
|
||||
case 'contact_created':
|
||||
case 'contact_updated':
|
||||
$eventIcon = '👤';
|
||||
$eventTitle = 'Contacts Callback';
|
||||
$eventContent = buildContactsCallbackContent($data);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown event type - display all data
|
||||
$eventIcon = '📱';
|
||||
$eventTitle = "SMS Event: $statusSafe";
|
||||
$eventContent = '<div class="section"><div class="section-title">Event Details</div>';
|
||||
$eventContent .= renderDataAsTable($data);
|
||||
$eventContent .= '</div>';
|
||||
}
|
||||
|
||||
$eventTitleSafe = htmlspecialchars($eventTitle, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$emailTitle = "$eventIcon Webex Interact: $eventTitleSafe";
|
||||
$emailStamp = "$dateSafe";
|
||||
$body = <<<HTML
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📋 Event Information</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Event Type</td>
|
||||
<td class="metadata-value">$statusSafe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">IP Address</td>
|
||||
<td class="metadata-value">$ipSafe</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
$eventContent
|
||||
HTML;
|
||||
$html = emailShell($emailTitle, $emailStamp, $body, "This Webex Interact webhook was automatically forwarded to your email address.");
|
||||
|
||||
$subject = "$eventIcon Webex Interact: $eventTitle";
|
||||
|
||||
return [
|
||||
'html' => $html,
|
||||
'subject' => $subject
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for Outbound SMS Submitted event
|
||||
*/
|
||||
function buildOutboundSmsSubmittedContent($data) {
|
||||
$messageId = htmlspecialchars($data['message_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$to = htmlspecialchars($data['to'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$from = htmlspecialchars($data['from'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$message = esc($data['message'] ?? 'N/A');
|
||||
$submittedAt = htmlspecialchars($data['submitted_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$campaignId = htmlspecialchars($data['campaign_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">📤 SMS Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Message ID</td>
|
||||
<td class="metadata-value">$messageId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">To</td>
|
||||
<td class="metadata-value">$to</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">From</td>
|
||||
<td class="metadata-value">$from</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Campaign ID</td>
|
||||
<td class="metadata-value">$campaignId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Submitted At</td>
|
||||
<td class="metadata-value">$submittedAt</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">💬 Message Content</div>
|
||||
<div class="message-box">$message</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for Outbound SMS Delivered event
|
||||
*/
|
||||
function buildOutboundSmsDeliveredContent($data) {
|
||||
$messageId = htmlspecialchars($data['message_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$to = htmlspecialchars($data['to'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$from = htmlspecialchars($data['from'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$deliveredAt = htmlspecialchars($data['delivered_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$campaignId = htmlspecialchars($data['campaign_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">✅ Delivery Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Message ID</td>
|
||||
<td class="metadata-value">$messageId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">To</td>
|
||||
<td class="metadata-value">$to</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">From</td>
|
||||
<td class="metadata-value">$from</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Campaign ID</td>
|
||||
<td class="metadata-value">$campaignId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Delivered At</td>
|
||||
<td class="metadata-value">$deliveredAt</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for Outbound SMS Failed event
|
||||
*/
|
||||
function buildOutboundSmsFailedContent($data) {
|
||||
$messageId = htmlspecialchars($data['message_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$to = htmlspecialchars($data['to'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$from = htmlspecialchars($data['from'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$failedAt = htmlspecialchars($data['failed_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$errorCode = htmlspecialchars($data['error_code'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$errorMessage = esc($data['error_message'] ?? 'N/A');
|
||||
$campaignId = htmlspecialchars($data['campaign_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">❌ Failure Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Message ID</td>
|
||||
<td class="metadata-value">$messageId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">To</td>
|
||||
<td class="metadata-value">$to</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">From</td>
|
||||
<td class="metadata-value">$from</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Campaign ID</td>
|
||||
<td class="metadata-value">$campaignId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Failed At</td>
|
||||
<td class="metadata-value">$failedAt</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">⚠️ Error Information</div>
|
||||
<div class="error-box">
|
||||
<strong>Error Code:</strong> $errorCode<br>
|
||||
<strong>Error Message:</strong> $errorMessage
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for Shortlink Clicked event
|
||||
*/
|
||||
function buildShortlinkClickedContent($data) {
|
||||
$shortlink = htmlspecialchars($data['shortlink'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$originalUrl = htmlspecialchars($data['original_url'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$clickedAt = htmlspecialchars($data['clicked_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$phoneNumber = htmlspecialchars($data['phone_number'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$messageId = htmlspecialchars($data['message_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$campaignId = htmlspecialchars($data['campaign_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$userAgent = htmlspecialchars($data['user_agent'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$ipAddress = htmlspecialchars($data['ip_address'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">🔗 Click Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Shortlink</td>
|
||||
<td class="metadata-value"><a href="$shortlink" style="color: #7c3aed;">$shortlink</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Original URL</td>
|
||||
<td class="metadata-value"><a href="$originalUrl" style="color: #7c3aed;">$originalUrl</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Phone Number</td>
|
||||
<td class="metadata-value">$phoneNumber</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Message ID</td>
|
||||
<td class="metadata-value">$messageId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Campaign ID</td>
|
||||
<td class="metadata-value">$campaignId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Clicked At</td>
|
||||
<td class="metadata-value">$clickedAt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">IP Address</td>
|
||||
<td class="metadata-value">$ipAddress</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">🌐 Browser Information</div>
|
||||
<div class="data-box">$userAgent</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for Inbound SMS Received event
|
||||
*/
|
||||
function buildInboundSmsContent($data) {
|
||||
$messageId = htmlspecialchars($data['message_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$from = htmlspecialchars($data['from'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$to = htmlspecialchars($data['to'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$message = esc($data['message'] ?? 'N/A');
|
||||
$receivedAt = htmlspecialchars($data['received_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$keyword = htmlspecialchars($data['keyword'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">📥 Inbound SMS Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Message ID</td>
|
||||
<td class="metadata-value">$messageId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">From</td>
|
||||
<td class="metadata-value">$from</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">To</td>
|
||||
<td class="metadata-value">$to</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Keyword</td>
|
||||
<td class="metadata-value">$keyword</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Received At</td>
|
||||
<td class="metadata-value">$receivedAt</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">💬 Message Content</div>
|
||||
<div class="message-box">$message</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for SMS Opt Out event
|
||||
*/
|
||||
function buildOptOutContent($data) {
|
||||
$phoneNumber = htmlspecialchars($data['phone_number'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$optOutAt = htmlspecialchars($data['opt_out_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$keyword = htmlspecialchars($data['keyword'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$messageId = htmlspecialchars($data['message_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$from = htmlspecialchars($data['from'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">🚫 Opt Out Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Phone Number</td>
|
||||
<td class="metadata-value">$phoneNumber</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">From Number</td>
|
||||
<td class="metadata-value">$from</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Keyword</td>
|
||||
<td class="metadata-value">$keyword</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Message ID</td>
|
||||
<td class="metadata-value">$messageId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Opt Out At</td>
|
||||
<td class="metadata-value">$optOutAt</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content for Contacts Callback event
|
||||
*/
|
||||
function buildContactsCallbackContent($data) {
|
||||
$contactId = htmlspecialchars($data['contact_id'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$phoneNumber = htmlspecialchars($data['phone_number'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$firstName = htmlspecialchars($data['first_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$lastName = htmlspecialchars($data['last_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$email = htmlspecialchars($data['email'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$status = htmlspecialchars($data['status'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$createdAt = htmlspecialchars($data['created_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
$updatedAt = htmlspecialchars($data['updated_at'] ?? 'N/A', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$customFieldsHtml = '';
|
||||
if (isset($data['custom_fields']) && is_array($data['custom_fields']) && !empty($data['custom_fields'])) {
|
||||
$customFieldsHtml = '<div class="section"><div class="section-title">🏷️ Custom Fields</div><table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">';
|
||||
foreach ($data['custom_fields'] as $key => $value) {
|
||||
$keySafe = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
|
||||
$valueSafe = esc($value);
|
||||
$customFieldsHtml .= <<<HTML
|
||||
<tr>
|
||||
<td class="metadata-label">$keySafe</td>
|
||||
<td class="metadata-value">$valueSafe</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
$customFieldsHtml .= '</table></div>';
|
||||
}
|
||||
|
||||
return <<<HTML
|
||||
<div class="section">
|
||||
<div class="section-title">👤 Contact Details</div>
|
||||
<table class="metadata" role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td class="metadata-label">Contact ID</td>
|
||||
<td class="metadata-value">$contactId</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Phone Number</td>
|
||||
<td class="metadata-value">$phoneNumber</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">First Name</td>
|
||||
<td class="metadata-value">$firstName</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Last Name</td>
|
||||
<td class="metadata-value">$lastName</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Email</td>
|
||||
<td class="metadata-value">$email</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Status</td>
|
||||
<td class="metadata-value">$status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Created At</td>
|
||||
<td class="metadata-value">$createdAt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metadata-label">Updated At</td>
|
||||
<td class="metadata-value">$updatedAt</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
$customFieldsHtml
|
||||
HTML;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
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 "webhooks.dixon.cx"
|
||||
}()
|
||||
|
||||
// emailShell wraps a body of sections in the shared design: stylesheet, gradient 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:#0f0f17; color:#15151f; -webkit-font-smoothing:antialiased; font-family:{{.Sans}}; font-size:15px; line-height:1.6; }
|
||||
a { color:#7c3aed; }
|
||||
table { border-collapse:separate; border-spacing:0; }
|
||||
.frame { width:100%; max-width:660px; }
|
||||
.hero { background:#7c3aed; background-image:linear-gradient(120deg,#8b5cf6 0%,#ec4899 55%,#f97316 100%); padding:32px 34px 28px; border-radius:20px 20px 0 0; }
|
||||
.kicker { font-family:{{.Mono}}; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:rgba(255,255,255,.75); margin:0 0 10px; }
|
||||
.hero h1 { margin:0 0 14px; font-size:26px; font-weight:800; letter-spacing:-.02em; line-height:1.2; color:#ffffff; }
|
||||
.stamp { display:inline-block; font-family:{{.Mono}}; font-size:12px; letter-spacing:.06em; color:#ffffff; background:rgba(15,15,23,.28); padding:6px 12px; border-radius:999px; }
|
||||
.card { background:#ffffff; padding:8px 0 14px; border-radius:0 0 20px 20px; }
|
||||
.section { padding:24px 34px 0; }
|
||||
.section-title { font-family:{{.Mono}}; font-size:11px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:#7c3aed; margin:0 0 12px; padding-left:10px; border-left:3px solid #ec4899; line-height:1.4; }
|
||||
.subsection { margin:14px 0 0; }
|
||||
.subsection-title { font-size:13px; font-weight:700; color:#15151f; margin:16px 0 8px; }
|
||||
table.metadata { width:100%; border:1px solid #e6e3f2; border-radius:12px; overflow:hidden; background:#ffffff; }
|
||||
td.metadata-label, td.metadata-value { padding:11px 14px; border-bottom:1px solid #efedf7; 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:#6b6b80; background:#f8f7fd; word-break:break-word; }
|
||||
td.metadata-value { font-size:14px; color:#15151f; word-break:break-word; }
|
||||
td.metadata-value table.metadata { margin:2px 0; }
|
||||
.data-box { margin:0; padding:16px 18px; background:#15151f; color:#e6e6f0; border-radius:12px; border:1px solid #2a2a3a; 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; padding:5px 12px; font-family:{{.Mono}}; font-size:12px; font-weight:700; color:#be185d; background:#fdf2f8; border:1px solid #fbcfe8; border-radius:999px; }
|
||||
.array-item { margin:0 0 12px; padding:16px; background:#f8f7fd; border:1px solid #e6e3f2; border-radius:14px; }
|
||||
.array-item-title { font-size:14px; font-weight:800; margin:0 0 10px; color:#15151f; letter-spacing:-.01em; }
|
||||
.array-item-simple { margin:0 0 6px; padding:9px 14px; background:#f8f7fd; border:1px solid #e6e3f2; border-radius:10px; font-size:14px; }
|
||||
.simple-value { font-size:14px; padding:4px 0; }
|
||||
.commit-item { margin:0 0 10px; padding:12px 14px; background:#f8f7fd; border:1px solid #e6e3f2; border-radius:12px; }
|
||||
.commit-item .sha { display:inline-block; font-family:{{.Mono}}; font-size:12px; font-weight:700; color:#be185d; background:#fdf2f8; border:1px solid #fbcfe8; border-radius:999px; padding:2px 9px; text-decoration:none; }
|
||||
.commit-item .who { font-size:12px; color:#6b6b80; margin-left:6px; }
|
||||
.commit-item .msg { margin-top:6px; font-size:14px; line-height:1.5; color:#15151f; }
|
||||
.message-box { margin:12px 0; padding:16px 18px 16px 20px; background:#f8f7fd; border-left:4px solid #8b5cf6; border-radius:0 12px 12px 0; font-size:16px; line-height:1.55; color:#15151f; }
|
||||
.error-box { margin:12px 0; padding:12px 14px; background:#fef2f2; border-left:4px solid #ef4444; border-radius:0 10px 10px 0; }
|
||||
.footer { padding:22px 34px 8px; text-align:center; color:#6a6a86; font-family:{{.Mono}}; font-size:11px; letter-spacing:.06em; line-height:1.8; }
|
||||
.footer a { color:#a78bfa; text-decoration:none; }
|
||||
.footer .bolt { color:#f97316; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0f0f17;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f17;">
|
||||
<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>
|
||||
`
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
sendmail_args="$test_dir/sendmail.args"
|
||||
sendmail_message="$test_dir/sendmail.message"
|
||||
fake_sendmail="$test_dir/sendmail"
|
||||
prepend_file="$test_dir/prepend.php"
|
||||
|
||||
cat > "$fake_sendmail" <<'FAKE_SENDMAIL'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$@" > "$MAIL_TEST_ARGS"
|
||||
cat > "$MAIL_TEST_MESSAGE"
|
||||
FAKE_SENDMAIL
|
||||
chmod +x "$fake_sendmail"
|
||||
|
||||
cat > "$prepend_file" <<'PHP'
|
||||
<?php
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
PHP
|
||||
|
||||
MAIL_TEST_ARGS="$sendmail_args" \
|
||||
MAIL_TEST_MESSAGE="$sendmail_message" \
|
||||
REQUEST_URI='/recipient%40example.com' \
|
||||
REQUEST_METHOD='POST' \
|
||||
REMOTE_ADDR='192.0.2.1' \
|
||||
CONTENT_TYPE='application/json' \
|
||||
php \
|
||||
-d "auto_prepend_file=$prepend_file" \
|
||||
-d "sendmail_path=$fake_sendmail -t -i" \
|
||||
"$project_dir/index.php" >/dev/null
|
||||
|
||||
if ! grep -Fxq -- '-fno-reply@dixon.cx' "$sendmail_args"; then
|
||||
echo 'Expected the envelope sender to be no-reply@dixon.cx.' >&2
|
||||
echo 'Actual sendmail arguments:' >&2
|
||||
sed 's/^/ /' "$sendmail_args" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq $'From: Webhook Call <no-reply@dixon.cx>\r' "$sendmail_message"; then
|
||||
echo 'Expected the From header to be Webhook Call <no-reply@dixon.cx>.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'Mail sender test passed.'
|
||||
Reference in New Issue
Block a user