PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.0
5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 All 42 releases
← All changes | src/FollowUp/FollowUpCoordinator.php +158 -32 5.6.2 → 5.8.0 View file →
@@ -164,8 +164,9 @@
164 164 );
165 165 $actions = array( new FollowUpAction( 'plan', FollowUpAction::KIND_MARKER, 'Plan', false ) );
166 166 $this->repository->plan( $optIn->get_id(), $adapter->getIntegration(), $actions, array(), FollowUpAction::fingerprint( $actions ), $this->now() );
167 167 $this->completeImmediately( $optIn, 'plan', FollowUpResult::failedPermanent( 'plan_failed' ) );
168 + $this->planGlobals( $optIn );
168 169 return true;
169 170 }
170 171
171 172 $skipReasons = array();
@@ -201,12 +202,70 @@
201 202 'skipped' => count( $skipReasons ),
202 203 )
203 204 );
204 205
206 + $this->planGlobals( $optIn );
207 +
205 208 return true;
206 209 }
207 210
208 211 /**
212 + * Add the actions of every global adapter (webhooks …) to the plan.
213 + *
214 + * A global adapter that fails or plans nothing leaves no trace and
215 + * never disturbs the form's own plan. Actions outside the adapter's
216 + * id prefix are dropped.
217 + */
218 + private function planGlobals( OptIn $optIn ): void {
219 + foreach ( $this->registry->globals() as $integration => $adapter ) {
220 + try {
221 + $actions = $adapter->planActions( $optIn );
222 + } catch ( \Throwable $e ) {
223 + $this->logger->error(
224 + 'Follow-up planning of a global adapter failed',
225 + array(
226 + 'plugin' => 'double-opt-in',
227 + 'optin_id' => $optIn->get_id(),
228 + 'integration' => $integration,
229 + 'exception' => get_class( $e ),
230 + )
231 + );
232 + continue;
233 + }
234 +
235 + $prefix = $integration . ':';
236 + $own = array();
237 + $skip = array();
238 + foreach ( $actions as $action ) {
239 + if ( ! $action instanceof FollowUpAction || strpos( $action->getId(), $prefix ) !== 0 ) {
240 + continue;
241 + }
242 + $own[] = $action;
243 + if ( $action->getSkipReason() !== '' ) {
244 + $skip[ $action->getId() ] = $action->getSkipReason();
245 + }
246 + }
247 +
248 + if ( count( $own ) !== count( $actions ) ) {
249 + $this->logger->warning(
250 + 'Follow-up actions outside the adapter prefix were dropped',
251 + array(
252 + 'plugin' => 'double-opt-in',
253 + 'optin_id' => $optIn->get_id(),
254 + 'integration' => $integration,
255 + 'dropped' => count( $actions ) - count( $own ),
256 + )
257 + );
258 + }
259 + if ( empty( $own ) ) {
260 + continue;
261 + }
262 +
263 + $this->repository->plan( $optIn->get_id(), $integration, $own, $skip, FollowUpAction::fingerprint( $own ), $this->now() );
264 + }
265 + }
266 +
267 + /**
209 268 * Execute the eligible actions of an opt-in.
210 269 *
211 270 * @param string $trigger One of FollowUpAttempt::TRIGGER_*.
212 271 * @param array{include_unknown?: bool, action_ids?: string[]} $options
@@ -270,13 +329,8 @@
270 329 // Another request won every claim — it owns this attempt.
271 330 return $this->repository->findByOptIn( $optInId );
272 331 }
273 332
274 - $actions = array();
275 - foreach ( $claimed as $record ) {
276 - $actions[] = $record->toAction();
277 - }
278 -
279 333 $this->logger->info(
280 334 'Follow-up attempt started',
281 335 array(
282 336 'plugin' => 'double-opt-in',
@@ -289,22 +343,53 @@
289 343 );
290 344
291 345 $startedAt = microtime( true );
292 346 $results = array();
293 - $fallback = 'action_outcome_unknown';
294 - try {
295 - $results = $adapter->execute( $optIn, $actions, $attempt );
296 - } catch ( \Throwable $e ) {
297 - $fallback = 'adapter_exception';
298 - $this->logger->error(
299 - 'Follow-up adapter threw',
300 - array(
301 - 'plugin' => 'double-opt-in',
302 - 'optin_id' => $optInId,
303 - 'attempt_id' => $attempt->getId(),
304 - 'exception' => get_class( $e ),
305 - )
306 - );
347 + $fallbacks = array();
348 +
349 + // Each adapter executes its own rows: the form adapter first (its
350 + // replay may need the request to itself), then the global ones.
351 + foreach ( $this->groupByAdapter( $claimed, $adapter ) as $integration => $group ) {
352 + $handler = $this->handlerFor( $integration, $adapter );
353 + if ( $handler === null ) {
354 + // The add-on that planned these is gone. Recorded honestly;
355 + // a manual retry runs them once it is back.
356 + foreach ( array_keys( $group ) as $actionId ) {
357 + $results[ $actionId ] = FollowUpResult::failedPermanent( 'adapter_missing' );
358 + }
359 + continue;
360 + }
361 +
362 + $actions = array();
363 + foreach ( $group as $record ) {
364 + $actions[] = $record->toAction();
365 + }
366 +
367 + $own = array();
368 + try {
369 + $own = $handler->execute( $optIn, $actions, $attempt );
370 + } catch ( \Throwable $e ) {
371 + foreach ( array_keys( $group ) as $actionId ) {
372 + $fallbacks[ $actionId ] = 'adapter_exception';
373 + }
374 + $this->logger->error(
375 + 'Follow-up adapter threw',
376 + array(
377 + 'plugin' => 'double-opt-in',
378 + 'optin_id' => $optInId,
379 + 'attempt_id' => $attempt->getId(),
380 + 'integration' => $integration,
381 + 'exception' => get_class( $e ),
382 + )
383 + );
384 + }
385 +
386 + // An adapter reports for its own actions only.
387 + foreach ( array_keys( $group ) as $actionId ) {
388 + if ( is_array( $own ) && isset( $own[ $actionId ] ) ) {
389 + $results[ $actionId ] = $own[ $actionId ];
390 + }
391 + }
307 392 }
308 393 $durationMs = (int) round( ( microtime( true ) - $startedAt ) * 1000 );
309 394
310 395 $finishedAt = $this->now();
@@ -312,9 +397,9 @@
312 397 $nextRetry = 0;
313 398 foreach ( $claimed as $actionId => $record ) {
314 399 $result = $results[ $actionId ] ?? null;
315 400 if ( ! $result instanceof FollowUpResult ) {
316 - $result = FollowUpResult::unknown( $fallback );
401 + $result = FollowUpResult::unknown( $fallbacks[ $actionId ] ?? 'action_outcome_unknown' );
317 402 }
318 403
319 404 $attemptNumber = $record->attempts + 1;
320 405 // Position in the automatic-retry budget. A manual retry starts
@@ -350,23 +435,34 @@
350 435 }
351 436
352 437 $records = $this->repository->findByOptIn( $optInId );
353 438 $statuses = array();
439 + $byOwner = array( $adapter->getIntegration() => array() );
354 440 foreach ( $records as $record ) {
355 - $statuses[ $record->actionId ] = $record->status;
441 + $statuses[ $record->actionId ] = $record->status;
442 + $byOwner[ $record->integration ][ $record->actionId ] = $record->status;
356 443 }
357 444
358 - try {
359 - $adapter->onSettled( $optIn, $statuses );
360 - } catch ( \Throwable $e ) {
361 - $this->logger->error(
362 - 'Follow-up onSettled threw',
363 - array(
364 - 'plugin' => 'double-opt-in',
365 - 'optin_id' => $optInId,
366 - 'exception' => get_class( $e ),
367 - )
368 - );
445 + // Each adapter settles its own rows — the form adapter's upload
446 + // cleanup must not wait for a webhook.
447 + foreach ( $byOwner as $integration => $own ) {
448 + $handler = $this->handlerFor( (string) $integration, $adapter );
449 + if ( $handler === null ) {
450 + continue;
451 + }
452 + try {
453 + $handler->onSettled( $optIn, $own );
454 + } catch ( \Throwable $e ) {
455 + $this->logger->error(
456 + 'Follow-up onSettled threw',
457 + array(
458 + 'plugin' => 'double-opt-in',
459 + 'optin_id' => $optInId,
460 + 'integration' => $integration,
461 + 'exception' => get_class( $e ),
462 + )
463 + );
464 + }
369 465 }
370 466
371 467 $this->auditAttempt( $optIn, $adapter, $attempt, $statuses, $report, $durationMs );
372 468
@@ -475,8 +571,38 @@
475 571 public function forget( int $optInId ): void {
476 572 if ( $optInId > 0 ) {
477 573 $this->repository->deleteByOptIn( $optInId );
478 574 }
575 + }
576 +
577 + /**
578 + * Claimed rows by the integration that planned them, the form
579 + * adapter's group first.
580 + *
581 + * @param array<string, FollowUpRecord> $claimed Keyed by action id.
582 + *
583 + * @return array<string, array<string, FollowUpRecord>>
584 + */
585 + private function groupByAdapter( array $claimed, FollowUpAdapterInterface $formAdapter ): array {
586 + $groups = array( $formAdapter->getIntegration() => array() );
587 + foreach ( $claimed as $actionId => $record ) {
588 + $groups[ $record->integration ][ $actionId ] = $record;
589 + }
590 +
591 + return array_filter( $groups );
592 + }
593 +
594 + /**
595 + * The adapter that owns rows of an integration: the opt-in's form
596 + * adapter or a global one. Null when the add-on that planned them is
597 + * no longer there.
598 + */
599 + private function handlerFor( string $integration, FollowUpAdapterInterface $formAdapter ): ?FollowUpAdapterInterface {
600 + if ( $integration === $formAdapter->getIntegration() ) {
601 + return $formAdapter;
602 + }
603 +
604 + return $this->registry->globals()[ $integration ] ?? null;
479 605 }
480 606
481 607 /**
482 608 * Backoff in seconds before automatic retry N (1-based position of