diff --git a/postal-router.php b/postal-router.php index 14bd968..3e8b28e 100644 --- a/postal-router.php +++ b/postal-router.php @@ -34,7 +34,7 @@ declare(strict_types=1); * Matching is case-insensitive. */ $ROUTES = [ - 'example.com' => 'https://example.com/mail-events.php', + '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. @@ -91,59 +91,132 @@ function wh_respond(int $code, array $body): void } /** - * 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. + * 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_extract_domain(array $payload): ?string +function wh_unwrap(array $payload): array { - $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']; + 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]); } } - // 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']; + 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; + } } } - // 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 ($data as $value) { + if (is_array($value)) { + $found = wh_deep_find($value, $keys, $depth + 1); + if ($found !== null) { + return $found; + } } } - foreach ($candidates as $candidate) { - if (!is_string($candidate) || $candidate === '') { - continue; + 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]; + } + } } - // 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]); + } + + // 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]; } - if (preg_match('/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', trim($candidate))) { - return strtolower(trim($candidate)); + } + + 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; } @@ -247,13 +320,17 @@ if (json_last_error() !== JSON_ERROR_NONE || !is_array($payload)) { wh_respond(400, ['status' => 'error', 'message' => 'Invalid JSON']); } -$event = (string) ($payload['event'] ?? $payload['status'] ?? 'unknown'); -$domain = wh_extract_domain($payload); +$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, - 'excerpt' => substr($rawBody, 0, 400), + '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. @@ -269,7 +346,11 @@ foreach ($ROUTES as $configuredDomain => $url) { } if ($target === null) { - wh_log('info', 'No route configured, skipping', ['domain' => $domain, 'event' => $event]); + 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]); }