$args */ public function run(array $args): void { $job = PushJob::fromArgs($args); if ($job === null) { return; } $destination = $this->destinations->find($job->destinationId); if ($destination === null) { // Deleted while the job was in flight. Its health went with it, so // there is nowhere to record anything and nothing that would read // it if there were. return; } $type = $this->registry->find($destination->type); // Belt to the dispatcher's braces: a type that stopped being // dispatchable between enqueue and run — a licence that lapsed, a // plugin deactivated — is skipped rather than retried against a // handler that will never succeed (#4). if ($type === null || !$this->registry->isDispatchable($destination->type)) { RecentPushHistory::record('skipped', $job->attempt); return; } $lead = $this->leads->find($job->leadId); if ($lead === null) { // Erased or pruned since capture. **Not a delivery failure** — // nothing went wrong, and recording one would put a removed // person's id back into an option (ADR 0018). RecentPushHistory::record('skipped', $job->attempt); return; } $snapshot = $lead->submission($job->submissionId); if ($snapshot === null) { RecentPushHistory::record('skipped', $job->attempt); return; } $expected = $snapshot->capture['route_identities'][$job->destinationId] ?? null; if (is_string($expected) && !hash_equals($expected, RouteIdentity::of($destination))) { $this->record($job, $lead->optinId, $destination->type, PushResult::terminal(__('This destination’s account, target or policy changed after capture. Review before resending.', 'wconvert')), [], []); return; } $subject = PushSubject::of($snapshot, $job->destinationId, $job->submissionId, $lead); $credentials = $this->connections->credentialsFor($destination); try { $mappingIssue = $this->mappingIssue($type, $destination, $subject, $credentials); $send = fn (): PushResult => $type->push($subject, new PushContext( // Preserve the campaign's name as provider-side provenance. $this->optins->nameOf($lead->optinId), $destination->settings, $credentials, $lead->optinId, $this->optins->goalOf($lead->optinId)?->value )); $result = $mappingIssue ?? ($destination->type === 'lead_magnet_email' && $this->sendGuard !== null ? $this->sendGuard->send($subject->values['email'] ?? '', (string) ($destination->settings['file_url'] ?? ''), $send) : $send()); } catch (\Throwable $failure) { $result = PushResult::retryable(__('The destination stopped unexpectedly. The result may be uncertain.', 'wconvert')); } $this->record($job, $lead->optinId, $destination->type, $result, $subject->values, $credentials, $type instanceof DeclaresRetrySchedule ? $type->retryDelays() : null); } /** @param array $credentials */ private function mappingIssue(DestinationType $type, Destination $destination, PushSubject $subject, array $credentials): ?PushResult { if ($subject->mapped === []) return null; if (!method_exists($type, 'mappingFields')) return PushResult::attention(__('This destination no longer supports the selected answer fields.', 'wconvert')); try { $available = array_column(MappingFields::for($type, $destination, $credentials), null, 'value'); } catch (\Throwable $failure) { return PushResult::retryable(__('The destination fields could not be checked. Sending will retry.', 'wconvert')); } foreach ($subject->mapped as $target => $value) { if (!isset($available[$target]) || !MappingFields::accepts($available[$target], $value)) return PushResult::attention(__('A mapped destination field is unavailable or has an incompatible type. Review this campaign’s mapping.', 'wconvert')); } return null; } /** * `$optinId` and `$destinationType` travel here rather than being looked * up again, because the only thing that reads them is * {@see DeliveryCount::landed()} — and it needs BOTH: a lead-magnet Optin * may be bound to the MailPoet push as well, and counting that success as a * delivery would report two deliveries per Conversion (#31). * * @param array $personal * @param array $secrets * @param list|null $delays The type's own retry schedule, where it declares one. */ private function record( PushJob $job, string $optinId, string $destinationType, PushResult $result, array $personal, array $secrets, ?array $delays = null ): void { $now = current_time('mysql'); if ($result->outcome === PushOutcome::Success) { RecentPushHistory::record('accepted', $job->attempt); $this->health->landed($job->destinationId, $now); // **This is the whole of exactly-once**, and it is the shape // rather than a flag: the chain below re-queues only on a // retryable failure and stops here, so it reaches Success at most // once. The two seams that can replay it anyway are named in // {@see DeliveryCount} and clamped by the dashboard. $this->deliveries->landed($optinId, $destinationType); return; } if ($result->outcome === PushOutcome::Skipped) { RecentPushHistory::record('skipped', $job->attempt); return; } $error = DiagnosticSanitizer::message((string) $result->reason, $personal, $secrets); if ($result->needsAttention) { $this->health->failed($job->destinationId, $error, $now); RecentPushHistory::record('needs_attention', $job->attempt); return; } // An outage moves health. A Lead-specific rejection does not — see the // class docblock. if ($result->retryable) { $this->health->failed($job->destinationId, $error, $now); if ($job->hasAttemptsLeft($delays)) { try { $this->queue->schedule( time() + $job->backoffSeconds($delays), PushJob::HOOK, $job->next()->toArgs() ); RecentPushHistory::record('retry_scheduled', $job->attempt); return; } catch (QueueFailure $failure) { $error = DiagnosticSanitizer::message($failure->getMessage(), $personal, $secrets); $this->health->failed($job->destinationId, $error, $now); } } } // Terminal: `retryable = false`, or retryable with its attempts spent. $this->failures->record($job->destinationId, $job->leadId, $error, $now); RecentPushHistory::record('needs_attention', $job->attempt); } }