PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.0
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 3.0.70 3.0.71 3.0.72 All 35 releases
double-opt-in / src / Integration / AbstractFormIntegration.php

AbstractFormIntegration.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.6.0, at src/Integration/AbstractFormIntegration.php

1,494 lines 43.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abstract Form Integration
4 *
5 * @package Forge12\DoubleOptIn\Integration
6 * @since 4.0.0
7 */
8
9 namespace Forge12\DoubleOptIn\Integration;
10
11 use Forge12\DoubleOptIn\Consent\ConsentGate;
12 use Forge12\DoubleOptIn\Container\Container;
13 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
14 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
15 use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
16 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
17 use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
18 use Forge12\DoubleOptIn\Files\FileStorage;
19 use Forge12\DoubleOptIn\FollowUp\FollowUpAttempt;
20 use Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator;
21 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
22 use Forge12\DoubleOptIn\Service\RateLimiter;
23 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
24 use forge12\contactform7\CF7DoubleOptIn\IPHelper;
25 use forge12\contactform7\CF7DoubleOptIn\OptIn;
26 use forge12\contactform7\CF7DoubleOptIn\OptInFrontend;
27 use forge12\contactform7\CF7DoubleOptIn\Telemetry;
28 use Forge12\Shared\LoggerInterface;
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 /**
35 * Class AbstractFormIntegration
36 *
37 * @api
38 *
39 * Base class providing common functionality for all form integrations.
40 * Extracted from the legacy OptInFrontend class to provide reusable logic.
41 * Addons that integrate a form system extend this class to reduce
42 * boilerplate — see docs/addon-api.md §4.3. Covered by the Addon API
43 * semver policy as of Core API 4.3.0.
44 */
45 abstract class AbstractFormIntegration implements FormIntegrationInterface {
46
47 /**
48 * Logger instance.
49 *
50 * @var LoggerInterface
51 */
52 protected LoggerInterface $logger;
53
54 /**
55 * Validation status from the last validateOptIn() call.
56 *
57 * @var string
58 */
59 private static string $validationStatus = '';
60
61 /**
62 * Stored hCaptcha CF7 instance for restore after mail send.
63 *
64 * @var object|null
65 */
66 private $hcaptchaCf7Instance = null;
67
68 /**
69 * Stored hCaptcha CF7 filter priority for restore after mail send.
70 *
71 * @var int
72 */
73 private int $hcaptchaCf7Priority = 20;
74
75 /**
76 * Last error from the most recent createOptIn() call.
77 *
78 * @var OptInError|null
79 */
80 private static ?OptInError $lastError = null;
81
82 /**
83 * Get the validation status from the last validateOptIn() call.
84 *
85 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
86 */
87 /**
88 * Nesting depth of post-confirmation replays in this request.
89 *
90 * @var int
91 */
92 private static $replayDepth = 0;
93
94 /**
95 * True while a post-confirmation replay (re-submitting the stored
96 * data to the form plugin) runs in this request. Set only by server
97 * code via {@see runAsReplay()} — never derived from request input.
98 *
99 * @since 5.6.0
100 */
101 public static function isReplaying(): bool {
102 return self::$replayDepth > 0;
103 }
104
105 /**
106 * Run $fn as a post-confirmation replay: form-submit hooks that fire
107 * inside it (wpcf7_before_send_mail, gform_after_submission, …) see
108 * {@see isReplaying()} and do not create a new opt-in.
109 *
110 * @template T
111 * @param callable():T $fn
112 *
113 * @return T
114 *
115 * @since 5.6.0
116 */
117 public static function runAsReplay( callable $fn ) {
118 self::$replayDepth++;
119 try {
120 return $fn();
121 } finally {
122 self::$replayDepth--;
123 }
124 }
125
126 public static function getValidationStatus(): string {
127 return self::$validationStatus;
128 }
129
130 /**
131 * Set the validation status.
132 *
133 * @param string $status The validation status.
134 */
135 private static function setValidationStatus( string $status ): void {
136 self::$validationStatus = $status;
137 }
138
139 /**
140 * Get the last error from the most recent createOptIn() call.
141 *
142 * @return OptInError|null The error, or null if no error occurred.
143 */
144 public static function getLastError(): ?OptInError {
145 return self::$lastError;
146 }
147
148 /**
149 * Clear the last error.
150 */
151 private static function clearLastError(): void {
152 self::$lastError = null;
153 }
154
155 /**
156 * Set the last error and store it for the frontend notification system.
157 *
158 * @param OptInError $error The error that occurred.
159 * @param int $formId The form ID.
160 */
161 private static function setLastError( OptInError $error, int $formId ): void {
162 self::$lastError = $error;
163 ErrorNotification::store( $error, $formId );
164 }
165
166 /**
167 * Get the recipient validation error from the last createOptIn() call.
168 *
169 * @deprecated Use getLastError() instead.
170 *
171 * @return string The error message, or empty string if no error.
172 */
173 public static function getLastRecipientValidationError(): string {
174 if ( self::$lastError && self::$lastError->getCode() === OptInError::RECIPIENT_INVALID ) {
175 return self::$lastError->getMessage();
176 }
177 return '';
178 }
179
180 /**
181 * Constructor.
182 *
183 * @param LoggerInterface $logger The logger instance.
184 */
185 public function __construct( LoggerInterface $logger ) {
186 $this->logger = $logger;
187 }
188
189 /**
190 * Get the logger instance.
191 *
192 * @return LoggerInterface
193 */
194 protected function getLogger(): LoggerInterface {
195 return $this->logger;
196 }
197
198 /**
199 * {@inheritdoc}
200 */
201 public function getHookPriority(): int {
202 return 10;
203 }
204
205 /**
206 * {@inheritdoc}
207 */
208 public function getFormParameter( int $formId ): array {
209 return CF7DoubleOptIn::getInstance()->getParameter( $formId );
210 }
211
212 /**
213 * {@inheritdoc}
214 */
215 public function isOptInEnabled( int $formId ): bool {
216 // Disable while our own post-confirmation replay runs — the stored
217 // submission must be processed, not turned into a new opt-in.
218 //
219 // This used to test `isset( $_GET['optin'] )`. That flag is set by
220 // whoever sends the request: `…/feedback?optin=1` on the CF7 REST
221 // route submitted a DOI form with every mail and no confirmation.
222 // It also broke every replay outside the confirmation request
223 // (cron, admin retry), which has no `?optin` in its URL.
224 if ( self::isReplaying() ) {
225 $this->getLogger()->debug(
226 'Opt-in disabled during post-confirmation replay',
227 array(
228 'plugin' => 'double-opt-in',
229 'class' => static::class,
230 )
231 );
232 return false;
233 }
234
235 $parameter = $this->getFormParameter( $formId );
236
237 if ( (int) ( $parameter['enable'] ?? 0 ) !== 1 ) {
238 $this->getLogger()->debug(
239 'Opt-in not enabled in form parameter',
240 array(
241 'plugin' => 'double-opt-in',
242 'form_id' => $formId,
243 )
244 );
245 return false;
246 }
247
248 // Check the custom condition
249 if ( isset( $parameter['conditions'] ) ) {
250 $condition = sanitize_text_field( $parameter['conditions'] );
251
252 if ( ( $condition !== 'disable' && $condition !== 'disabled' )
253 && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
254 $this->getLogger()->debug(
255 'Opt-in disabled due to unmet custom condition',
256 array(
257 'plugin' => 'double-opt-in',
258 'condition' => $condition,
259 )
260 );
261 return false;
262 }
263 }
264
265 return true;
266 }
267
268 /**
269 * Create an OptIn record from form data.
270 *
271 * @param FormDataInterface $formData The normalized form data.
272 * @param array $formParameter The form configuration.
273 *
274 * @return OptIn|null The created OptIn or null on failure.
275 */
276 protected function createOptIn( FormDataInterface $formData, array $formParameter ): ?OptIn {
277 $this->getLogger()->debug(
278 'Creating OptIn record',
279 array(
280 'plugin' => 'double-opt-in',
281 'class' => static::class,
282 'form_id' => $formData->getFormId(),
283 'form_type' => $formData->getFormType(),
284 )
285 );
286
287 // Clear previous error
288 self::clearLastError();
289
290 // Dispatch FormSubmissionEvent to allow modifications/cancellation
291
292 $event = $this->dispatchFormSubmissionEvent( $formData );
293 if ( $event && $event->shouldSkipOptIn() ) {
294 $this->getLogger()->info(
295 'OptIn skipped by FormSubmissionEvent',
296 array(
297 'plugin' => 'double-opt-in',
298 'form_id' => $formData->getFormId(),
299 )
300 );
301 self::setLastError(
302 OptInError::fromCode( OptInError::SUBMISSION_CANCELLED, array( 'form_id' => $formData->getFormId() ) ),
303 $formData->getFormId()
304 );
305 return null;
306 }
307
308 // Store uploaded files
309 $files = $this->storeFiles( $formData->getFiles() );
310
311 // Filter parameters before saving
312 $fields = apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $formData->getFields() );
313
314 // Resolve recipient email
315 $recipient = $this->resolveRecipient( $formData, $formParameter );
316
317 if ( empty( $recipient ) ) {
318 $this->getLogger()->warning(
319 'No recipient found, skipping OptIn creation',
320 array(
321 'plugin' => 'double-opt-in',
322 'form_id' => $formData->getFormId(),
323 )
324 );
325 self::setLastError(
326 OptInError::fromCode( OptInError::NO_RECIPIENT, array( 'form_id' => $formData->getFormId() ) ),
327 $formData->getFormId()
328 );
329 return null;
330 }
331
332 $consentError = $this->validateConsentAcceptance( $formData, $formParameter );
333 if ( $consentError !== null ) {
334 $this->getLogger()->info(
335 'Consent acceptance not given, rejecting OptIn',
336 array(
337 'plugin' => 'double-opt-in',
338 'form_id' => $formData->getFormId(),
339 'consent_field' => $consentError->getContext()['consent_field'] ?? '',
340 )
341 );
342 do_action(
343 'f12_cf7_doubleoptin_consent_not_given',
344 $formData->getFormId(),
345 $consentError->getContext()['consent_field'] ?? ''
346 );
347 self::setLastError( $consentError, $formData->getFormId() );
348 return null;
349 }
350
351 // Rate-Limiting: Check IP and email limits before creating OptIn
352 $rateLimiter = new RateLimiter();
353 $settings = CF7DoubleOptIn::getInstance()->getSettings();
354 $rateLimitIp = (int) ( $settings['rate_limit_ip'] ?? 5 );
355 $rateLimitEmail = (int) ( $settings['rate_limit_email'] ?? 3 );
356 $rateLimitWindow = (int) ( $settings['rate_limit_window'] ?? 60 );
357
358 $ip = IPHelper::getIPAdress();
359 if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
360 $this->getLogger()->warning(
361 'Rate limit exceeded for IP',
362 array(
363 'plugin' => 'double-opt-in',
364 'ip' => $ip,
365 'form_id' => $formData->getFormId(),
366 )
367 );
368 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formData->getFormId() );
369 self::setLastError(
370 OptInError::fromCode(
371 OptInError::RATE_LIMIT_IP,
372 array(
373 'ip' => $ip,
374 'form_id' => $formData->getFormId(),
375 )
376 ),
377 $formData->getFormId()
378 );
379 return null;
380 }
381
382 if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
383 $this->getLogger()->warning(
384 'Rate limit exceeded for email',
385 array(
386 'plugin' => 'double-opt-in',
387 'email' => $recipient,
388 'form_id' => $formData->getFormId(),
389 )
390 );
391 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formData->getFormId() );
392 self::setLastError(
393 OptInError::fromCode(
394 OptInError::RATE_LIMIT_EMAIL,
395 array(
396 'email' => $recipient,
397 'form_id' => $formData->getFormId(),
398 )
399 ),
400 $formData->getFormId()
401 );
402 return null;
403 }
404
405 // Validate recipient (extensible via Pro MX check)
406 $recipientValid = apply_filters(
407 'f12_cf7_doubleoptin_validate_recipient',
408 true,
409 $recipient,
410 $formData
411 );
412
413 if ( $recipientValid !== true ) {
414 $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
415 $errorCode = OptInError::RECIPIENT_INVALID;
416
417 // Unique Email rejection gets its own error code
418 if ( $errorMsg === 'unique_email_rejected' ) {
419 $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
420 $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
421 }
422
423 $this->getLogger()->warning(
424 'Recipient validation failed',
425 array(
426 'plugin' => 'double-opt-in',
427 'email' => $recipient,
428 'form_id' => $formData->getFormId(),
429 'reason' => $errorMsg,
430 )
431 );
432 do_action( 'f12_cf7_doubleoptin_recipient_invalid', $recipient, $formData->getFormId(), $errorMsg );
433 self::setLastError(
434 new OptInError(
435 $errorCode,
436 ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
437 array(
438 'email' => $recipient,
439 'form_id' => $formData->getFormId(),
440 )
441 ),
442 $formData->getFormId()
443 );
444 return null;
445 }
446
447 $properties = $this->buildOptInProperties( $formData, $formParameter, $recipient, $fields, $files );
448
449 $optIn = new OptIn( $this->getLogger(), $properties );
450
451 if ( $optIn->save() ) {
452 $this->getLogger()->info(
453 'OptIn record created successfully',
454 array(
455 'plugin' => 'double-opt-in',
456 'optin_id' => $optIn->get_id(),
457 'form_id' => $formData->getFormId(),
458 )
459 );
460
461 // Dispatch typed event
462 $this->dispatchOptInCreatedEvent( $optIn, $formData );
463
464 // Track telemetry
465 $telemetry = new Telemetry( $this->getLogger() );
466 $telemetry->increment( 'total_optins' );
467 $telemetry->increment( $this->getIdentifier() . '_optins' );
468
469 return $optIn;
470 }
471
472 $this->getLogger()->error(
473 'Failed to save OptIn record',
474 array(
475 'plugin' => 'double-opt-in',
476 'form_id' => $formData->getFormId(),
477 )
478 );
479
480 do_action( 'f12_cf7_doubleoptin_creation_failed', $formData->getFormId(), $recipient );
481
482 self::setLastError(
483 OptInError::fromCode( OptInError::SAVE_FAILED, array( 'form_id' => $formData->getFormId() ) ),
484 $formData->getFormId()
485 );
486
487 return null;
488 }
489
490 /**
491 * Validate the consent-acceptance gate (GDPR Art. 7).
492 *
493 * The decision itself lives in {@see ConsentGate} — this method only
494 * turns it into the return value `createOptIn()` expects and writes
495 * the log lines. Both halves of the plugin share that class now: the
496 * integrations that extend this base, and the legacy path in
497 * `OptInFrontend::maybeCreateOptIn()` that serves Elementor and the
498 * CF7/Avada shims.
499 *
500 * `ConsentGate::FIELD_UNKNOWN` — the configured field is not on this
501 * form — deliberately does NOT reject. Until 5.4.0 it did, which took
502 * a site's registrations offline over a settings mistake the visitor
503 * could neither see nor fix (customer report 2026-08-27). The admin
504 * now hears about it through the log, the Site Health check and the
505 * banner on the form's settings tab instead.
506 *
507 * @param FormDataInterface $formData The submitted form data.
508 * @param array<string, mixed> $formParameter The form-settings snapshot.
509 *
510 * @return OptInError|null Error when the gate rejects; null when the
511 * submission may proceed.
512 */
513 protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
514 $consentField = (string) ( $formParameter['consent_field'] ?? '' );
515 if ( $consentField === '' ) {
516 return null;
517 }
518
519 $verdict = ConsentGate::evaluate(
520 $consentField,
521 $formData->getFields(),
522 $this->getKnownFieldNames( $formData->getFormId() )
523 );
524
525 if ( $verdict === ConsentGate::PASSED ) {
526 return null;
527 }
528
529 if ( $verdict === ConsentGate::FIELD_UNKNOWN ) {
530 // Never a rejection — see the ConsentGate docblock. Logged as
531 // a warning all the same: until someone fixes the setting, the
532 // consent proof stored with every opt-in of this form is
533 // worthless.
534 $this->getLogger()->warning(
535 'Consent field is not on this form — opt-in accepted without provable consent',
536 array(
537 'plugin' => 'double-opt-in',
538 'form_id' => $formData->getFormId(),
539 'integration' => $this->getIdentifier(),
540 'consent_field' => $consentField,
541 )
542 );
543
544 /**
545 * Fires when a form's configured acceptance field cannot be
546 * found on the form itself. The submission is accepted.
547 *
548 * @since 5.4.0
549 *
550 * @param int $formId The form the submission came from.
551 * @param string $consentField The configured field name.
552 * @param string $integration The integration identifier.
553 */
554 do_action(
555 'f12_doi_consent_field_unknown',
556 $formData->getFormId(),
557 $consentField,
558 $this->getIdentifier()
559 );
560
561 return null;
562 }
563
564 if ( ! ConsentGate::isEnforced( $formData->getFormId(), $this->getIdentifier() ) ) {
565 $this->getLogger()->warning(
566 'Consent gate disabled by filter — accepting an unconfirmed submission',
567 array(
568 'plugin' => 'double-opt-in',
569 'form_id' => $formData->getFormId(),
570 'integration' => $this->getIdentifier(),
571 'consent_field' => $consentField,
572 )
573 );
574
575 return null;
576 }
577
578 return OptInError::fromCode(
579 OptInError::CONSENT_NOT_GIVEN,
580 array(
581 'form_id' => $formData->getFormId(),
582 'consent_field' => $consentField,
583 )
584 );
585 }
586
587 /**
588 * The names of the fields this form actually declares.
589 *
590 * Needed to tell an unticked checkbox — which the browser leaves out
591 * of the payload entirely — from a `consent_field` pointing at a
592 * field the admin has since renamed or deleted. An integration that
593 * cannot answer yields an empty list, and the gate then stays on the
594 * cautious side and rejects nothing it cannot prove.
595 *
596 * @param int $formId The form to inspect.
597 *
598 * @return array<int,string>
599 */
600 protected function getKnownFieldNames( int $formId ): array {
601 try {
602 return ConsentGate::normalizeFieldNames( $this->getFormFields( $formId ) );
603 } catch ( \Throwable $e ) {
604 $this->getLogger()->warning(
605 'Could not read the form field inventory for the consent gate',
606 array(
607 'plugin' => 'double-opt-in',
608 'form_id' => $formId,
609 'error' => $e->getMessage(),
610 )
611 );
612
613 return array();
614 }
615 }
616
617 /**
618 * Build the OptIn properties array that will be persisted on
619 * record creation. Extracted from {@see createOptIn()} so the
620 * field-coverage contract is testable in isolation — the full
621 * createOptIn() flow has too many side-effects (rate-limiting,
622 * file storage, container access) for clean unit testing.
623 *
624 * Snapshot semantics:
625 * - `consent_text` is captured per GDPR Art. 7 — the consent
626 * record reflects the wording the user actually agreed to,
627 * even if the form's settings change later.
628 * - Custom addon fields land via the `f12_doi_optin_properties`
629 * filter; addons that contribute a per-form setting hook here
630 * to persist a snapshot with each opt-in.
631 *
632 * @param FormDataInterface $formData The submitted form data.
633 * @param array<string, mixed> $formParameter The form-settings snapshot.
634 * @param string $recipient The resolved recipient email.
635 * @param array<string, mixed> $fields Filtered form fields.
636 * @param array<string, mixed> $files Stored file references.
637 *
638 * @return array<string, mixed>
639 */
640 protected function buildOptInProperties(
641 FormDataInterface $formData,
642 array $formParameter,
643 string $recipient,
644 array $fields,
645 array $files
646 ): array {
647 $properties = array(
648 'cf_form_id' => $formData->getFormId(),
649 'doubleoptin' => 0,
650 'createtime' => time(),
651 'content' => maybe_serialize( $fields ),
652 'files' => maybe_serialize( $files ),
653 'ipaddr_register' => IPHelper::getIPAdress(),
654 'category' => (int) ( $formParameter['category'] ?? 0 ),
655 'form' => $formData->getFormHtml(),
656 'email' => $recipient,
657 'consent_text' => (string) ( $formParameter['consent_text'] ?? '' ),
658 'consent_field' => (string) ( $formParameter['consent_field'] ?? '' ),
659 );
660
661 /**
662 * Filter the OptIn properties array before the record is
663 * created. Addons hook this to snapshot their own per-form
664 * settings into the opt-in record at submit time. Mirrors the
665 * symmetric DTO filter pattern (`f12_doi_settings_dto_from_array`
666 * / `f12_doi_settings_dto_sanitize`) — an addon that contributes
667 * a per-form setting AND wants it persisted with each opt-in
668 * snapshots it through this filter.
669 *
670 * @since 4.4.0
671 *
672 * @param array<string, mixed> $properties The properties array
673 * for the new OptIn.
674 * @param FormDataInterface $formData The submitted form data.
675 * @param array<string, mixed> $formParameter The form-settings snapshot.
676 */
677 return apply_filters( 'f12_doi_optin_properties', $properties, $formData, $formParameter );
678 }
679
680 /**
681 * Prepare the opt-in mail body with placeholders replaced.
682 *
683 * @param string $body The mail body template.
684 * @param OptIn $optIn The OptIn record.
685 * @param array $formParameter The form configuration.
686 *
687 * @return string The processed mail body.
688 */
689 protected function prepareMailBody( string $body, OptIn $optIn, array $formParameter ): string {
690 // Replace system placeholders
691 $body = $this->addSystemPlaceholders( $body, $optIn, $formParameter );
692
693 // Replace form field placeholders
694 $formData = maybe_unserialize( $optIn->get_content() );
695 if ( is_array( $formData ) ) {
696 // Handle nested content structure (e.g., Avada stores {data: {...}, field_labels: {...}, ...})
697 // Extract the flat field data for placeholder replacement
698 $fieldData = isset( $formData['data'] ) && is_array( $formData['data'] ) ? $formData['data'] : $formData;
699
700 $body = PlaceholderMapper::replacePlaceholders(
701 $body,
702 $fieldData,
703 $optIn->get_cf_form_id(),
704 array(),
705 $this->getIdentifier()
706 );
707 }
708
709 return $body;
710 }
711
712 /**
713 * Add system placeholders to the mail body.
714 *
715 * @param string $body The mail body.
716 * @param OptIn $optIn The OptIn record.
717 * @param array $formParameter The form configuration.
718 *
719 * @return string The body with placeholders replaced.
720 */
721 protected function addSystemPlaceholders( string $body, OptIn $optIn, array $formParameter ): string {
722 $placeholders = array(
723 // User-influenced (the submit page URL incl. query string) — sanitise
724 // as a URL so a crafted `?x="><script>` can't reflect into the mail
725 // HTML. esc_url_raw (not esc_url) keeps ampersands un-entity-encoded
726 // so the plain-text mail variant stays intact too.
727 'doubleoptin_form_url' => esc_url_raw( (string) ( $formParameter['formUrl'] ?? '' ) ),
728 'doubleoptin_form_subject' => $formParameter['subject'] ?? '',
729 // wp_date() formats in the site's timezone without mutating PHP's
730 // global timezone (the old date() + date_default_timezone_set() did).
731 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
732 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
733 'doubleoptin_form_email' => get_option( 'admin_email' ),
734 'doubleoptinlink' => $optIn->get_link_optin( $formParameter ),
735 'doubleoptoutlink' => $optIn->get_link_optout(),
736 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
737 );
738
739 foreach ( $placeholders as $key => $value ) {
740 if ( is_array( $value ) || is_object( $value ) ) {
741 $value = wp_json_encode( $value );
742 }
743 $body = str_replace( '[' . $key . ']', (string) $value, $body );
744 }
745
746 return $body;
747 }
748
749 /**
750 * Get the privacy policy URL.
751 *
752 * @return string The privacy policy URL or empty string.
753 */
754 protected function getPrivacyPolicyUrl(): string {
755 $settings = CF7DoubleOptIn::getInstance()->getSettings();
756 $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
757
758 if ( $pageId > 0 ) {
759 $url = get_permalink( $pageId );
760 if ( $url ) {
761 return $url;
762 }
763 }
764
765 // Fallback to WordPress privacy policy page
766 $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
767 if ( $wpPrivacyPageId > 0 ) {
768 $url = get_permalink( $wpPrivacyPageId );
769 if ( $url ) {
770 return $url;
771 }
772 }
773
774 return '';
775 }
776
777 /**
778 * Store uploaded files for later use after opt-in confirmation.
779 *
780 * @param array $files The uploaded files.
781 *
782 * @return array The stored file paths.
783 */
784 protected function storeFiles( array $files ): array {
785 // File-lifecycle plan, Step 1 (2026-05-07): delegate to the
786 // centralised FileStorage service. This moves files into
787 // `wp-content/uploads/f12-doi/pending/` (deny-from-all) with
788 // random hex names, instead of the pre-4.3 in-tmp-dir copy.
789 // The legacy copyAndRenameFile path below stays for now as a
790 // fallback if FileStorage construction fails — removed in
791 // Schritt 2 once each integration's hand-off is wired up.
792 try {
793 $storage = new FileStorage( $this->logger );
794 return $storage->store( $files );
795 } catch ( \Throwable $e ) {
796 $this->logger->error(
797 'FileStorage unavailable, falling back to legacy in-tmp store',
798 array(
799 'plugin' => 'double-opt-in',
800 'error' => $e->getMessage(),
801 )
802 );
803 }
804
805 // ── Legacy fallback (pre-4.3) ─────────────────────────────────
806 $storedFiles = array();
807
808 if ( empty( $files ) ) {
809 return $storedFiles;
810 }
811
812 foreach ( $files as $key => $fileList ) {
813 if ( ! is_array( $fileList ) ) {
814 $fileList = array( $fileList );
815 }
816
817 foreach ( $fileList as $file ) {
818 if ( empty( $file ) || ! is_file( $file ) ) {
819 continue;
820 }
821
822 $newFile = $this->copyAndRenameFile( $file );
823 if ( $newFile ) {
824 $storedFiles[] = $newFile;
825 }
826 }
827 }
828
829 return $storedFiles;
830 }
831
832 /**
833 * Hand off the opt-in's stored files to the integration's own
834 * form-system entry (Avada form_entries / GF entry / WPForms
835 * entry / CF7 mail attachment). Per-integration override.
836 *
837 * Default behaviour: return false (no hand-off). The files stay
838 * in `pending/` until the OptIn is deleted (cron / manual /
839 * REST), at which point cascade-delete removes them.
840 *
841 * Contract (file-lifecycle plan, 2026-05-07):
842 *
843 * - true: hand-off succeeded. Caller (template-method below)
844 * will delete the pending/ copies — single source of
845 * truth = the integration's own DB. GDPR-compliant by
846 * construction (consent-bound retention).
847 *
848 * - false: hand-off failed or not implemented. Files stay in
849 * pending/ and are removed eventually via OptIn-deletion
850 * cascade (cron expiry or manual delete). No silent
851 * data loss.
852 *
853 * @param OptIn $optIn The just-confirmed opt-in record.
854 *
855 * @return bool true on successful hand-off, false otherwise.
856 *
857 * @since 4.3.0
858 */
859 public function handOffFilesToFormSystem( OptIn $optIn ): bool {
860 // Default: not implemented for this integration. Per-
861 // integration overrides in addon-avada / addon-cf7 / etc.
862 return false;
863 }
864
865 /**
866 * Template-method: process file hand-off when an opt-in confirms.
867 *
868 * Hooked on `f12_cf7_doubleoptin_after_confirm` at priority 5
869 * (BEFORE addon-specific listeners that fire at default 10), so
870 * file hand-off completes before any side-effects that might
871 * rely on the integration's entry being fully populated.
872 *
873 * Each subclass registers this in its own `registerHooks()` —
874 * see Schritt 2 per-integration commits.
875 *
876 * @param string $hash The confirmation hash (action arg).
877 * @param OptIn $optIn The confirmed opt-in (action arg).
878 *
879 * @since 4.3.0
880 */
881 final public function processFilesOnConfirm( string $hash, OptIn $optIn ): void {
882 // Bail for opt-ins that aren't this integration's responsibility.
883 // The after_confirm action fires for ALL types — every integration
884 // hooks it but only acts on its own.
885 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
886 return;
887 }
888
889 $rawFiles = (string) $optIn->get_files();
890 $decoded = $rawFiles === '' ? array() : maybe_unserialize( $rawFiles );
891 $decoded = is_array( $decoded ) ? $decoded : array();
892 $files = array_values(
893 array_filter(
894 $decoded,
895 static function ( $p ) { return is_string( $p ) && $p !== ''; }
896 )
897 );
898
899 if ( empty( $files ) ) {
900 return;
901 }
902
903 $handedOff = $this->handOffFilesToFormSystem( $optIn );
904
905 if ( ! $handedOff ) {
906 $this->logger->info(
907 'File hand-off not performed (or failed) — pending files will be cleaned up on OptIn deletion',
908 array(
909 'plugin' => 'double-opt-in',
910 'integration' => $this->getIdentifier(),
911 'optin_id' => $optIn->get_id(),
912 'file_count' => count( $files ),
913 )
914 );
915 return;
916 }
917
918 // Hand-off succeeded → integration now owns the files in its
919 // own DB. Delete our pending copies to avoid duplicate storage.
920 try {
921 $storage = new FileStorage( $this->logger );
922 $storage->deletePaths( $files );
923
924 // Clear the OptIn::files column so the cascade-delete on
925 // later OptIn removal doesn't try to re-unlink missing
926 // paths. Idempotent if save fails — pending dir is empty
927 // either way.
928 if ( method_exists( $optIn, 'set_files' ) ) {
929 $optIn->set_files( serialize( array() ) );
930 if ( method_exists( $optIn, 'save' ) ) {
931 $optIn->save();
932 }
933 }
934
935 $this->logger->info(
936 'Files handed off to form system + pending copies deleted',
937 array(
938 'plugin' => 'double-opt-in',
939 'integration' => $this->getIdentifier(),
940 'optin_id' => $optIn->get_id(),
941 'file_count' => count( $files ),
942 )
943 );
944 } catch ( \Throwable $e ) {
945 $this->logger->error(
946 'Hand-off succeeded but pending-cleanup failed',
947 array(
948 'plugin' => 'double-opt-in',
949 'error' => $e->getMessage(),
950 )
951 );
952 }
953 }
954
955 /**
956 * Allowed MIME types for file uploads stored with opt-in records.
957 */
958 private const ALLOWED_MIME_TYPES = array(
959 'jpg' => 'image/jpeg',
960 'jpeg' => 'image/jpeg',
961 'png' => 'image/png',
962 'gif' => 'image/gif',
963 'webp' => 'image/webp',
964 'pdf' => 'application/pdf',
965 'doc' => 'application/msword',
966 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
967 'txt' => 'text/plain',
968 'csv' => 'text/csv',
969 );
970
971 /**
972 * Copy and rename a file with a unique, non-guessable name.
973 *
974 * Validates the file MIME type against an allowlist before copying.
975 *
976 * @param string $file The source file path.
977 *
978 * @return string|null The new file path or null on failure/rejection.
979 */
980 private function copyAndRenameFile( string $file ): ?string {
981 $allowedMimes = apply_filters( 'f12_cf7_doubleoptin_allowed_mime_types', self::ALLOWED_MIME_TYPES );
982
983 $fileType = wp_check_filetype_and_ext( $file, wp_basename( $file ), $allowedMimes );
984
985 if ( empty( $fileType['type'] ) || empty( $fileType['ext'] ) ) {
986 $this->getLogger()->warning(
987 'File rejected: MIME type not allowed',
988 array(
989 'plugin' => 'double-opt-in',
990 'original' => $file,
991 )
992 );
993 return null;
994 }
995
996 $pathParts = explode( '/', $file );
997 array_pop( $pathParts );
998 $newName = bin2hex( random_bytes( 16 ) ) . '.' . $fileType['ext'];
999 $pathParts[] = $newName;
1000 $newFile = implode( '/', $pathParts );
1001
1002 if ( copy( $file, $newFile ) ) {
1003 $this->getLogger()->debug(
1004 'File copied successfully',
1005 array(
1006 'plugin' => 'double-opt-in',
1007 'original' => $file,
1008 'new_file' => $newFile,
1009 )
1010 );
1011 return $newFile;
1012 }
1013
1014 $this->getLogger()->error(
1015 'Failed to copy file',
1016 array(
1017 'plugin' => 'double-opt-in',
1018 'original' => $file,
1019 )
1020 );
1021
1022 return null;
1023 }
1024
1025 /**
1026 * Validate and confirm an opt-in by hash.
1027 *
1028 * @param string $hash The opt-in hash.
1029 *
1030 * @return bool True if the opt-in was confirmed successfully.
1031 */
1032 public function validateOptIn( string $hash ): bool {
1033 $optIn = OptIn::get_by_hash( $hash );
1034
1035 if ( ! $optIn ) {
1036 $this->getLogger()->warning(
1037 'OptIn not found for hash',
1038 array(
1039 'plugin' => 'double-opt-in',
1040 'hash' => $hash,
1041 )
1042 );
1043 self::setValidationStatus( 'not_found' );
1044 return false;
1045 }
1046
1047 // Check if this opt-in belongs to this integration
1048 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
1049 return false;
1050 }
1051
1052 // Check if the token has expired
1053 $settings = CF7DoubleOptIn::getInstance()->getSettings();
1054 $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
1055 if ( $expiryHours > 0 && ( time() - (int) $optIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
1056 $this->getLogger()->info(
1057 'OptIn token expired',
1058 array(
1059 'plugin' => 'double-opt-in',
1060 'optin_id' => $optIn->get_id(),
1061 'expiry_hours' => $expiryHours,
1062 )
1063 );
1064 do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $optIn );
1065 self::setValidationStatus( 'expired' );
1066 return false;
1067 }
1068
1069 // Skip if already confirmed
1070 if ( $optIn->is_confirmed() ) {
1071 $this->getLogger()->info(
1072 'OptIn already confirmed',
1073 array(
1074 'plugin' => 'double-opt-in',
1075 'optin_id' => $optIn->get_id(),
1076 )
1077 );
1078 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $optIn );
1079 self::setValidationStatus( 'already_confirmed' );
1080 return false;
1081 }
1082
1083 /**
1084 * Enable / Disable default mail.
1085 *
1086 * @param bool $status Enable (true) or disable (false) the default mail.
1087 * @param int $postId The ID of the Post / Form.
1088 *
1089 * @since 2.3.3
1090 */
1091 $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() );
1092
1093 // Bind the follow-up plan BEFORE the confirmation is saved, so a
1094 // request that dies in between leaves rows the sweep can finish.
1095 // False = no adapter for this integration → previous behaviour.
1096 $coordinator = FollowUpCoordinator::instance();
1097 $managed = $coordinator !== null && $coordinator->plan( $optIn, $sendDefaultMail );
1098
1099 // Confirm the opt-in
1100 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1101
1102 $optIn->set_doubleoptin( 1 );
1103 $optIn->set_updatetime( time() );
1104 $optIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
1105
1106 if ( ! $optIn->save() ) {
1107 $this->getLogger()->error(
1108 'Failed to confirm OptIn',
1109 array(
1110 'plugin' => 'double-opt-in',
1111 'optin_id' => $optIn->get_id(),
1112 )
1113 );
1114 return false;
1115 }
1116
1117 self::setValidationStatus( 'confirmed' );
1118
1119 // Track telemetry
1120 $telemetry = new Telemetry( $this->getLogger() );
1121 $telemetry->increment( 'confirmed_optins' );
1122
1123 // Dispatch event
1124 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1125
1126 // Everything from here on re-processes the stored submission.
1127 self::runAsReplay(
1128 function () use ( $hash, $optIn, $managed, $coordinator, $sendDefaultMail ) {
1129 do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1130
1131 if ( $managed ) {
1132 // The coordinator runs every planned action — entry and
1133 // mail — and records a result per action. The before/after
1134 // hooks keep firing for listeners that depend on them.
1135 if ( $sendDefaultMail ) {
1136 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1137 }
1138 $coordinator->run( $optIn, FollowUpAttempt::TRIGGER_CONFIRM );
1139 if ( $sendDefaultMail ) {
1140 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1141 }
1142 } elseif ( $sendDefaultMail ) {
1143 // Send the original mail if enabled
1144 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1145 $this->sendConfirmationMail( $optIn );
1146 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1147 }
1148 }
1149 );
1150
1151 $this->getLogger()->info(
1152 'OptIn confirmed successfully',
1153 array(
1154 'plugin' => 'double-opt-in',
1155 'optin_id' => $optIn->get_id(),
1156 )
1157 );
1158
1159 return true;
1160 }
1161
1162 /**
1163 * Remove stored files after processing.
1164 *
1165 * @param OptIn $optIn The opt-in record.
1166 *
1167 * @return void
1168 */
1169 public function removeStoredFiles( OptIn $optIn ): void {
1170 $files = maybe_unserialize( $optIn->get_files() );
1171
1172 if ( empty( $files ) || ! is_array( $files ) ) {
1173 return;
1174 }
1175
1176 foreach ( $files as $file ) {
1177 if ( empty( $file ) || ! is_file( $file ) ) {
1178 continue;
1179 }
1180
1181 if ( unlink( $file ) ) {
1182 $this->getLogger()->debug(
1183 'File removed successfully',
1184 array(
1185 'plugin' => 'double-opt-in',
1186 'file' => $file,
1187 )
1188 );
1189 } else {
1190 $this->getLogger()->warning(
1191 'Failed to remove file',
1192 array(
1193 'plugin' => 'double-opt-in',
1194 'file' => $file,
1195 )
1196 );
1197 }
1198 }
1199 }
1200
1201 /**
1202 * Disable spam protection hooks before sending confirmation mail.
1203 *
1204 * @return void
1205 */
1206 protected function beforeSendConfirmationMail(): void {
1207 // Disable CF7 validation for confirmation mail resend.
1208 // CF7 re-runs all form validations (required fields, quiz, acceptance checkboxes)
1209 // when creating a WPCF7_Submission instance. Since this is a confirmed opt-in
1210 // (not a real form submit), these validations must be bypassed.
1211 add_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1212 add_filter( 'wpcf7_spam', '__return_false', 0 );
1213 add_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1214
1215 // Disable CF7 Captcha if present
1216 add_filter( 'f12_cf7_captcha_is_installed_cf7', '__return_false', 999 );
1217
1218 // Remove reCAPTCHA filter
1219 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
1220
1221 // Remove hCaptcha validation filter
1222 $this->removeHCaptchaFilter();
1223
1224 $this->getLogger()->debug(
1225 'Validation and spam protection disabled for confirmation mail',
1226 array(
1227 'plugin' => 'double-opt-in',
1228 )
1229 );
1230 }
1231
1232 /**
1233 * Clear CF7 validation result to bypass field validation during confirmation mail.
1234 *
1235 * @param \WPCF7_Validation $result The validation result.
1236 *
1237 * @return \WPCF7_Validation A clean validation result with no errors.
1238 */
1239 public function clearValidationResult( $result ) {
1240 return new \WPCF7_Validation();
1241 }
1242
1243 /**
1244 * Re-enable spam protection hooks after sending confirmation mail.
1245 *
1246 * @return void
1247 */
1248 protected function afterSendConfirmationMail(): void {
1249 // Re-enable CF7 validation
1250 remove_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1251 remove_filter( 'wpcf7_spam', '__return_false', 0 );
1252 remove_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1253
1254 // Re-add reCAPTCHA filter
1255 if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
1256 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
1257 }
1258
1259 // Re-add CF7 Captcha hooks
1260 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
1261 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
1262 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
1263 add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
1264 }
1265
1266 // Re-add hCaptcha validation filter
1267 $this->restoreHCaptchaFilter();
1268
1269 $this->getLogger()->debug(
1270 'Validation and spam protection re-enabled',
1271 array(
1272 'plugin' => 'double-opt-in',
1273 )
1274 );
1275 }
1276
1277 /**
1278 * Remove hCaptcha CF7 validation filter and store the instance for later restore.
1279 *
1280 * @return void
1281 */
1282 private function removeHCaptchaFilter(): void {
1283 if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
1284 return;
1285 }
1286
1287 global $wp_filter;
1288
1289 if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
1290 return;
1291 }
1292
1293 foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
1294 foreach ( $hooks as $key => $hook ) {
1295 if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
1296 $this->hcaptchaCf7Instance = $hook['function'][0];
1297 $this->hcaptchaCf7Priority = $priority;
1298 remove_filter( 'wpcf7_validate', $hook['function'], $priority );
1299 $this->getLogger()->debug(
1300 'hCaptcha CF7 validation filter removed',
1301 array(
1302 'plugin' => 'double-opt-in',
1303 )
1304 );
1305
1306 return;
1307 }
1308 }
1309 }
1310 }
1311
1312 /**
1313 * Re-add hCaptcha CF7 validation filter if it was previously removed.
1314 *
1315 * @return void
1316 */
1317 private function restoreHCaptchaFilter(): void {
1318 if ( isset( $this->hcaptchaCf7Instance ) ) {
1319 add_filter( 'wpcf7_validate', array( $this->hcaptchaCf7Instance, 'verify_hcaptcha' ), $this->hcaptchaCf7Priority, 2 );
1320 $this->getLogger()->debug(
1321 'hCaptcha CF7 validation filter re-added',
1322 array(
1323 'plugin' => 'double-opt-in',
1324 )
1325 );
1326 unset( $this->hcaptchaCf7Instance, $this->hcaptchaCf7Priority );
1327 }
1328 }
1329
1330 /**
1331 * Dispatch FormSubmissionEvent.
1332 *
1333 * @param FormDataInterface $formData The form data.
1334 *
1335 * @return FormSubmissionEvent|null The event or null if dispatcher unavailable.
1336 */
1337 protected function dispatchFormSubmissionEvent( FormDataInterface $formData ): ?FormSubmissionEvent {
1338 try {
1339 $container = Container::getInstance();
1340 if ( $container->has( EventDispatcherInterface::class ) ) {
1341 $dispatcher = $container->get( EventDispatcherInterface::class );
1342 $event = new FormSubmissionEvent(
1343 $formData,
1344 $this->getIdentifier()
1345 );
1346 $dispatcher->dispatch( $event );
1347 return $event;
1348 }
1349 } catch ( \Exception $e ) {
1350 $this->getLogger()->warning(
1351 'Failed to dispatch FormSubmissionEvent',
1352 array(
1353 'plugin' => 'double-opt-in',
1354 'error' => $e->getMessage(),
1355 )
1356 );
1357 }
1358 return null;
1359 }
1360
1361 /**
1362 * Dispatch OptInCreatedEvent.
1363 *
1364 * @param OptIn $optIn The created opt-in.
1365 * @param FormDataInterface $formData The form data.
1366 *
1367 * @return void
1368 */
1369 protected function dispatchOptInCreatedEvent( OptIn $optIn, FormDataInterface $formData ): void {
1370 try {
1371 $container = Container::getInstance();
1372 if ( $container->has( EventDispatcherInterface::class ) ) {
1373 $dispatcher = $container->get( EventDispatcherInterface::class );
1374 $event = new OptInCreatedEvent(
1375 $optIn->get_id(),
1376 $formData->getFormId(),
1377 $this->getIdentifier(),
1378 $optIn->get_email(),
1379 $optIn->get_hash(),
1380 $formData->getFields()
1381 );
1382 $dispatcher->dispatch( $event );
1383 }
1384 } catch ( \Exception $e ) {
1385 $this->getLogger()->warning(
1386 'Failed to dispatch OptInCreatedEvent',
1387 array(
1388 'plugin' => 'double-opt-in',
1389 'error' => $e->getMessage(),
1390 )
1391 );
1392 }
1393 }
1394
1395 /**
1396 * Dispatch OptInConfirmedEvent.
1397 *
1398 * @param OptIn $optIn The confirmed opt-in.
1399 * @param string $hash The opt-in hash.
1400 *
1401 * @return void
1402 */
1403 protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1404 try {
1405 $container = Container::getInstance();
1406 if ( $container->has( EventDispatcherInterface::class ) ) {
1407 $dispatcher = $container->get( EventDispatcherInterface::class );
1408
1409 $formData = maybe_unserialize( $optIn->get_content() );
1410
1411 $event = new OptInConfirmedEvent(
1412 $optIn->get_id(),
1413 $hash,
1414 $optIn->get_email(),
1415 $optIn->get_ipaddr_confirmation(),
1416 (int) $optIn->get_cf_form_id(),
1417 is_array( $formData ) ? $formData : array()
1418 );
1419 $dispatcher->dispatch( $event );
1420 }
1421 } catch ( \Exception $e ) {
1422 $this->getLogger()->warning(
1423 'Failed to dispatch OptInConfirmedEvent',
1424 array(
1425 'plugin' => 'double-opt-in',
1426 'error' => $e->getMessage(),
1427 )
1428 );
1429 }
1430 }
1431
1432 /**
1433 * Get the post type for this integration's forms.
1434 *
1435 * @since 4.1.0
1436 *
1437 * @return string The post type.
1438 */
1439 abstract protected function getPostType(): string;
1440
1441 /**
1442 * {@inheritdoc}
1443 */
1444 public function getForms(): array {
1445 $posts = get_posts(
1446 array(
1447 'post_type' => $this->getPostType(),
1448 'posts_per_page' => -1,
1449 'post_status' => 'publish',
1450 'orderby' => 'title',
1451 'order' => 'ASC',
1452 )
1453 );
1454
1455 $forms = array();
1456 foreach ( $posts as $post ) {
1457 $parameter = $this->getFormParameter( $post->ID );
1458 $forms[] = array(
1459 'id' => $post->ID,
1460 'title' => $post->post_title,
1461 'integration' => $this->getIdentifier(),
1462 'enabled' => (int) ( $parameter['enable'] ?? 0 ) === 1,
1463 'edit_url' => $this->getFormEditUrl( $post->ID ),
1464 );
1465 }
1466
1467 $this->getLogger()->debug(
1468 'Retrieved forms for integration',
1469 array(
1470 'plugin' => 'double-opt-in',
1471 'integration' => $this->getIdentifier(),
1472 'count' => count( $forms ),
1473 )
1474 );
1475
1476 return $forms;
1477 }
1478
1479 /**
1480 * {@inheritdoc}
1481 */
1482 public function getFormTitle( $formId ): string {
1483 $post = get_post( (int) $formId );
1484 return $post ? $post->post_title : '';
1485 }
1486
1487 /**
1488 * {@inheritdoc}
1489 */
1490 public function getFormEditUrl( $formId ): string {
1491 return get_edit_post_link( (int) $formId, 'raw' ) ?: '';
1492 }
1493 }
1494