fix missed domains

This commit is contained in:
2026-09-16 15:30:24 +02:00
parent e06d5ef32e
commit 4783ce15a4
+122 -41
View File
@@ -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,58 +91,131 @@ 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 <token>@<sending domain>.
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'];
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;
}
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 ($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;
}
// 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]);
/**
* 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];
}
if (preg_match('/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', trim($candidate))) {
return strtolower(trim($candidate));
}
}
}
// 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;
}
@@ -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),
'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]);
}