PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.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 +163 -37 5.7.0 → 5.9.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
@@ -478,8 +574,38 @@
478 574 }
479 575 }
480 576
481 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;
605 + }
606 +
607 + /**
482 608 * Backoff in seconds before automatic retry N (1-based position of
483 609 * the attempt that just failed within the current budget — reset by
484 610 * every manual retry), or null when automatic retries are exhausted.
485 611 */
@@ -588,25 +714,25 @@
588 714
589 715 switch ( $aggregate ) {
590 716 case FollowUpStatus::AGGREGATE_COMPLETED:
591 717 $severity = AuditLogger::SEVERITY_INFO;
592 - $message = 'Follow-up actions completed';
718 + $message = __( 'Follow-up actions completed', 'double-opt-in' );
593 719 break;
594 720 case FollowUpStatus::AGGREGATE_PENDING:
595 721 $severity = AuditLogger::SEVERITY_WARNING;
596 - $message = 'Follow-up actions failed, retry scheduled';
722 + $message = __( 'Follow-up actions failed, retry scheduled', 'double-opt-in' );
597 723 break;
598 724 case FollowUpStatus::AGGREGATE_UNKNOWN:
599 725 $severity = AuditLogger::SEVERITY_WARNING;
600 - $message = 'Follow-up actions with unknown outcome';
726 + $message = __( 'Follow-up actions with unknown outcome', 'double-opt-in' );
601 727 break;
602 728 case FollowUpStatus::AGGREGATE_PARTIAL:
603 729 $severity = AuditLogger::SEVERITY_ERROR;
604 - $message = 'Follow-up actions partially failed';
730 + $message = __( 'Follow-up actions partially failed', 'double-opt-in' );
605 731 break;
606 732 default:
607 733 $severity = AuditLogger::SEVERITY_ERROR;
608 - $message = 'Follow-up actions failed';
734 + $message = __( 'Follow-up actions failed', 'double-opt-in' );
609 735 }
610 736
611 737 $details = array(
612 738 'event' => 'follow_up.attempt',