PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.3.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.3.2
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
double-opt-in / src / Integration / AbstractFormIntegration.php

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

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