From e06d5ef32efd61478c79c6e37ae265c1db7579e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ndor?= <1+sandros@noreply.localhost> Date: Wed, 16 Sep 2026 15:21:45 +0200 Subject: [PATCH] uploaded --- postal-router.php | 327 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 postal-router.php diff --git a/postal-router.php b/postal-router.php new file mode 100644 index 0000000..14bd968 --- /dev/null +++ b/postal-router.php @@ -0,0 +1,327 @@ + 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 @. + 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 " 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'], +]); \ No newline at end of file