Files
random_scripts/postal-router.php
T
2026-09-16 15:21:45 +02:00

327 lines
11 KiB
PHP

<?php
/**
* Postal webhook router
*
* Set this file's URL as the webhook URL in Postal:
* Postal -> your server -> Webhooks -> Add webhook
* https://your-host.example.com/postal-webhook.php
*
* What it does:
* 1. Receives any Postal webhook event (payload shape can vary per event).
* 2. Works out the sending domain from message.from, falling back to the
* domain in message.message_id.
* 3. Looks that domain up in the $ROUTES table below.
* 4. Replays the ORIGINAL, byte-for-byte untouched payload to that endpoint.
* 5. Logs the outcome and answers Postal:
* 200 - forwarded successfully, or deliberately dropped (no route /
* no domain), i.e. nothing to retry.
* 502 - forwarding failed. Postal treats a non-2xx as a failure and
* will redeliver the event later on its own schedule.
*
* Requires nothing beyond stock PHP 7.4+ (uses curl if available, otherwise
* falls back to a plain stream request).
*/
declare(strict_types=1);
// =====================================================================
// CONFIG - the only part you normally need to edit
// =====================================================================
/**
* Sending domain => endpoint to replay the webhook to.
* Add a line to add a domain, delete/comment a line to remove one.
* Matching is case-insensitive.
*/
$ROUTES = [
'example.com' => 'https://example.com/mail-events.php',
];
// Log file. Set to null to disable logging entirely.
$LOG_FILE = __DIR__ . '/postal-webhook.log';
// Truncate the log once it grows past this (bytes). 0 = never truncate.
$LOG_MAX_BYTES = 5 * 1024 * 1024; // 5 MB
// Optional shared secret. If set to a non-empty string, requests must include
// ?key=THAT_VALUE in the webhook URL, otherwise they are rejected. Keeps random
// internet noise out. Leave '' to disable.
$SECRET = '';
// Network timeouts (seconds) and in-request retry behaviour for the replay.
// Keep these low: Postal waits for the response, and anything that still fails
// after $MAX_ATTEMPTS is handed back to Postal (HTTP 502) to redeliver later.
$CONNECT_TIMEOUT = 5;
$TIMEOUT = 10;
$MAX_ATTEMPTS = 2; // 1 = no immediate retry, just hand it back to Postal
// =====================================================================
// END CONFIG - no need to edit below
// =====================================================================
function wh_log(string $level, string $message, array $context = []): void
{
global $LOG_FILE, $LOG_MAX_BYTES;
if (!$LOG_FILE) {
return;
}
if ($LOG_MAX_BYTES > 0 && @filesize($LOG_FILE) > $LOG_MAX_BYTES) {
@file_put_contents($LOG_FILE, ''); // simple self-rotation
}
$line = sprintf(
"[%s] %s: %s %s\n",
date('Y-m-d H:i:s'),
strtoupper($level),
$message,
$context ? json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : ''
);
@file_put_contents($LOG_FILE, $line, FILE_APPEND | LOCK_EX);
}
function wh_respond(int $code, array $body): void
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Pull the sending domain out of whatever shape the payload has.
* Postal nests the mail details under "message" for essentially every
* event type (MessageSent, MessageDeliveryFailed, MessageHeld, MessageBounced,
* MessageLinkClicked, MessageLoaded, DomainDNSError, ...), but we also scan a
* couple of top-level keys just in case.
*/
function wh_extract_domain(array $payload): ?string
{
$candidates = [];
$message = $payload['message'] ?? null;
if (is_array($message)) {
// Preferred: the envelope sender.
if (!empty($message['from'])) {
$candidates[] = $message['from'];
}
// Fallback: Postal generates message_id as <token>@<sending domain>.
if (!empty($message['message_id'])) {
$candidates[] = $message['message_id'];
}
}
// Some events (e.g. bounce payloads) carry the original message separately.
$original = $payload['original_message'] ?? null;
if (is_array($original)) {
if (!empty($original['from'])) {
$candidates[] = $original['from'];
}
if (!empty($original['message_id'])) {
$candidates[] = $original['message_id'];
}
}
// Domain-level events (e.g. DNS error webhooks).
foreach (['domain', 'from'] as $key) {
if (!empty($payload[$key]) && is_string($payload[$key])) {
$candidates[] = $payload[$key];
}
}
foreach ($candidates as $candidate) {
if (!is_string($candidate) || $candidate === '') {
continue;
}
// Handles "user@domain.tld", "Name <user@domain.tld>" and bare "domain.tld".
if (preg_match('/@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/', $candidate, $m)) {
return strtolower($m[1]);
}
if (preg_match('/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', trim($candidate))) {
return strtolower(trim($candidate));
}
}
return null;
}
/**
* POST the raw body to $url. Uses curl when present, otherwise streams.
* Returns ['ok' => bool, 'code' => int, 'error' => string|null, 'body' => string|null]
*/
function wh_post(string $url, string $rawBody): array
{
global $CONNECT_TIMEOUT, $TIMEOUT;
$headers = [
'Content-Type: application/json',
'Content-Length: ' . strlen($rawBody),
'User-Agent: postal-webhook-router/1.0',
'Accept: */*',
];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $rawBody,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => $TIMEOUT,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch) ?: null;
curl_close($ch);
return [
'ok' => $body !== false && $code >= 200 && $code < 300,
'code' => $code,
'error' => $error,
'body' => is_string($body) ? substr($body, 0, 500) : null,
];
}
// No curl: plain stream fallback.
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $rawBody,
'timeout' => $TIMEOUT,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$body = @file_get_contents($url, false, $context);
$code = 0;
if (!empty($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m)) {
$code = (int) $m[1];
}
return [
'ok' => $body !== false && $code >= 200 && $code < 300,
'code' => $code,
'error' => $body === false ? 'stream request failed' : null,
'body' => is_string($body) ? substr($body, 0, 500) : null,
];
}
// ---------------------------------------------------------------------
// Handle the request
// ---------------------------------------------------------------------
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
wh_respond(405, ['status' => 'error', 'message' => 'POST only']);
}
if ($SECRET !== '' && !hash_equals($SECRET, (string) ($_GET['key'] ?? ''))) {
wh_log('warning', 'Rejected request with bad/missing key', [
'ip' => $_SERVER['REMOTE_ADDR'] ?? '?',
]);
wh_respond(403, ['status' => 'error', 'message' => 'Forbidden']);
}
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
wh_log('warning', 'Empty request body');
wh_respond(400, ['status' => 'error', 'message' => 'Empty body']);
}
$payload = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($payload)) {
wh_log('warning', 'Invalid JSON', ['error' => json_last_error_msg()]);
wh_respond(400, ['status' => 'error', 'message' => 'Invalid JSON']);
}
$event = (string) ($payload['event'] ?? $payload['status'] ?? 'unknown');
$domain = wh_extract_domain($payload);
if ($domain === null) {
wh_log('warning', 'No sending domain found, dropping event', [
'event' => $event,
'excerpt' => substr($rawBody, 0, 400),
]);
// 200 on purpose: this is not a transient failure, a retry would be
// identical and we'd still have nothing to route on.
wh_respond(200, ['status' => 'ignored', 'reason' => 'no domain in payload']);
}
$target = null;
foreach ($ROUTES as $configuredDomain => $url) {
if (strcasecmp((string) $configuredDomain, $domain) === 0) {
$target = $url;
break;
}
}
if ($target === null) {
wh_log('info', 'No route configured, skipping', ['domain' => $domain, 'event' => $event]);
wh_respond(200, ['status' => 'ignored', 'reason' => 'no route for ' . $domain]);
}
// Replay the original payload, unchanged.
$result = ['ok' => false, 'code' => 0, 'error' => 'not attempted', 'body' => null];
for ($attempt = 1; $attempt <= max(1, $MAX_ATTEMPTS); $attempt++) {
$result = wh_post($target, $rawBody);
if ($result['ok']) {
wh_log('info', 'Replayed OK', [
'domain' => $domain,
'event' => $event,
'target' => $target,
'code' => $result['code'],
'attempt' => $attempt,
]);
break;
}
wh_log('error', 'Replay failed', [
'domain' => $domain,
'event' => $event,
'target' => $target,
'code' => $result['code'],
'curl_err' => $result['error'],
'response' => $result['body'],
'attempt' => $attempt,
]);
if ($attempt < $MAX_ATTEMPTS) {
usleep(500000); // 0.5s backoff before retrying
}
}
if ($result['ok']) {
wh_respond(200, [
'status' => 'ok',
'domain' => $domain,
'event' => $event,
'relayed' => true,
]);
}
// Forwarding failed after all local attempts: do NOT ack. Returning a non-2xx
// makes Postal mark this delivery as failed and redeliver the event later,
// so nothing is lost if the destination site is down or erroring.
wh_respond(502, [
'status' => 'error',
'message' => 'Forwarding to destination failed, retry later',
'domain' => $domain,
'event' => $event,
'relayed' => false,
'destination_code' => $result['code'],
]);