PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.2
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 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
double-opt-in / src / FollowUp / FollowUpCoordinator.php

FollowUpCoordinator.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.6.2, at src/FollowUp/FollowUpCoordinator.php

651 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plans, claims, executes and records follow-up actions.
4 *
5 * @package Forge12\DoubleOptIn\FollowUp
6 * @since 5.6.0
7 */
8
9 declare( strict_types=1 );
10
11 namespace Forge12\DoubleOptIn\FollowUp;
12
13 use Forge12\DoubleOptIn\Audit\AuditLogger;
14 use Forge12\DoubleOptIn\Repository\FollowUpRepositoryInterface;
15 use Forge12\Shared\LoggerInterface;
16 use forge12\contactform7\CF7DoubleOptIn\OptIn;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * The single path every follow-up execution goes through — the first
24 * run after confirmation, the cron retry and the admin's manual retry.
25 *
26 * Guarantees:
27 * - An action is planned once per opt-in (unique key) and claimed by
28 * exactly one request per attempt (conditional UPDATE).
29 * - A successful action is never executed again; `unknown` is never
30 * re-executed without an explicit administrator decision.
31 * - Nothing runs for an unconfirmed or opted-out opt-in.
32 * - Results carry codes, never form data.
33 */
34 final class FollowUpCoordinator {
35
36 public const CRON_RETRY_HOOK = 'f12_doi_follow_up_retry';
37 public const CRON_SWEEP_HOOK = 'f12_doi_follow_up_sweep';
38
39 /**
40 * How long a claimed action may stay `running` before the sweep
41 * declares its outcome unknown. Must exceed the longest adapter
42 * timeout (Elementor replay: 30 s).
43 */
44 public const LEASE_SECONDS = 300;
45
46 /**
47 * Replay tickets are consumed within the same PHP request that
48 * issued them; two minutes is generous.
49 */
50 public const TICKET_TTL_SECONDS = 120;
51
52 /**
53 * A `pending` row this old on a confirmed opt-in means the confirming
54 * request died before executing it.
55 */
56 public const STALE_PENDING_SECONDS = 600;
57
58 /** @var self|null */
59 private static $instance;
60
61 /** @var FollowUpRepositoryInterface */
62 private $repository;
63
64 /** @var FollowUpAdapterRegistry */
65 private $registry;
66
67 /** @var LoggerInterface */
68 private $logger;
69
70 /** @var callable():int */
71 private $clock;
72
73 /** @var callable(string, string, string, array<string, mixed>):mixed */
74 private $audit;
75
76 /** @var callable(int, string, array<int, mixed>):mixed */
77 private $scheduler;
78
79 /**
80 * @param callable|null $clock Returns the current Unix time.
81 * @param callable|null $audit `(type, severity, message, details)`.
82 * @param callable|null $scheduler `(timestamp, hook, args)`.
83 */
84 public function __construct(
85 FollowUpRepositoryInterface $repository,
86 FollowUpAdapterRegistry $registry,
87 LoggerInterface $logger,
88 ?callable $clock = null,
89 ?callable $audit = null,
90 ?callable $scheduler = null
91 ) {
92 $this->repository = $repository;
93 $this->registry = $registry;
94 $this->logger = $logger;
95 $this->clock = $clock ?? static function (): int {
96 return time();
97 };
98 $this->audit = $audit ?? static function ( string $type, string $severity, string $message, array $details ) {
99 return AuditLogger::log( $type, $severity, $message, $details );
100 };
101 $this->scheduler = $scheduler ?? static function ( int $timestamp, string $hook, array $args ) {
102 if ( function_exists( 'wp_schedule_single_event' ) ) {
103 return wp_schedule_single_event( $timestamp, $hook, $args );
104 }
105 return false;
106 };
107 }
108
109 /**
110 * Accessor for the legacy layer (compatibility/), which cannot take
111 * constructor injection. Set by FollowUpServiceProvider.
112 */
113 public static function instance(): ?self {
114 return self::$instance;
115 }
116
117 public static function setInstance( ?self $instance ): void {
118 self::$instance = $instance;
119 }
120
121 public function getRegistry(): FollowUpAdapterRegistry {
122 return $this->registry;
123 }
124
125 public function adapterFor( OptIn $optIn ): ?FollowUpAdapterInterface {
126 return $this->registry->forOptIn( $optIn );
127 }
128
129 /**
130 * Bind the follow-up plan of an opt-in. Called during confirmation,
131 * BEFORE the confirmation is saved: if the request dies in between,
132 * the rows exist and the sweep finishes the work; if the confirmation
133 * save fails, nothing runs because execution requires a confirmed
134 * opt-in. Idempotent.
135 *
136 * @param bool $defaultMailEnabled Result of `f12_cf7_doubleoptin_send_default_mail`.
137 *
138 * @return bool False when no adapter handles this opt-in (the caller
139 * then keeps its previous behaviour).
140 */
141 public function plan( OptIn $optIn, bool $defaultMailEnabled ): bool {
142 $adapter = $this->adapterFor( $optIn );
143 if ( $adapter === null ) {
144 return false;
145 }
146
147 if ( ! empty( $this->repository->findByOptIn( $optIn->get_id() ) ) ) {
148 return true;
149 }
150
151 try {
152 $actions = $adapter->planActions( $optIn );
153 } catch ( \Throwable $e ) {
154 // A form that cannot be read (deleted, plugin half-updated) is
155 // itself a result worth recording.
156 $this->logger->error(
157 'Follow-up planning failed',
158 array(
159 'plugin' => 'double-opt-in',
160 'optin_id' => $optIn->get_id(),
161 'integration' => $adapter->getIntegration(),
162 'exception' => get_class( $e ),
163 )
164 );
165 $actions = array( new FollowUpAction( 'plan', FollowUpAction::KIND_MARKER, 'Plan', false ) );
166 $this->repository->plan( $optIn->get_id(), $adapter->getIntegration(), $actions, array(), FollowUpAction::fingerprint( $actions ), $this->now() );
167 $this->completeImmediately( $optIn, 'plan', FollowUpResult::failedPermanent( 'plan_failed' ) );
168 return true;
169 }
170
171 $skipReasons = array();
172 foreach ( $actions as $action ) {
173 if ( $action->getSkipReason() !== '' ) {
174 $skipReasons[ $action->getId() ] = $action->getSkipReason();
175 } elseif ( ! $defaultMailEnabled && $action->isGatedByDefaultMail() ) {
176 $skipReasons[ $action->getId() ] = 'send_default_mail_disabled';
177 }
178 }
179
180 if ( empty( $actions ) ) {
181 $actions = array( new FollowUpAction( FollowUpAction::ID_NONE, FollowUpAction::KIND_MARKER, 'No follow-up actions', false ) );
182 $skipReasons = array( FollowUpAction::ID_NONE => 'no_actions_configured' );
183 }
184
185 $inserted = $this->repository->plan(
186 $optIn->get_id(),
187 $adapter->getIntegration(),
188 $actions,
189 $skipReasons,
190 FollowUpAction::fingerprint( $actions ),
191 $this->now()
192 );
193
194 $this->logger->info(
195 'Follow-up actions planned',
196 array(
197 'plugin' => 'double-opt-in',
198 'optin_id' => $optIn->get_id(),
199 'integration' => $adapter->getIntegration(),
200 'planned' => $inserted,
201 'skipped' => count( $skipReasons ),
202 )
203 );
204
205 return true;
206 }
207
208 /**
209 * Execute the eligible actions of an opt-in.
210 *
211 * @param string $trigger One of FollowUpAttempt::TRIGGER_*.
212 * @param array{include_unknown?: bool, action_ids?: string[]} $options
213 *
214 * @return FollowUpRecord[] The opt-in's rows after the run.
215 */
216 public function run( OptIn $optIn, string $trigger, array $options = array() ): array {
217 $adapter = $this->adapterFor( $optIn );
218 $optInId = $optIn->get_id();
219
220 if ( $adapter === null || $optInId <= 0 || ! $optIn->is_confirmed() ) {
221 return $this->repository->findByOptIn( $optInId );
222 }
223
224 // Consent withdrawn: nothing further may happen for this address.
225 if ( $optIn->is_optout() ) {
226 $this->repository->skipOpen( $optInId, 'opted_out', $this->now() );
227 return $this->repository->findByOptIn( $optInId );
228 }
229
230 $records = $this->repository->findByOptIn( $optInId );
231 $eligible = $this->selectEligible( $records, $trigger, $options );
232
233 if ( $trigger === FollowUpAttempt::TRIGGER_MANUAL ) {
234 // Who retried what is part of the record — above all when an
235 // administrator accepted the risk of a duplicate.
236 $includeUnknown = ! empty( $options['include_unknown'] );
237 ( $this->audit )(
238 AuditLogger::TYPE_FOLLOW_UP,
239 $includeUnknown ? AuditLogger::SEVERITY_WARNING : AuditLogger::SEVERITY_INFO,
240 $includeUnknown
241 ? 'Manual follow-up retry including actions with unknown outcome'
242 : 'Manual follow-up retry',
243 array(
244 'event' => 'follow_up.manual_retry',
245 'optin_id' => $optInId,
246 'include_unknown' => $includeUnknown,
247 'action_ids' => array_values( array_map( 'strval', (array) ( $options['action_ids'] ?? array() ) ) ),
248 'eligible' => count( $eligible ),
249 )
250 );
251 }
252
253 if ( empty( $eligible ) ) {
254 return $records;
255 }
256
257 $attempt = new FollowUpAttempt( self::generateId(), $trigger, $optInId );
258 $now = $this->now();
259 $lease = $this->format( ( $this->clock )() + self::LEASE_SECONDS );
260
261 /** @var FollowUpRecord[] $claimed */
262 $claimed = array();
263 foreach ( $eligible as $record ) {
264 if ( $this->repository->claim( $record->id, array( $record->status ), $attempt->getId(), $trigger, $now, $lease ) ) {
265 $claimed[ $record->actionId ] = $record;
266 }
267 }
268
269 if ( empty( $claimed ) ) {
270 // Another request won every claim — it owns this attempt.
271 return $this->repository->findByOptIn( $optInId );
272 }
273
274 $actions = array();
275 foreach ( $claimed as $record ) {
276 $actions[] = $record->toAction();
277 }
278
279 $this->logger->info(
280 'Follow-up attempt started',
281 array(
282 'plugin' => 'double-opt-in',
283 'optin_id' => $optInId,
284 'integration' => $adapter->getIntegration(),
285 'attempt_id' => $attempt->getId(),
286 'trigger' => $trigger,
287 'actions' => array_keys( $claimed ),
288 )
289 );
290
291 $startedAt = microtime( true );
292 $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 );
307 }
308 $durationMs = (int) round( ( microtime( true ) - $startedAt ) * 1000 );
309
310 $finishedAt = $this->now();
311 $report = array();
312 $nextRetry = 0;
313 foreach ( $claimed as $actionId => $record ) {
314 $result = $results[ $actionId ] ?? null;
315 if ( ! $result instanceof FollowUpResult ) {
316 $result = FollowUpResult::unknown( $fallback );
317 }
318
319 $attemptNumber = $record->attempts + 1;
320 // Position in the automatic-retry budget. A manual retry starts
321 // a fresh budget (the admin fixed the cause); mirrors the
322 // repository's claim().
323 $budgetNumber = $trigger === FollowUpAttempt::TRIGGER_MANUAL ? 1 : $record->budgetAttempts + 1;
324 $next = '';
325 if ( $result->getStatus() === FollowUpStatus::FAILED_RETRYABLE ) {
326 $delay = $this->backoffDelay( $budgetNumber );
327 if ( $delay === null ) {
328 $result = $result->withStatus( FollowUpStatus::FAILED_PERMANENT );
329 } else {
330 $at = ( $this->clock )() + $delay;
331 $next = $this->format( $at );
332 $nextRetry = $nextRetry === 0 ? $at : min( $nextRetry, $at );
333 }
334 }
335
336 $this->repository->complete( $record->id, $attempt->getId(), $result, $finishedAt, $next );
337
338 $report[] = array_merge(
339 array(
340 'action_id' => $actionId,
341 'attempt_number' => $attemptNumber,
342 'next_attempt' => $next,
343 ),
344 $result->toArray()
345 );
346 }
347
348 if ( $nextRetry > 0 ) {
349 ( $this->scheduler )( $nextRetry, self::CRON_RETRY_HOOK, array( $optInId ) );
350 }
351
352 $records = $this->repository->findByOptIn( $optInId );
353 $statuses = array();
354 foreach ( $records as $record ) {
355 $statuses[ $record->actionId ] = $record->status;
356 }
357
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 );
369 }
370
371 $this->auditAttempt( $optIn, $adapter, $attempt, $statuses, $report, $durationMs );
372
373 return $records;
374 }
375
376 /**
377 * Cron: expire dead leases, then run everything that is due.
378 *
379 * @param callable(int):?OptIn $loader Loads an opt-in by id.
380 *
381 * @return int Number of opt-ins processed.
382 */
383 public function sweep( callable $loader, int $limit = 20 ): int {
384 $this->repository->deleteOrphans( 500 );
385
386 $expired = $this->repository->expireLeases( $this->now() );
387 if ( $expired > 0 ) {
388 ( $this->audit )(
389 AuditLogger::TYPE_FOLLOW_UP,
390 AuditLogger::SEVERITY_WARNING,
391 'Follow-up actions with unknown outcome after an interrupted attempt',
392 array(
393 'event' => 'follow_up.lease_expired',
394 'affected' => $expired,
395 )
396 );
397 }
398
399 $ids = $this->repository->findDueOptInIds(
400 $this->now(),
401 $this->format( ( $this->clock )() - self::STALE_PENDING_SECONDS ),
402 $limit
403 );
404
405 $processed = 0;
406 foreach ( $ids as $id ) {
407 $optIn = $loader( $id );
408 if ( $optIn instanceof OptIn ) {
409 $this->run( $optIn, FollowUpAttempt::TRIGGER_CRON );
410 $processed++;
411 }
412 }
413
414 return $processed;
415 }
416
417 /**
418 * Status overview for the admin / REST.
419 *
420 * @return array{aggregate: string, actions: array<int, array<string, mixed>>}
421 */
422 public function statusFor( int $optInId, bool $confirmed ): array {
423 $records = $this->repository->findByOptIn( $optInId );
424 $statuses = array();
425 $actions = array();
426 foreach ( $records as $record ) {
427 $statuses[] = $record->status;
428 $actions[] = $record->toArray();
429 }
430
431 $aggregate = FollowUpStatus::aggregate( $statuses );
432 if ( $aggregate === FollowUpStatus::AGGREGATE_NONE && $confirmed ) {
433 // Confirmed before follow-up tracking existed (or by an
434 // integration without an adapter). Deliberately not replayed.
435 $aggregate = FollowUpStatus::AGGREGATE_LEGACY_UNKNOWN;
436 }
437
438 return array(
439 'aggregate' => $aggregate,
440 'actions' => $actions,
441 );
442 }
443
444 /**
445 * Issue a single-use replay ticket bound to one opt-in and the rows
446 * of one attempt. Only the SHA-256 is stored.
447 */
448 public function issueTicket( int $optInId, string $attemptId ): string {
449 $ticket = self::generateId() . self::generateId();
450 $this->repository->setTicket(
451 $optInId,
452 $attemptId,
453 hash( 'sha256', $ticket ),
454 $this->format( ( $this->clock )() + self::TICKET_TTL_SECONDS )
455 );
456 return $ticket;
457 }
458
459 /**
460 * Consume a replay ticket. Returns the action ids it authorises, or
461 * null for an unknown, expired or already used ticket.
462 *
463 * @return string[]|null
464 */
465 public function consumeTicket( int $optInId, string $ticket ): ?array {
466 if ( $optInId <= 0 || strlen( $ticket ) < 32 || strlen( $ticket ) > 128 ) {
467 return null;
468 }
469 return $this->repository->consumeTicket( $optInId, hash( 'sha256', $ticket ), $this->now() );
470 }
471
472 /**
473 * Cascade on opt-in deletion (retention, manual delete, eraser).
474 */
475 public function forget( int $optInId ): void {
476 if ( $optInId > 0 ) {
477 $this->repository->deleteByOptIn( $optInId );
478 }
479 }
480
481 /**
482 * Backoff in seconds before automatic retry N (1-based position of
483 * the attempt that just failed within the current budget — reset by
484 * every manual retry), or null when automatic retries are exhausted.
485 */
486 private function backoffDelay( int $attemptNumber ): ?int {
487 $schedule = array( 60, 300, 1800 );
488 if ( function_exists( 'apply_filters' ) ) {
489 /**
490 * Delays (seconds) between automatic retries of follow-up
491 * actions that demonstrably did not run. The number of
492 * entries is the maximum number of automatic retries.
493 *
494 * @param int[] $schedule
495 *
496 * @since 5.6.0
497 */
498 $filtered = apply_filters( 'f12_doi_follow_up_backoff', $schedule );
499 if ( is_array( $filtered ) ) {
500 $schedule = array_values( array_map( 'intval', $filtered ) );
501 }
502 }
503
504 $index = $attemptNumber - 1;
505 if ( ! isset( $schedule[ $index ] ) || $schedule[ $index ] < 0 ) {
506 return null;
507 }
508 return $schedule[ $index ];
509 }
510
511 /**
512 * @param FollowUpRecord[] $records
513 * @param array{include_unknown?: bool, action_ids?: string[]} $options
514 *
515 * @return FollowUpRecord[]
516 */
517 private function selectEligible( array $records, string $trigger, array $options ): array {
518 $nowTs = ( $this->clock )();
519
520 switch ( $trigger ) {
521 case FollowUpAttempt::TRIGGER_MANUAL:
522 $allowed = array( FollowUpStatus::PENDING, FollowUpStatus::FAILED_RETRYABLE, FollowUpStatus::FAILED_PERMANENT );
523 if ( ! empty( $options['include_unknown'] ) ) {
524 $allowed[] = FollowUpStatus::UNKNOWN;
525 }
526 break;
527 case FollowUpAttempt::TRIGGER_CRON:
528 $allowed = array( FollowUpStatus::PENDING, FollowUpStatus::FAILED_RETRYABLE );
529 break;
530 default:
531 $allowed = array( FollowUpStatus::PENDING );
532 }
533
534 $only = isset( $options['action_ids'] ) && is_array( $options['action_ids'] )
535 ? array_map( 'strval', $options['action_ids'] )
536 : null;
537
538 $eligible = array();
539 foreach ( $records as $record ) {
540 if ( $record->actionKind === FollowUpAction::KIND_MARKER ) {
541 continue;
542 }
543 if ( ! in_array( $record->status, $allowed, true ) ) {
544 continue;
545 }
546 if ( $only !== null && ! in_array( $record->actionId, $only, true ) ) {
547 continue;
548 }
549 if ( $trigger === FollowUpAttempt::TRIGGER_CRON
550 && $record->status === FollowUpStatus::FAILED_RETRYABLE
551 && ( $record->nextAttemptAt === '' || strtotime( $record->nextAttemptAt . ' UTC' ) > $nowTs )
552 ) {
553 continue;
554 }
555 $eligible[] = $record;
556 }
557
558 return $eligible;
559 }
560
561 /**
562 * Record a result for a row that never needs execution (planning
563 * failure). Claims first so the unique claim path stays the only
564 * writer of results.
565 */
566 private function completeImmediately( OptIn $optIn, string $actionId, FollowUpResult $result ): void {
567 $attemptId = self::generateId();
568 $now = $this->now();
569 foreach ( $this->repository->findByOptIn( $optIn->get_id() ) as $record ) {
570 if ( $record->actionId === $actionId
571 && $this->repository->claim( $record->id, array( FollowUpStatus::PENDING ), $attemptId, FollowUpAttempt::TRIGGER_CONFIRM, $now, $now )
572 ) {
573 $this->repository->complete( $record->id, $attemptId, $result, $now, '' );
574 }
575 }
576 }
577
578 /**
579 * One audit event per attempt, with the per-action outcome in the
580 * details. Severity follows the aggregate so failures are findable
581 * in the audit log without debug logging enabled.
582 *
583 * @param array<string, string> $statuses
584 * @param array<int, array<string, mixed>> $report
585 */
586 private function auditAttempt( OptIn $optIn, FollowUpAdapterInterface $adapter, FollowUpAttempt $attempt, array $statuses, array $report, int $durationMs ): void {
587 $aggregate = FollowUpStatus::aggregate( array_values( $statuses ) );
588
589 switch ( $aggregate ) {
590 case FollowUpStatus::AGGREGATE_COMPLETED:
591 $severity = AuditLogger::SEVERITY_INFO;
592 $message = 'Follow-up actions completed';
593 break;
594 case FollowUpStatus::AGGREGATE_PENDING:
595 $severity = AuditLogger::SEVERITY_WARNING;
596 $message = 'Follow-up actions failed, retry scheduled';
597 break;
598 case FollowUpStatus::AGGREGATE_UNKNOWN:
599 $severity = AuditLogger::SEVERITY_WARNING;
600 $message = 'Follow-up actions with unknown outcome';
601 break;
602 case FollowUpStatus::AGGREGATE_PARTIAL:
603 $severity = AuditLogger::SEVERITY_ERROR;
604 $message = 'Follow-up actions partially failed';
605 break;
606 default:
607 $severity = AuditLogger::SEVERITY_ERROR;
608 $message = 'Follow-up actions failed';
609 }
610
611 $details = array(
612 'event' => 'follow_up.attempt',
613 'optin_id' => $optIn->get_id(),
614 'integration' => $adapter->getIntegration(),
615 'form_id' => $optIn->get_cf_form_id(),
616 'attempt_id' => $attempt->getId(),
617 'trigger' => $attempt->getTrigger(),
618 'aggregate' => $aggregate,
619 'duration_ms' => $durationMs,
620 'actions' => $report,
621 );
622
623 ( $this->audit )( AuditLogger::TYPE_FOLLOW_UP, $severity, $message, $details );
624
625 $this->logger->info(
626 $message,
627 array(
628 'plugin' => 'double-opt-in',
629 'optin_id' => $optIn->get_id(),
630 'attempt_id' => $attempt->getId(),
631 'aggregate' => $aggregate,
632 )
633 );
634 }
635
636 private function now(): string {
637 return $this->format( ( $this->clock )() );
638 }
639
640 private function format( int $timestamp ): string {
641 return gmdate( 'Y-m-d H:i:s', $timestamp );
642 }
643
644 /**
645 * 32 hex characters from a CSPRNG.
646 */
647 public static function generateId(): string {
648 return bin2hex( random_bytes( 16 ) );
649 }
650 }
651