PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.11.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.11.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 All 47 releases
double-opt-in / src / Integration / AbstractFormIntegration.php

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

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