408 lines
13 KiB
PHP
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'],
|
|
]); |