fix missed domains
This commit is contained in:
+122
-41
@@ -34,7 +34,7 @@ declare(strict_types=1);
|
|||||||
* Matching is case-insensitive.
|
* Matching is case-insensitive.
|
||||||
*/
|
*/
|
||||||
$ROUTES = [
|
$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.
|
// 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 versions differ in how they wrap events. Older ones POST the event
|
||||||
* Postal nests the mail details under "message" for essentially every
|
* object directly; newer ones wrap it as
|
||||||
* event type (MessageSent, MessageDeliveryFailed, MessageHeld, MessageBounced,
|
* {"event":"MessageSent","timestamp":...,"uuid":"...","payload":{...}}
|
||||||
* MessageLinkClicked, MessageLoaded, DomainDNSError, ...), but we also scan a
|
* This returns the inner event object to work with, whatever the shape.
|
||||||
* couple of top-level keys just in case.
|
|
||||||
*/
|
*/
|
||||||
function wh_extract_domain(array $payload): ?string
|
function wh_unwrap(array $payload): array
|
||||||
{
|
{
|
||||||
$candidates = [];
|
foreach (['payload', 'data'] as $key) {
|
||||||
|
if (isset($payload[$key]) && is_array($payload[$key])) {
|
||||||
$message = $payload['message'] ?? null;
|
// Merge so top-level keys (event, uuid) stay reachable too.
|
||||||
if (is_array($message)) {
|
return array_merge($payload, $payload[$key]);
|
||||||
// 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.
|
return $payload;
|
||||||
$original = $payload['original_message'] ?? null;
|
}
|
||||||
if (is_array($original)) {
|
|
||||||
if (!empty($original['from'])) {
|
/** Pull a domain out of "user@domain.tld", "Name <user@domain.tld>" or "domain.tld". */
|
||||||
$candidates[] = $original['from'];
|
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 ($data as $value) {
|
||||||
foreach (['domain', 'from'] as $key) {
|
if (is_array($value)) {
|
||||||
if (!empty($payload[$key]) && is_string($payload[$key])) {
|
$found = wh_deep_find($value, $keys, $depth + 1);
|
||||||
$candidates[] = $payload[$key];
|
if ($found !== null) {
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
return null;
|
||||||
if (!is_string($candidate) || $candidate === '') {
|
}
|
||||||
continue;
|
|
||||||
|
/**
|
||||||
|
* 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 <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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,13 +320,17 @@ if (json_last_error() !== JSON_ERROR_NONE || !is_array($payload)) {
|
|||||||
wh_respond(400, ['status' => 'error', 'message' => 'Invalid JSON']);
|
wh_respond(400, ['status' => 'error', 'message' => 'Invalid JSON']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$event = (string) ($payload['event'] ?? $payload['status'] ?? 'unknown');
|
$unwrapped = wh_unwrap($payload);
|
||||||
$domain = wh_extract_domain($payload);
|
$event = (string) ($unwrapped['event'] ?? $unwrapped['status'] ?? 'unknown');
|
||||||
|
$domain = wh_extract_domain($payload, $rawBody);
|
||||||
|
|
||||||
if ($domain === null) {
|
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', [
|
wh_log('warning', 'No sending domain found, dropping event', [
|
||||||
'event' => $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
|
// 200 on purpose: this is not a transient failure, a retry would be
|
||||||
// identical and we'd still have nothing to route on.
|
// identical and we'd still have nothing to route on.
|
||||||
@@ -269,7 +346,11 @@ foreach ($ROUTES as $configuredDomain => $url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($target === null) {
|
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]);
|
wh_respond(200, ['status' => 'ignored', 'reason' => 'no route for ' . $domain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user