327 lines
11 KiB
PHP
327 lines
11 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/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 <token>@<sending domain>.
|
|
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 <user@domain.tld>" 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'],
|
|
]); |