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

408 lines
13 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/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 <user@domain.tld>" 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'],
]);