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'], ]);