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/wp-json/fluent-crm/v2/public/bounce_handler/postalserver/handle/fcrm_xxxxxxxx', ]; // 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; } /** * Postal versions differ in how they wrap events. Older ones POST the event * object directly; newer ones wrap it as * {"event":"MessageSent","timestamp":...,"uuid":"...","payload":{...}} * This returns the inner event object to work with, whatever the shape. */ function wh_unwrap(array $payload): array { foreach (['payload', 'data'] as $key) { if (isset($payload[$key]) && is_array($payload[$key])) { // Merge so top-level keys (event, uuid) stay reachable too. return array_merge($payload, $payload[$key]); } } return $payload; } /** Pull a domain out of "user@domain.tld", "Name " or "domain.tld". */ function wh_domain_from(string $value): ?string { $value = trim($value); if ($value === '') { return null; } if (preg_match('/@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/', $value, $m)) { return strtolower(rtrim($m[1], '.')); } if (preg_match('/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $value)) { return strtolower($value); } return null; } /** * Recursively hunt for the first usable value under any of $keys, at any depth. * Used as a safety net when Postal nests things somewhere unexpected. */ function wh_deep_find(array $data, array $keys, int $depth = 0): ?string { if ($depth > 6) { return null; } foreach ($keys as $wanted) { if (!empty($data[$wanted]) && is_string($data[$wanted])) { $domain = wh_domain_from($data[$wanted]); if ($domain !== null) { return $domain; } } } foreach ($data as $value) { if (is_array($value)) { $found = wh_deep_find($value, $keys, $depth + 1); if ($found !== null) { return $found; } } } return null; } /** * Work out the sending domain. * * Order matters: message.from is the real sending domain. message_id is only a * fallback, because Postal stamps it with the server's return-path domain * (e.g. ...@rp.postal2.dimail.hu), which is NOT the customer domain. */ function wh_extract_domain(array $payload, string $rawBody): ?string { $event = wh_unwrap($payload); $ordered = []; foreach (['message', 'original_message'] as $section) { if (isset($event[$section]) && is_array($event[$section])) { foreach (['from', 'sender', 'mail_from', 'from_address'] as $key) { if (!empty($event[$section][$key]) && is_string($event[$section][$key])) { $ordered[] = $event[$section][$key]; } } } } // Domain-level events (DNS errors etc.) and flat variants. foreach (['domain', 'from', 'sender'] as $key) { if (!empty($event[$key]) && is_string($event[$key])) { $ordered[] = $event[$key]; } } foreach ($ordered as $candidate) { $domain = wh_domain_from($candidate); if ($domain !== null) { return $domain; } } // Safety net 1: deep scan for a sender-ish key anywhere in the structure. $deep = wh_deep_find($event, ['from', 'sender', 'mail_from', 'from_address', 'domain'], 0); if ($deep !== null) { return $deep; } // Safety net 2: message_id, accepting that it may be a return-path domain. foreach (['message', 'original_message'] as $section) { if (!empty($event[$section]['message_id']) && is_string($event[$section]['message_id'])) { $domain = wh_domain_from($event[$section]['message_id']); if ($domain !== null) { return $domain; } } } // Safety net 3: scrape the raw body for a "from" field we failed to reach. if (preg_match('/"(?:from|sender|mail_from)"\s*:\s*"([^"]+)"/i', $rawBody, $m)) { return wh_domain_from($m[1]); } 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']); } $unwrapped = wh_unwrap($payload); $event = (string) ($unwrapped['event'] ?? $unwrapped['status'] ?? 'unknown'); $domain = wh_extract_domain($payload, $rawBody); if ($domain === null) { // Log the WHOLE body here - if extraction ever fails again, this is the // only way to see what shape Postal actually sent. wh_log('warning', 'No sending domain found, dropping event', [ 'event' => $event, 'top_keys' => array_keys($payload), 'full_body' => $rawBody, ]); // 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, 'known' => array_keys($ROUTES), ]); 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'], ]);