PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.3
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.3
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 / Integration / AbstractFormIntegration.php

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

1,567 lines 45.6 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 * The consent gate, asked at the form plugin's own validation stage.
492 *
493 * For integrations whose submit hook runs after the form plugin has
494 * already accepted the submission (WPForms `wpforms_process_complete`,
495 * Gravity Forms `gform_after_submission`). By then the form has been
496 * replaced by its confirmation, and a refused consent could only be
497 * reported in a toast over an empty page (5.6.2 click test). Asked from
498 * `wpforms_process` / `gform_validation` instead, the form plugin marks
499 * the checkbox like a missed required field and keeps the input.
500 *
501 * Only a refusal that would stand is returned: DOI on for the form, not
502 * skipped by `f12_cf7_doubleoptin_skip_option`, verdict NOT_GIVEN, gate
503 * enforced. Everything else — a stale field name, the gate switched off
504 * by filter — is left to createOptIn(), which logs it as before.
505 *
506 * @param FormDataInterface $formData The submission, normalized the same
507 * way the submit hook will normalize it.
508 * @param mixed $rawFields What the skip filter receives in the
509 * submit hook of this integration.
510 *
511 * @return OptInError|null The refusal, or null to let the form through.
512 *
513 * @since 5.6.2
514 */
515 public function refusedConsentBeforeSubmit( FormDataInterface $formData, $rawFields = array() ): ?OptInError {
516 $formId = $formData->getFormId();
517
518 if ( ! $this->isOptInEnabled( $formId ) ) {
519 return null;
520 }
521
522 if ( apply_filters( 'f12_cf7_doubleoptin_skip_option', false, $formId, $rawFields, $this->getIdentifier() ) ) {
523 return null;
524 }
525
526 $consentField = (string) ( $this->getFormParameter( $formId )['consent_field'] ?? '' );
527 if ( $consentField === '' ) {
528 return null;
529 }
530
531 $verdict = ConsentGate::evaluate(
532 $consentField,
533 $formData->getFields(),
534 $this->getKnownFieldNames( $formId )
535 );
536
537 if ( $verdict !== ConsentGate::NOT_GIVEN || ! ConsentGate::isEnforced( $formId, $this->getIdentifier() ) ) {
538 return null;
539 }
540
541 $this->getLogger()->info(
542 'Consent acceptance not given, rejecting submission at validation',
543 array(
544 'plugin' => 'double-opt-in',
545 'form_id' => $formId,
546 'integration' => $this->getIdentifier(),
547 'consent_field' => $consentField,
548 )
549 );
550
551 /** This action is documented in createOptIn(). */
552 do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
553
554 return OptInError::fromCode(
555 OptInError::CONSENT_NOT_GIVEN,
556 array(
557 'form_id' => $formId,
558 'consent_field' => $consentField,
559 )
560 );
561 }
562
563 /**
564 * Validate the consent-acceptance gate (GDPR Art. 7).
565 *
566 * The decision itself lives in {@see ConsentGate} — this method only
567 * turns it into the return value `createOptIn()` expects and writes
568 * the log lines. Both halves of the plugin share that class now: the
569 * integrations that extend this base, and the legacy path in
570 * `OptInFrontend::maybeCreateOptIn()` that serves Elementor and the
571 * CF7/Avada shims.
572 *
573 * `ConsentGate::FIELD_UNKNOWN` — the configured field is not on this
574 * form — deliberately does NOT reject. Until 5.4.0 it did, which took
575 * a site's registrations offline over a settings mistake the visitor
576 * could neither see nor fix (customer report 2026-08-27). The admin
577 * now hears about it through the log, the Site Health check and the
578 * banner on the form's settings tab instead.
579 *
580 * @param FormDataInterface $formData The submitted form data.
581 * @param array<string, mixed> $formParameter The form-settings snapshot.
582 *
583 * @return OptInError|null Error when the gate rejects; null when the
584 * submission may proceed.
585 */
586 protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
587 $consentField = (string) ( $formParameter['consent_field'] ?? '' );
588 if ( $consentField === '' ) {
589 return null;
590 }
591
592 $verdict = ConsentGate::evaluate(
593 $consentField,
594 $formData->getFields(),
595 $this->getKnownFieldNames( $formData->getFormId() )
596 );
597
598 if ( $verdict === ConsentGate::PASSED ) {
599 return null;
600 }
601
602 if ( $verdict === ConsentGate::FIELD_UNKNOWN ) {
603 // Never a rejection — see the ConsentGate docblock. Logged as
604 // a warning all the same: until someone fixes the setting, the
605 // consent proof stored with every opt-in of this form is
606 // worthless.
607 $this->getLogger()->warning(
608 'Consent field is not on this form — opt-in accepted without provable consent',
609 array(
610 'plugin' => 'double-opt-in',
611 'form_id' => $formData->getFormId(),
612 'integration' => $this->getIdentifier(),
613 'consent_field' => $consentField,
614 )
615 );
616
617 /**
618 * Fires when a form's configured acceptance field cannot be
619 * found on the form itself. The submission is accepted.
620 *
621 * @since 5.4.0
622 *
623 * @param int $formId The form the submission came from.
624 * @param string $consentField The configured field name.
625 * @param string $integration The integration identifier.
626 */
627 do_action(
628 'f12_doi_consent_field_unknown',
629 $formData->getFormId(),
630 $consentField,
631 $this->getIdentifier()
632 );
633
634 return null;
635 }
636
637 if ( ! ConsentGate::isEnforced( $formData->getFormId(), $this->getIdentifier() ) ) {
638 $this->getLogger()->warning(
639 'Consent gate disabled by filter — accepting an unconfirmed submission',
640 array(
641 'plugin' => 'double-opt-in',
642 'form_id' => $formData->getFormId(),
643 'integration' => $this->getIdentifier(),
644 'consent_field' => $consentField,
645 )
646 );
647
648 return null;
649 }
650
651 return OptInError::fromCode(
652 OptInError::CONSENT_NOT_GIVEN,
653 array(
654 'form_id' => $formData->getFormId(),
655 'consent_field' => $consentField,
656 )
657 );
658 }
659
660 /**
661 * The names of the fields this form actually declares.
662 *
663 * Needed to tell an unticked checkbox — which the browser leaves out
664 * of the payload entirely — from a `consent_field` pointing at a
665 * field the admin has since renamed or deleted. An integration that
666 * cannot answer yields an empty list, and the gate then stays on the
667 * cautious side and rejects nothing it cannot prove.
668 *
669 * @param int $formId The form to inspect.
670 *
671 * @return array<int,string>
672 */
673 protected function getKnownFieldNames( int $formId ): array {
674 try {
675 return ConsentGate::normalizeFieldNames( $this->getFormFields( $formId ) );
676 } catch ( \Throwable $e ) {
677 $this->getLogger()->warning(
678 'Could not read the form field inventory for the consent gate',
679 array(
680 'plugin' => 'double-opt-in',
681 'form_id' => $formId,
682 'error' => $e->getMessage(),
683 )
684 );
685
686 return array();
687 }
688 }
689
690 /**
691 * Build the OptIn properties array that will be persisted on
692 * record creation. Extracted from {@see createOptIn()} so the
693 * field-coverage contract is testable in isolation — the full
694 * createOptIn() flow has too many side-effects (rate-limiting,
695 * file storage, container access) for clean unit testing.
696 *
697 * Snapshot semantics:
698 * - `consent_text` is captured per GDPR Art. 7 — the consent
699 * record reflects the wording the user actually agreed to,
700 * even if the form's settings change later.
701 * - Custom addon fields land via the `f12_doi_optin_properties`
702 * filter; addons that contribute a per-form setting hook here
703 * to persist a snapshot with each opt-in.
704 *
705 * @param FormDataInterface $formData The submitted form data.
706 * @param array<string, mixed> $formParameter The form-settings snapshot.
707 * @param string $recipient The resolved recipient email.
708 * @param array<string, mixed> $fields Filtered form fields.
709 * @param array<string, mixed> $files Stored file references.
710 *
711 * @return array<string, mixed>
712 */
713 protected function buildOptInProperties(
714 FormDataInterface $formData,
715 array $formParameter,
716 string $recipient,
717 array $fields,
718 array $files
719 ): array {
720 $properties = array(
721 'cf_form_id' => $formData->getFormId(),
722 'doubleoptin' => 0,
723 'createtime' => time(),
724 'content' => maybe_serialize( $fields ),
725 'files' => maybe_serialize( $files ),
726 'ipaddr_register' => IPHelper::getIPAdress(),
727 'category' => (int) ( $formParameter['category'] ?? 0 ),
728 'form' => $formData->getFormHtml(),
729 'email' => $recipient,
730 'consent_text' => (string) ( $formParameter['consent_text'] ?? '' ),
731 'consent_field' => (string) ( $formParameter['consent_field'] ?? '' ),
732 );
733
734 /**
735 * Filter the OptIn properties array before the record is
736 * created. Addons hook this to snapshot their own per-form
737 * settings into the opt-in record at submit time. Mirrors the
738 * symmetric DTO filter pattern (`f12_doi_settings_dto_from_array`
739 * / `f12_doi_settings_dto_sanitize`) — an addon that contributes
740 * a per-form setting AND wants it persisted with each opt-in
741 * snapshots it through this filter.
742 *
743 * @since 4.4.0
744 *
745 * @param array<string, mixed> $properties The properties array
746 * for the new OptIn.
747 * @param FormDataInterface $formData The submitted form data.
748 * @param array<string, mixed> $formParameter The form-settings snapshot.
749 */
750 return apply_filters( 'f12_doi_optin_properties', $properties, $formData, $formParameter );
751 }
752
753 /**
754 * Prepare the opt-in mail body with placeholders replaced.
755 *
756 * @param string $body The mail body template.
757 * @param OptIn $optIn The OptIn record.
758 * @param array $formParameter The form configuration.
759 *
760 * @return string The processed mail body.
761 */
762 protected function prepareMailBody( string $body, OptIn $optIn, array $formParameter ): string {
763 // Replace system placeholders
764 $body = $this->addSystemPlaceholders( $body, $optIn, $formParameter );
765
766 // Replace form field placeholders
767 $formData = maybe_unserialize( $optIn->get_content() );
768 if ( is_array( $formData ) ) {
769 // Handle nested content structure (e.g., Avada stores {data: {...}, field_labels: {...}, ...})
770 // Extract the flat field data for placeholder replacement
771 $fieldData = isset( $formData['data'] ) && is_array( $formData['data'] ) ? $formData['data'] : $formData;
772
773 $body = PlaceholderMapper::replacePlaceholders(
774 $body,
775 $fieldData,
776 $optIn->get_cf_form_id(),
777 array(),
778 $this->getIdentifier()
779 );
780 }
781
782 return $body;
783 }
784
785 /**
786 * Add system placeholders to the mail body.
787 *
788 * @param string $body The mail body.
789 * @param OptIn $optIn The OptIn record.
790 * @param array $formParameter The form configuration.
791 *
792 * @return string The body with placeholders replaced.
793 */
794 protected function addSystemPlaceholders( string $body, OptIn $optIn, array $formParameter ): string {
795 $placeholders = array(
796 // User-influenced (the submit page URL incl. query string) — sanitise
797 // as a URL so a crafted `?x="><script>` can't reflect into the mail
798 // HTML. esc_url_raw (not esc_url) keeps ampersands un-entity-encoded
799 // so the plain-text mail variant stays intact too.
800 'doubleoptin_form_url' => esc_url_raw( (string) ( $formParameter['formUrl'] ?? '' ) ),
801 'doubleoptin_form_subject' => $formParameter['subject'] ?? '',
802 // wp_date() formats in the site's timezone without mutating PHP's
803 // global timezone (the old date() + date_default_timezone_set() did).
804 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
805 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
806 'doubleoptin_form_email' => get_option( 'admin_email' ),
807 'doubleoptinlink' => $optIn->get_link_optin( $formParameter ),
808 'doubleoptoutlink' => $optIn->get_link_optout(),
809 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
810 );
811
812 foreach ( $placeholders as $key => $value ) {
813 if ( is_array( $value ) || is_object( $value ) ) {
814 $value = wp_json_encode( $value );
815 }
816 $body = str_replace( '[' . $key . ']', (string) $value, $body );
817 }
818
819 return $body;
820 }
821
822 /**
823 * Get the privacy policy URL.
824 *
825 * @return string The privacy policy URL or empty string.
826 */
827 protected function getPrivacyPolicyUrl(): string {
828 $settings = CF7DoubleOptIn::getInstance()->getSettings();
829 $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
830
831 if ( $pageId > 0 ) {
832 $url = get_permalink( $pageId );
833 if ( $url ) {
834 return $url;
835 }
836 }
837
838 // Fallback to WordPress privacy policy page
839 $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
840 if ( $wpPrivacyPageId > 0 ) {
841 $url = get_permalink( $wpPrivacyPageId );
842 if ( $url ) {
843 return $url;
844 }
845 }
846
847 return '';
848 }
849
850 /**
851 * Store uploaded files for later use after opt-in confirmation.
852 *
853 * @param array $files The uploaded files.
854 *
855 * @return array The stored file paths.
856 */
857 protected function storeFiles( array $files ): array {
858 // File-lifecycle plan, Step 1 (2026-05-07): delegate to the
859 // centralised FileStorage service. This moves files into
860 // `wp-content/uploads/f12-doi/pending/` (deny-from-all) with
861 // random hex names, instead of the pre-4.3 in-tmp-dir copy.
862 // The legacy copyAndRenameFile path below stays for now as a
863 // fallback if FileStorage construction fails — removed in
864 // Schritt 2 once each integration's hand-off is wired up.
865 try {
866 $storage = new FileStorage( $this->logger );
867 return $storage->store( $files );
868 } catch ( \Throwable $e ) {
869 $this->logger->error(
870 'FileStorage unavailable, falling back to legacy in-tmp store',
871 array(
872 'plugin' => 'double-opt-in',
873 'error' => $e->getMessage(),
874 )
875 );
876 }
877
878 // ── Legacy fallback (pre-4.3) ─────────────────────────────────
879 $storedFiles = array();
880
881 if ( empty( $files ) ) {
882 return $storedFiles;
883 }
884
885 foreach ( $files as $key => $fileList ) {
886 if ( ! is_array( $fileList ) ) {
887 $fileList = array( $fileList );
888 }
889
890 foreach ( $fileList as $file ) {
891 if ( empty( $file ) || ! is_file( $file ) ) {
892 continue;
893 }
894
895 $newFile = $this->copyAndRenameFile( $file );
896 if ( $newFile ) {
897 $storedFiles[] = $newFile;
898 }
899 }
900 }
901
902 return $storedFiles;
903 }
904
905 /**
906 * Hand off the opt-in's stored files to the integration's own
907 * form-system entry (Avada form_entries / GF entry / WPForms
908 * entry / CF7 mail attachment). Per-integration override.
909 *
910 * Default behaviour: return false (no hand-off). The files stay
911 * in `pending/` until the OptIn is deleted (cron / manual /
912 * REST), at which point cascade-delete removes them.
913 *
914 * Contract (file-lifecycle plan, 2026-05-07):
915 *
916 * - true: hand-off succeeded. Caller (template-method below)
917 * will delete the pending/ copies — single source of
918 * truth = the integration's own DB. GDPR-compliant by
919 * construction (consent-bound retention).
920 *
921 * - false: hand-off failed or not implemented. Files stay in
922 * pending/ and are removed eventually via OptIn-deletion
923 * cascade (cron expiry or manual delete). No silent
924 * data loss.
925 *
926 * @param OptIn $optIn The just-confirmed opt-in record.
927 *
928 * @return bool true on successful hand-off, false otherwise.
929 *
930 * @since 4.3.0
931 */
932 public function handOffFilesToFormSystem( OptIn $optIn ): bool {
933 // Default: not implemented for this integration. Per-
934 // integration overrides in addon-avada / addon-cf7 / etc.
935 return false;
936 }
937
938 /**
939 * Template-method: process file hand-off when an opt-in confirms.
940 *
941 * Hooked on `f12_cf7_doubleoptin_after_confirm` at priority 5
942 * (BEFORE addon-specific listeners that fire at default 10), so
943 * file hand-off completes before any side-effects that might
944 * rely on the integration's entry being fully populated.
945 *
946 * Each subclass registers this in its own `registerHooks()` —
947 * see Schritt 2 per-integration commits.
948 *
949 * @param string $hash The confirmation hash (action arg).
950 * @param OptIn $optIn The confirmed opt-in (action arg).
951 *
952 * @since 4.3.0
953 */
954 final public function processFilesOnConfirm( string $hash, OptIn $optIn ): void {
955 // Bail for opt-ins that aren't this integration's responsibility.
956 // The after_confirm action fires for ALL types — every integration
957 // hooks it but only acts on its own.
958 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
959 return;
960 }
961
962 $rawFiles = (string) $optIn->get_files();
963 $decoded = $rawFiles === '' ? array() : maybe_unserialize( $rawFiles );
964 $decoded = is_array( $decoded ) ? $decoded : array();
965 $files = array_values(
966 array_filter(
967 $decoded,
968 static function ( $p ) { return is_string( $p ) && $p !== ''; }
969 )
970 );
971
972 if ( empty( $files ) ) {
973 return;
974 }
975
976 $handedOff = $this->handOffFilesToFormSystem( $optIn );
977
978 if ( ! $handedOff ) {
979 $this->logger->info(
980 'File hand-off not performed (or failed) — pending files will be cleaned up on OptIn deletion',
981 array(
982 'plugin' => 'double-opt-in',
983 'integration' => $this->getIdentifier(),
984 'optin_id' => $optIn->get_id(),
985 'file_count' => count( $files ),
986 )
987 );
988 return;
989 }
990
991 // Hand-off succeeded → integration now owns the files in its
992 // own DB. Delete our pending copies to avoid duplicate storage.
993 try {
994 $storage = new FileStorage( $this->logger );
995 $storage->deletePaths( $files );
996
997 // Clear the OptIn::files column so the cascade-delete on
998 // later OptIn removal doesn't try to re-unlink missing
999 // paths. Idempotent if save fails — pending dir is empty
1000 // either way.
1001 if ( method_exists( $optIn, 'set_files' ) ) {
1002 $optIn->set_files( serialize( array() ) );
1003 if ( method_exists( $optIn, 'save' ) ) {
1004 $optIn->save();
1005 }
1006 }
1007
1008 $this->logger->info(
1009 'Files handed off to form system + pending copies deleted',
1010 array(
1011 'plugin' => 'double-opt-in',
1012 'integration' => $this->getIdentifier(),
1013 'optin_id' => $optIn->get_id(),
1014 'file_count' => count( $files ),
1015 )
1016 );
1017 } catch ( \Throwable $e ) {
1018 $this->logger->error(
1019 'Hand-off succeeded but pending-cleanup failed',
1020 array(
1021 'plugin' => 'double-opt-in',
1022 'error' => $e->getMessage(),
1023 )
1024 );
1025 }
1026 }
1027
1028 /**
1029 * Allowed MIME types for file uploads stored with opt-in records.
1030 */
1031 private const ALLOWED_MIME_TYPES = array(
1032 'jpg' => 'image/jpeg',
1033 'jpeg' => 'image/jpeg',
1034 'png' => 'image/png',
1035 'gif' => 'image/gif',
1036 'webp' => 'image/webp',
1037 'pdf' => 'application/pdf',
1038 'doc' => 'application/msword',
1039 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1040 'txt' => 'text/plain',
1041 'csv' => 'text/csv',
1042 );
1043
1044 /**
1045 * Copy and rename a file with a unique, non-guessable name.
1046 *
1047 * Validates the file MIME type against an allowlist before copying.
1048 *
1049 * @param string $file The source file path.
1050 *
1051 * @return string|null The new file path or null on failure/rejection.
1052 */
1053 private function copyAndRenameFile( string $file ): ?string {
1054 $allowedMimes = apply_filters( 'f12_cf7_doubleoptin_allowed_mime_types', self::ALLOWED_MIME_TYPES );
1055
1056 $fileType = wp_check_filetype_and_ext( $file, wp_basename( $file ), $allowedMimes );
1057
1058 if ( empty( $fileType['type'] ) || empty( $fileType['ext'] ) ) {
1059 $this->getLogger()->warning(
1060 'File rejected: MIME type not allowed',
1061 array(
1062 'plugin' => 'double-opt-in',
1063 'original' => $file,
1064 )
1065 );
1066 return null;
1067 }
1068
1069 $pathParts = explode( '/', $file );
1070 array_pop( $pathParts );
1071 $newName = bin2hex( random_bytes( 16 ) ) . '.' . $fileType['ext'];
1072 $pathParts[] = $newName;
1073 $newFile = implode( '/', $pathParts );
1074
1075 if ( copy( $file, $newFile ) ) {
1076 $this->getLogger()->debug(
1077 'File copied successfully',
1078 array(
1079 'plugin' => 'double-opt-in',
1080 'original' => $file,
1081 'new_file' => $newFile,
1082 )
1083 );
1084 return $newFile;
1085 }
1086
1087 $this->getLogger()->error(
1088 'Failed to copy file',
1089 array(
1090 'plugin' => 'double-opt-in',
1091 'original' => $file,
1092 )
1093 );
1094
1095 return null;
1096 }
1097
1098 /**
1099 * Validate and confirm an opt-in by hash.
1100 *
1101 * @param string $hash The opt-in hash.
1102 *
1103 * @return bool True if the opt-in was confirmed successfully.
1104 */
1105 public function validateOptIn( string $hash ): bool {
1106 $optIn = OptIn::get_by_hash( $hash );
1107
1108 if ( ! $optIn ) {
1109 $this->getLogger()->warning(
1110 'OptIn not found for hash',
1111 array(
1112 'plugin' => 'double-opt-in',
1113 'hash' => $hash,
1114 )
1115 );
1116 self::setValidationStatus( 'not_found' );
1117 return false;
1118 }
1119
1120 // Check if this opt-in belongs to this integration
1121 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
1122 return false;
1123 }
1124
1125 // Check if the token has expired
1126 $settings = CF7DoubleOptIn::getInstance()->getSettings();
1127 $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
1128 if ( $expiryHours > 0 && ( time() - (int) $optIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
1129 $this->getLogger()->info(
1130 'OptIn token expired',
1131 array(
1132 'plugin' => 'double-opt-in',
1133 'optin_id' => $optIn->get_id(),
1134 'expiry_hours' => $expiryHours,
1135 )
1136 );
1137 do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $optIn );
1138 self::setValidationStatus( 'expired' );
1139 return false;
1140 }
1141
1142 // Skip if already confirmed
1143 if ( $optIn->is_confirmed() ) {
1144 $this->getLogger()->info(
1145 'OptIn already confirmed',
1146 array(
1147 'plugin' => 'double-opt-in',
1148 'optin_id' => $optIn->get_id(),
1149 )
1150 );
1151 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $optIn );
1152 self::setValidationStatus( 'already_confirmed' );
1153 return false;
1154 }
1155
1156 /**
1157 * Enable / Disable default mail.
1158 *
1159 * @param bool $status Enable (true) or disable (false) the default mail.
1160 * @param int $postId The ID of the Post / Form.
1161 *
1162 * @since 2.3.3
1163 */
1164 $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() );
1165
1166 // Bind the follow-up plan BEFORE the confirmation is saved, so a
1167 // request that dies in between leaves rows the sweep can finish.
1168 // False = no adapter for this integration → previous behaviour.
1169 $coordinator = FollowUpCoordinator::instance();
1170 $managed = $coordinator !== null && $coordinator->plan( $optIn, $sendDefaultMail );
1171
1172 // Confirm the opt-in
1173 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1174
1175 $optIn->set_doubleoptin( 1 );
1176 $optIn->set_updatetime( time() );
1177 $optIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
1178
1179 if ( ! $optIn->save() ) {
1180 $this->getLogger()->error(
1181 'Failed to confirm OptIn',
1182 array(
1183 'plugin' => 'double-opt-in',
1184 'optin_id' => $optIn->get_id(),
1185 )
1186 );
1187 return false;
1188 }
1189
1190 self::setValidationStatus( 'confirmed' );
1191
1192 // Track telemetry
1193 $telemetry = new Telemetry( $this->getLogger() );
1194 $telemetry->increment( 'confirmed_optins' );
1195
1196 // Dispatch event
1197 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1198
1199 // Everything from here on re-processes the stored submission.
1200 self::runAsReplay(
1201 function () use ( $hash, $optIn, $managed, $coordinator, $sendDefaultMail ) {
1202 do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1203
1204 if ( $managed ) {
1205 // The coordinator runs every planned action — entry and
1206 // mail — and records a result per action. The before/after
1207 // hooks keep firing for listeners that depend on them.
1208 if ( $sendDefaultMail ) {
1209 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1210 }
1211 $coordinator->run( $optIn, FollowUpAttempt::TRIGGER_CONFIRM );
1212 if ( $sendDefaultMail ) {
1213 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1214 }
1215 } elseif ( $sendDefaultMail ) {
1216 // Send the original mail if enabled
1217 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1218 $this->sendConfirmationMail( $optIn );
1219 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1220 }
1221 }
1222 );
1223
1224 $this->getLogger()->info(
1225 'OptIn confirmed successfully',
1226 array(
1227 'plugin' => 'double-opt-in',
1228 'optin_id' => $optIn->get_id(),
1229 )
1230 );
1231
1232 return true;
1233 }
1234
1235 /**
1236 * Remove stored files after processing.
1237 *
1238 * @param OptIn $optIn The opt-in record.
1239 *
1240 * @return void
1241 */
1242 public function removeStoredFiles( OptIn $optIn ): void {
1243 $files = maybe_unserialize( $optIn->get_files() );
1244
1245 if ( empty( $files ) || ! is_array( $files ) ) {
1246 return;
1247 }
1248
1249 foreach ( $files as $file ) {
1250 if ( empty( $file ) || ! is_file( $file ) ) {
1251 continue;
1252 }
1253
1254 if ( unlink( $file ) ) {
1255 $this->getLogger()->debug(
1256 'File removed successfully',
1257 array(
1258 'plugin' => 'double-opt-in',
1259 'file' => $file,
1260 )
1261 );
1262 } else {
1263 $this->getLogger()->warning(
1264 'Failed to remove file',
1265 array(
1266 'plugin' => 'double-opt-in',
1267 'file' => $file,
1268 )
1269 );
1270 }
1271 }
1272 }
1273
1274 /**
1275 * Disable spam protection hooks before sending confirmation mail.
1276 *
1277 * @return void
1278 */
1279 protected function beforeSendConfirmationMail(): void {
1280 // Disable CF7 validation for confirmation mail resend.
1281 // CF7 re-runs all form validations (required fields, quiz, acceptance checkboxes)
1282 // when creating a WPCF7_Submission instance. Since this is a confirmed opt-in
1283 // (not a real form submit), these validations must be bypassed.
1284 add_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1285 add_filter( 'wpcf7_spam', '__return_false', 0 );
1286 add_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1287
1288 // Disable CF7 Captcha if present
1289 add_filter( 'f12_cf7_captcha_is_installed_cf7', '__return_false', 999 );
1290
1291 // Remove reCAPTCHA filter
1292 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
1293
1294 // Remove hCaptcha validation filter
1295 $this->removeHCaptchaFilter();
1296
1297 $this->getLogger()->debug(
1298 'Validation and spam protection disabled for confirmation mail',
1299 array(
1300 'plugin' => 'double-opt-in',
1301 )
1302 );
1303 }
1304
1305 /**
1306 * Clear CF7 validation result to bypass field validation during confirmation mail.
1307 *
1308 * @param \WPCF7_Validation $result The validation result.
1309 *
1310 * @return \WPCF7_Validation A clean validation result with no errors.
1311 */
1312 public function clearValidationResult( $result ) {
1313 return new \WPCF7_Validation();
1314 }
1315
1316 /**
1317 * Re-enable spam protection hooks after sending confirmation mail.
1318 *
1319 * @return void
1320 */
1321 protected function afterSendConfirmationMail(): void {
1322 // Re-enable CF7 validation
1323 remove_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1324 remove_filter( 'wpcf7_spam', '__return_false', 0 );
1325 remove_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1326
1327 // Re-add reCAPTCHA filter
1328 if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
1329 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
1330 }
1331
1332 // Re-add CF7 Captcha hooks
1333 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
1334 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
1335 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
1336 add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
1337 }
1338
1339 // Re-add hCaptcha validation filter
1340 $this->restoreHCaptchaFilter();
1341
1342 $this->getLogger()->debug(
1343 'Validation and spam protection re-enabled',
1344 array(
1345 'plugin' => 'double-opt-in',
1346 )
1347 );
1348 }
1349
1350 /**
1351 * Remove hCaptcha CF7 validation filter and store the instance for later restore.
1352 *
1353 * @return void
1354 */
1355 private function removeHCaptchaFilter(): void {
1356 if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
1357 return;
1358 }
1359
1360 global $wp_filter;
1361
1362 if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
1363 return;
1364 }
1365
1366 foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
1367 foreach ( $hooks as $key => $hook ) {
1368 if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
1369 $this->hcaptchaCf7Instance = $hook['function'][0];
1370 $this->hcaptchaCf7Priority = $priority;
1371 remove_filter( 'wpcf7_validate', $hook['function'], $priority );
1372 $this->getLogger()->debug(
1373 'hCaptcha CF7 validation filter removed',
1374 array(
1375 'plugin' => 'double-opt-in',
1376 )
1377 );
1378
1379 return;
1380 }
1381 }
1382 }
1383 }
1384
1385 /**
1386 * Re-add hCaptcha CF7 validation filter if it was previously removed.
1387 *
1388 * @return void
1389 */
1390 private function restoreHCaptchaFilter(): void {
1391 if ( isset( $this->hcaptchaCf7Instance ) ) {
1392 add_filter( 'wpcf7_validate', array( $this->hcaptchaCf7Instance, 'verify_hcaptcha' ), $this->hcaptchaCf7Priority, 2 );
1393 $this->getLogger()->debug(
1394 'hCaptcha CF7 validation filter re-added',
1395 array(
1396 'plugin' => 'double-opt-in',
1397 )
1398 );
1399 unset( $this->hcaptchaCf7Instance, $this->hcaptchaCf7Priority );
1400 }
1401 }
1402
1403 /**
1404 * Dispatch FormSubmissionEvent.
1405 *
1406 * @param FormDataInterface $formData The form data.
1407 *
1408 * @return FormSubmissionEvent|null The event or null if dispatcher unavailable.
1409 */
1410 protected function dispatchFormSubmissionEvent( FormDataInterface $formData ): ?FormSubmissionEvent {
1411 try {
1412 $container = Container::getInstance();
1413 if ( $container->has( EventDispatcherInterface::class ) ) {
1414 $dispatcher = $container->get( EventDispatcherInterface::class );
1415 $event = new FormSubmissionEvent(
1416 $formData,
1417 $this->getIdentifier()
1418 );
1419 $dispatcher->dispatch( $event );
1420 return $event;
1421 }
1422 } catch ( \Exception $e ) {
1423 $this->getLogger()->warning(
1424 'Failed to dispatch FormSubmissionEvent',
1425 array(
1426 'plugin' => 'double-opt-in',
1427 'error' => $e->getMessage(),
1428 )
1429 );
1430 }
1431 return null;
1432 }
1433
1434 /**
1435 * Dispatch OptInCreatedEvent.
1436 *
1437 * @param OptIn $optIn The created opt-in.
1438 * @param FormDataInterface $formData The form data.
1439 *
1440 * @return void
1441 */
1442 protected function dispatchOptInCreatedEvent( OptIn $optIn, FormDataInterface $formData ): void {
1443 try {
1444 $container = Container::getInstance();
1445 if ( $container->has( EventDispatcherInterface::class ) ) {
1446 $dispatcher = $container->get( EventDispatcherInterface::class );
1447 $event = new OptInCreatedEvent(
1448 $optIn->get_id(),
1449 $formData->getFormId(),
1450 $this->getIdentifier(),
1451 $optIn->get_email(),
1452 $optIn->get_hash(),
1453 $formData->getFields()
1454 );
1455 $dispatcher->dispatch( $event );
1456 }
1457 } catch ( \Exception $e ) {
1458 $this->getLogger()->warning(
1459 'Failed to dispatch OptInCreatedEvent',
1460 array(
1461 'plugin' => 'double-opt-in',
1462 'error' => $e->getMessage(),
1463 )
1464 );
1465 }
1466 }
1467
1468 /**
1469 * Dispatch OptInConfirmedEvent.
1470 *
1471 * @param OptIn $optIn The confirmed opt-in.
1472 * @param string $hash The opt-in hash.
1473 *
1474 * @return void
1475 */
1476 protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1477 try {
1478 $container = Container::getInstance();
1479 if ( $container->has( EventDispatcherInterface::class ) ) {
1480 $dispatcher = $container->get( EventDispatcherInterface::class );
1481
1482 $formData = maybe_unserialize( $optIn->get_content() );
1483
1484 $event = new OptInConfirmedEvent(
1485 $optIn->get_id(),
1486 $hash,
1487 $optIn->get_email(),
1488 $optIn->get_ipaddr_confirmation(),
1489 (int) $optIn->get_cf_form_id(),
1490 is_array( $formData ) ? $formData : array()
1491 );
1492 $dispatcher->dispatch( $event );
1493 }
1494 } catch ( \Exception $e ) {
1495 $this->getLogger()->warning(
1496 'Failed to dispatch OptInConfirmedEvent',
1497 array(
1498 'plugin' => 'double-opt-in',
1499 'error' => $e->getMessage(),
1500 )
1501 );
1502 }
1503 }
1504
1505 /**
1506 * Get the post type for this integration's forms.
1507 *
1508 * @since 4.1.0
1509 *
1510 * @return string The post type.
1511 */
1512 abstract protected function getPostType(): string;
1513
1514 /**
1515 * {@inheritdoc}
1516 */
1517 public function getForms(): array {
1518 $posts = get_posts(
1519 array(
1520 'post_type' => $this->getPostType(),
1521 'posts_per_page' => -1,
1522 'post_status' => 'publish',
1523 'orderby' => 'title',
1524 'order' => 'ASC',
1525 )
1526 );
1527
1528 $forms = array();
1529 foreach ( $posts as $post ) {
1530 $parameter = $this->getFormParameter( $post->ID );
1531 $forms[] = array(
1532 'id' => $post->ID,
1533 'title' => $post->post_title,
1534 'integration' => $this->getIdentifier(),
1535 'enabled' => (int) ( $parameter['enable'] ?? 0 ) === 1,
1536 'edit_url' => $this->getFormEditUrl( $post->ID ),
1537 );
1538 }
1539
1540 $this->getLogger()->debug(
1541 'Retrieved forms for integration',
1542 array(
1543 'plugin' => 'double-opt-in',
1544 'integration' => $this->getIdentifier(),
1545 'count' => count( $forms ),
1546 )
1547 );
1548
1549 return $forms;
1550 }
1551
1552 /**
1553 * {@inheritdoc}
1554 */
1555 public function getFormTitle( $formId ): string {
1556 $post = get_post( (int) $formId );
1557 return $post ? $post->post_title : '';
1558 }
1559
1560 /**
1561 * {@inheritdoc}
1562 */
1563 public function getFormEditUrl( $formId ): string {
1564 return get_edit_post_link( (int) $formId, 'raw' ) ?: '';
1565 }
1566 }
1567