PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
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 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
← All changes | src/Integration/CF7Integration.php +372 -33 5.1.6 → 5.8.1 View file →
@@ -9,8 +9,14 @@
9 9 namespace Forge12\DoubleOptIn\Integration;
10 10
11 11 use Forge12\DoubleOptIn\Container\Container;
12 12 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
13 +use Forge12\DoubleOptIn\FollowUp\FollowUpAttempt;
14 +use Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator;
15 +use Forge12\DoubleOptIn\FollowUp\FollowUpResult;
16 +use Forge12\DoubleOptIn\Frontend\ErrorNotification;
17 +use Forge12\DoubleOptIn\Frontend\SubmitNotice;
18 +use Forge12\DoubleOptIn\Spam\SubmissionTrap;
13 19 use forge12\contactform7\CF7DoubleOptIn\Category;
14 20 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
15 21 use forge12\contactform7\CF7DoubleOptIn\HTMLSelect;
16 22 use forge12\contactform7\CF7DoubleOptIn\OptIn;
@@ -36,8 +42,23 @@
36 42 */
37 43 private ?OptIn $currentOptIn = null;
38 44
39 45 /**
46 + * The confirmation mail sent in this request, for the feedback response
47 + * (5.8.0): recipient, resolved sender and subject, and the outcome.
48 + *
49 + * @var array{email: string, sender: string, subject: string, sent: bool}|null
50 + */
51 + private $lastOptInMail = null;
52 +
53 + /**
54 + * The DOI submission of this request, keyed to its form (5.8.0).
55 + *
56 + * @var array{form_id: int, email: string, sender: string, subject: string, sent: bool}|null
57 + */
58 + private $submitted = null;
59 +
60 + /**
40 61 * {@inheritdoc}
41 62 */
42 63 public function getIdentifier(): string {
43 64 return 'cf7';
@@ -78,8 +99,16 @@
78 99 // Frontend hooks
79 100 add_action( 'wpcf7_before_send_mail', array( $this, 'onSubmit' ), $this->getHookPriority(), 3 );
80 101 add_action( 'init', array( $this, 'handleOptInConfirmation' ) );
81 102
103 + // Honest success message + "open your inbox" (5.8.0).
104 + add_filter( 'wpcf7_feedback_response', array( $this, 'filterFeedbackResponse' ), 10, 2 );
105 + add_action( 'wpcf7_enqueue_scripts', array( $this, 'enqueueSubmitNotice' ) );
106 +
107 + // Honeypot + minimum fill time, through CF7's own spam flow (5.8.0).
108 + add_filter( 'wpcf7_form_elements', array( $this, 'addSubmissionTrap' ) );
109 + add_filter( 'wpcf7_spam', array( $this, 'checkSubmissionTrap' ), 9, 2 );
110 +
82 111 // Register recipient filter
83 112 add_filter( 'f12_cf7_doubleoptin_get_recipient_cf7', array( $this, 'getRecipientFilter' ), 10, 3 );
84 113
85 114 // Confirmation mail hooks
@@ -84,9 +113,9 @@
84 113
85 114 // Confirmation mail hooks
86 115 add_action( 'f12_cf7_doubleoptin_before_send_default_mail', array( $this, 'beforeSendDefaultMail' ) );
87 116 add_action( 'f12_cf7_doubleoptin_after_send_default_mail', array( $this, 'afterSendDefaultMail' ) );
88 - add_action( 'f12_cf7_doubleoptin_trigger_default_mail', array( $this, 'sendConfirmationMail' ) );
117 + add_action( 'f12_cf7_doubleoptin_trigger_default_mail', array( $this, 'onTriggerDefaultMail' ) );
89 118
90 119 // File hand-off + pending-cleanup. CF7 attaches files to the
91 120 // confirmation mail in attachExtraAttachments (hooked on
92 121 // wpcf7_before_send_mail during sendConfirmationMail). Cleanup
@@ -245,10 +274,10 @@
245 274 )
246 275 );
247 276
248 277 if ( ! $this->isOptInEnabled( $formId ) ) {
249 - // Handle file attachments for confirmation
250 - if ( isset( $_GET['optin'] ) ) {
278 + // Our own post-confirmation replay: attach the stored files.
279 + if ( self::isReplaying() ) {
251 280 $this->attachStoredFiles( $submission );
252 281 }
253 282 return;
254 283 }
@@ -283,20 +312,29 @@
283 312 // Always prevent the original CF7 mail from being sent when opt-in creation fails
284 313 add_filter( 'wpcf7_skip_mail', '__return_true' );
285 314
286 315 $error = self::getLastError();
287 - if ( $error && apply_filters( 'f12_cf7_doubleoptin_show_validation_error', false ) ) {
316 + // Abort with the reason instead of CF7's "sent": CF7 then keeps the
317 + // visitor's input and shows the message in the form. A refused
318 + // consent always takes this path (OptInError::isAlwaysShown()).
319 + if ( $error && $error->shouldShowToVisitor( (int) $formId ) ) {
288 320 $message = apply_filters( 'f12_cf7_doubleoptin_error_message', $error->getMessage(), $error, $formId );
289 321 if ( method_exists( $submission, 'set_response' ) ) {
290 322 $submission->set_response( $message );
291 323 }
292 324 $abort = true;
325 + // The form shows it now — no second copy in the toast.
326 + ErrorNotification::forget();
293 327 }
294 328 return;
295 329 }
296 330
297 331 // Send opt-in mail
332 + $this->lastOptInMail = null;
298 333 $this->sendOptInMail( $optIn, $formData, $formParameter );
334 + if ( $this->lastOptInMail !== null ) {
335 + $this->submitted = array( 'form_id' => (int) $formId ) + $this->lastOptInMail;
336 + }
299 337
300 338 // Skip original mail
301 339 add_filter( 'wpcf7_skip_mail', '__return_true' );
302 340 do_action( 'f12_cf7_doubleoptin_sent', $form, $formId );
@@ -342,28 +380,306 @@
342 380 if ( ! empty( $args['sender_name'] ) ) {
343 381 $args['additional_headers'] .= 'From: ' . $args['sender_name'] . ' <' . $args['sender'] . '>';
344 382 }
345 383
346 - // Send via CF7 mail system
347 - \WPCF7_Mail::send( $args, 'mail' );
384 + // Send via CF7 mail system. Up to 5.7 the result was dropped and a
385 + // failed send looked exactly like a successful one.
386 + $sent = (bool) \WPCF7_Mail::send( $args, 'mail' );
348 387
388 + // Record the outcome on the opt-in (OptInMailTracker). No address in
389 + // the log: the id identifies the record.
390 + do_action( 'f12_doi_optin_mail_result', (int) $optIn->get_id(), $sent, '' );
391 +
392 + // What the visitor is told to look for — with CF7's mail tags resolved,
393 + // as WPCF7_Mail::send() did for the mail itself.
394 + $sender = (string) $args['sender'];
395 + $subject = (string) $args['subject'];
396 + if ( function_exists( 'wpcf7_mail_replace_tags' ) ) {
397 + $sender = (string) wpcf7_mail_replace_tags( $sender );
398 + $subject = (string) wpcf7_mail_replace_tags( $subject );
399 + }
400 + $this->lastOptInMail = array(
401 + 'optin_id' => (int) $optIn->get_id(),
402 + 'email' => (string) $optIn->get_email(),
403 + 'sender' => SubmitNotice::senderAddress( $sender ),
404 + 'subject' => trim( wp_strip_all_tags( $subject ) ),
405 + 'sent' => $sent,
406 + );
407 +
349 408 $this->getLogger()->info(
350 - 'OptIn mail sent via CF7',
409 + $sent ? 'OptIn mail handed to the mail server via CF7' : 'OptIn mail could not be sent via CF7',
351 410 array(
352 - 'plugin' => 'double-opt-in',
353 - 'form_id' => $formData->getFormId(),
354 - 'recipient' => $args['recipient'],
411 + 'plugin' => 'double-opt-in',
412 + 'form_id' => $formData->getFormId(),
413 + 'optin_id' => (int) $optIn->get_id(),
355 414 )
356 415 );
357 416
417 + return $sent;
418 + }
419 +
420 + /**
421 + * Make CF7's answer to a double opt-in submission tell the truth (5.8.0).
422 + *
423 + * CF7 answers "Thank you for your message. It has been sent." — but
424 + * nothing is sent until the address is confirmed, and when the
425 + * confirmation mail itself failed CF7 still said so and cleared the form.
426 + * CF7 overwrites any response set during the submission, so this runs on
427 + * the finished feedback response.
428 + *
429 + * @param mixed $response CF7 feedback response.
430 + * @param mixed $result CF7 submission result.
431 + *
432 + * @return mixed
433 + */
434 + public function filterFeedbackResponse( $response, $result = null ) {
435 + if ( ! is_array( $response ) || $this->submitted === null ) {
436 + return $response;
437 + }
438 +
439 + $formId = (int) ( $response['contact_form_id'] ?? 0 );
440 + if ( $formId !== $this->submitted['form_id'] || ( $response['status'] ?? '' ) !== 'mail_sent' ) {
441 + return $response;
442 + }
443 +
444 + $submitted = $this->submitted;
445 + $this->submitted = null;
446 + $form = class_exists( '\WPCF7_ContactForm' ) ? \WPCF7_ContactForm::get_instance( $formId ) : null;
447 +
448 + if ( ! $submitted['sent'] ) {
449 + // CF7 then keeps the visitor's input and fires wpcf7mailfailed.
450 + $response['status'] = 'mail_failed';
451 + $response['message'] = $form ? (string) $form->message( 'mail_sent_ng' ) : __( 'The confirmation mail could not be sent. Please try again later.', 'double-opt-in' );
452 + return $response;
453 + }
454 +
455 + /**
456 + * Whether to show the confirmation hint (masked address, what to look
457 + * for, "open your inbox" link) after a double opt-in submission.
458 + *
459 + * @since 5.8.0
460 + *
461 + * @param bool $show Default true.
462 + * @param int $formId Form ID.
463 + */
464 + if ( ! apply_filters( 'f12_doi_submit_notice', true, $formId ) ) {
465 + return $response;
466 + }
467 +
468 + $notice = SubmitNotice::extend(
469 + SubmitNotice::build( $submitted['email'], $submitted['sender'], $submitted['subject'] ),
470 + array(
471 + 'form_id' => $formId,
472 + 'optin_id' => (int) ( $submitted['optin_id'] ?? 0 ),
473 + 'integration' => 'cf7',
474 + )
475 + );
476 +
477 + // A message the site owner wrote stays; only CF7's own default is wrong.
478 + if ( $form && self::isDefaultSentMessage( (string) $form->message( 'mail_sent_ok', false ) ) ) {
479 + $response['message'] = SubmitNotice::message( $notice['masked'] );
480 + }
481 +
482 + $response['doi'] = $notice;
483 +
484 + return $response;
485 + }
486 +
487 + /**
488 + * Whether a form's success message is still CF7's default, in English or
489 + * in the current language.
490 + */
491 + public static function isDefaultSentMessage( string $message ): bool {
492 + $defaults = array( 'Thank you for your message. It has been sent.' );
493 + if ( function_exists( 'wpcf7_messages' ) ) {
494 + $messages = wpcf7_messages();
495 + $defaults[] = (string) ( $messages['mail_sent_ok']['default'] ?? '' );
496 + }
497 +
498 + return in_array( trim( $message ), array_filter( $defaults ), true );
499 + }
500 +
501 + /**
502 + * Whether a form gets the honeypot and the minimum fill time.
503 + */
504 + private function trapEnabled( int $formId ): bool {
505 + /**
506 + * Whether a double opt-in form gets the honeypot and the minimum
507 + * fill time.
508 + *
509 + * @since 5.8.0
510 + *
511 + * @param bool $enabled Default true.
512 + * @param int $formId Form ID.
513 + */
514 + return $formId > 0 && $this->isOptInEnabled( $formId ) && (bool) apply_filters( 'f12_doi_spam_trap', true, $formId );
515 + }
516 +
517 + /**
518 + * Add the trap fields to a double opt-in form.
519 + *
520 + * @param mixed $elements Form HTML.
521 + *
522 + * @return mixed
523 + */
524 + public function addSubmissionTrap( $elements ) {
525 + if ( ! is_string( $elements ) || ! function_exists( 'wpcf7_get_current_contact_form' ) ) {
526 + return $elements;
527 + }
528 + $form = wpcf7_get_current_contact_form();
529 + if ( ! $form || ! $this->trapEnabled( (int) $form->id() ) ) {
530 + return $elements;
531 + }
532 +
533 + return $elements . SubmissionTrap::markup( time() );
534 + }
535 +
536 + /**
537 + * Mark a bot submission as spam before any opt-in or mail exists.
538 + *
539 + * @param mixed $spam CF7's verdict so far.
540 + * @param mixed $submission The submission.
541 + *
542 + * @return mixed
543 + */
544 + public function checkSubmissionTrap( $spam, $submission = null ) {
545 + if ( $spam || self::isReplaying() || ! is_object( $submission ) || ! method_exists( $submission, 'get_contact_form' ) ) {
546 + return $spam;
547 + }
548 + $form = $submission->get_contact_form();
549 + $formId = $form ? (int) $form->id() : 0;
550 + if ( ! $this->trapEnabled( $formId ) ) {
551 + return $spam;
552 + }
553 +
554 + /**
555 + * Minimum seconds between rendering a double opt-in form and
556 + * submitting it; faster submissions are treated as bots.
557 + *
558 + * @since 5.8.0
559 + *
560 + * @param int $seconds Default 2.
561 + * @param int $formId Form ID.
562 + */
563 + $minSeconds = (int) apply_filters( 'f12_doi_min_fill_seconds', SubmissionTrap::DEFAULT_MIN_SECONDS, $formId );
564 +
565 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- CF7 verified the submission; only our two trap fields are read.
566 + $reason = SubmissionTrap::check( wp_unslash( $_POST ), time(), $minSeconds );
567 + if ( $reason === '' ) {
568 + return $spam;
569 + }
570 +
571 + if ( method_exists( $submission, 'add_spam_log' ) ) {
572 + $submission->add_spam_log(
573 + array(
574 + 'agent' => 'double-opt-in',
575 + 'reason' => $reason,
576 + )
577 + );
578 + }
579 +
580 + /**
581 + * A double opt-in submission was stopped by the honeypot or the
582 + * minimum fill time. No personal data is passed.
583 + *
584 + * @since 5.8.0
585 + *
586 + * @param string $reason 'honeypot', 'stamp_invalid' or 'too_fast'.
587 + * @param int $formId Form ID.
588 + * @param string $integration Integration identifier.
589 + */
590 + do_action( 'f12_doi_spam_trap_hit', $reason, $formId, 'cf7' );
591 +
592 + $this->getLogger()->info(
593 + 'Submission stopped by the spam trap',
594 + array(
595 + 'plugin' => 'double-opt-in',
596 + 'form_id' => $formId,
597 + 'reason' => $reason,
598 + )
599 + );
600 +
358 601 return true;
359 602 }
360 603
361 604 /**
605 + * The script that renders the confirmation hint under the CF7 message.
606 + * Runs only where CF7 enqueues its own scripts.
607 + *
608 + * @return void
609 + */
610 + public function enqueueSubmitNotice(): void {
611 + $version = defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : false;
612 +
613 + wp_enqueue_script(
614 + 'f12-doi-submit-notice',
615 + plugins_url( 'assets/js/doi-submit-notice.js', F12_DOUBLEOPTIN_PLUGIN_FILE ),
616 + array(),
617 + $version,
618 + true
619 + );
620 +
621 + wp_register_style( 'f12-doi-submit-notice', false, array(), $version );
622 + wp_enqueue_style( 'f12-doi-submit-notice' );
623 + wp_add_inline_style(
624 + 'f12-doi-submit-notice',
625 + '.f12-doi-notice{margin:.5em 0 1em;padding:0 1em}'
626 + . '.f12-doi-notice p{margin:.4em 0}'
627 + . '.f12-doi-notice .f12-doi-inbox{display:inline-block;margin-top:.4em;padding:.5em 1em;border:1px solid currentColor;border-radius:4px;text-decoration:none;font-weight:600}'
628 + // Buttons and links of add-ons (f12_doi_submit_notice_data) look like
629 + // the inbox link instead of a bare browser button.
630 + . '.f12-doi-notice .f12-doi-action{display:inline-block;margin-top:.4em;padding:.5em 1em;border:1px solid currentColor;border-radius:4px;background:transparent;color:inherit;font:inherit;font-weight:600;text-decoration:none;cursor:pointer}'
631 + . '.f12-doi-notice button.f12-doi-action:disabled{opacity:.5;cursor:default}'
632 + );
633 + }
634 +
635 + /**
362 636 * {@inheritdoc}
363 637 */
364 638 public function sendConfirmationMail( OptIn $optIn ): void {
365 - if ( ! $this->isAvailable() ) {
639 + self::runAsReplay(
640 + function () use ( $optIn ) {
641 + $this->replaySubmission( $optIn );
642 + }
643 + );
644 + }
645 +
646 + /**
647 + * Listener on the global `f12_cf7_doubleoptin_trigger_default_mail`.
648 + * That action fires for every integration's opt-in; this used to run
649 + * the CF7 submission for Elementor opt-ins too, overwriting $_POST.
650 + * Managed opt-ins go through the follow-up coordinator, so a second
651 + * trigger never sends twice.
652 + *
653 + * @param OptIn $optIn The confirmed opt-in.
654 + *
655 + * @since 5.6.0
656 + */
657 + public function onTriggerDefaultMail( OptIn $optIn ): void {
658 + if ( ! $optIn->isType( $this->getIdentifier() ) ) {
659 + return;
660 + }
661 +
662 + $coordinator = FollowUpCoordinator::instance();
663 + if ( $coordinator !== null && $coordinator->plan( $optIn, true ) ) {
664 + $coordinator->run( $optIn, FollowUpAttempt::TRIGGER_LEGACY );
665 + return;
666 + }
667 +
668 + $this->sendConfirmationMail( $optIn );
669 + }
670 +
671 + /**
672 + * Re-run the stored submission through CF7 and report what CF7 says.
673 + *
674 + * Must run inside {@see runAsReplay()} so our own
675 + * `wpcf7_before_send_mail` listener processes it as the confirmed
676 + * submission (attachments) instead of creating a new opt-in.
677 + *
678 + * @since 5.6.0
679 + */
680 + public function replaySubmission( OptIn $optIn ): FollowUpResult {
681 + if ( ! $this->isAvailable() || ! class_exists( '\\WPCF7_ContactForm' ) || ! class_exists( '\\WPCF7_Submission' ) ) {
366 682 $this->getLogger()->warning(
367 683 'CF7 not available for confirmation mail',
368 684 array(
369 685 'plugin' => 'double-opt-in',
@@ -368,18 +684,11 @@
368 684 array(
369 685 'plugin' => 'double-opt-in',
370 686 )
371 687 );
372 - return;
688 + return FollowUpResult::failedRetryable( 'integration_unavailable' );
373 689 }
374 690
375 - $this->currentOptIn = $optIn;
376 -
377 - // Restore POST data
378 - $data = maybe_unserialize( $optIn->get_content() );
379 - $_POST = SanitizeHelper::sanitize_array( $data );
380 -
381 - // Get CF7 form
382 691 $contactForm = \WPCF7_ContactForm::get_instance( $optIn->get_cf_form_id() );
383 692 if ( ! $contactForm ) {
384 693 $this->getLogger()->warning(
385 694 'CF7 form not found for confirmation mail',
@@ -387,31 +696,52 @@
387 696 'plugin' => 'double-opt-in',
388 697 'form_id' => $optIn->get_cf_form_id(),
389 698 )
390 699 );
391 - return;
700 + return FollowUpResult::failedPermanent( 'form_missing' );
392 701 }
393 702
394 - // Add attachment hook
395 - add_action( 'wpcf7_before_send_mail', array( $this, 'attachExtraAttachments' ), 10, 3 );
703 + $data = maybe_unserialize( $optIn->get_content() );
704 + if ( ! is_array( $data ) ) {
705 + return FollowUpResult::failedPermanent( 'payload_missing' );
706 + }
396 707
397 - // Disable validation and spam checks before creating submission
398 - $this->beforeSendConfirmationMail();
708 + $previousPost = $_POST;
709 + $previousOptIn = $this->currentOptIn;
710 + $this->currentOptIn = $optIn;
711 + $status = '';
399 712
400 - // Create submission and send mail
401 - $submission = \WPCF7_Submission::get_instance( $contactForm );
713 + try {
714 + $_POST = SanitizeHelper::sanitize_array( $data );
402 715
403 - // Re-enable validation and spam checks
404 - $this->afterSendConfirmationMail();
716 + // Disable validation and spam checks before creating submission
717 + $this->beforeSendConfirmationMail();
405 718
719 + // Create submission and send mail. Attachments are added by
720 + // onSubmit() → attachStoredFiles() while isReplaying().
721 + $submission = \WPCF7_Submission::get_instance( $contactForm );
722 +
723 + if ( is_object( $submission ) && method_exists( $submission, 'get_status' ) ) {
724 + $status = (string) $submission->get_status();
725 + }
726 + } finally {
727 + // Re-enable validation and spam checks, restore request state.
728 + $this->afterSendConfirmationMail();
729 + $_POST = $previousPost;
730 + $this->currentOptIn = $previousOptIn;
731 + }
732 +
406 733 $this->getLogger()->info(
407 734 'Confirmation mail triggered via CF7',
408 735 array(
409 - 'plugin' => 'double-opt-in',
410 - 'form_id' => $optIn->get_cf_form_id(),
411 - 'optin_id' => $optIn->get_id(),
736 + 'plugin' => 'double-opt-in',
737 + 'form_id' => $optIn->get_cf_form_id(),
738 + 'optin_id' => $optIn->get_id(),
739 + 'cf7_status' => $status,
412 740 )
413 741 );
742 +
743 + return CF7FollowUpAdapter::mapStatus( $status );
414 744 }
415 745
416 746 /**
417 747 * Handle opt-in confirmation from URL.
@@ -472,10 +802,11 @@
472 802 *
473 803 * @return void
474 804 */
475 805 private function attachStoredFiles( $submission ): void {
476 - $hash = sanitize_text_field( $_GET['optin'] );
477 - $optIn = OptIn::get_by_hash( $hash );
806 + // The opt-in being replayed — not the one named in the URL, which
807 + // a cron or admin retry does not have (and a visitor controls).
808 + $optIn = $this->currentOptIn;
478 809
479 810 if ( ! $optIn ) {
480 811 return;
481 812 }
@@ -537,8 +868,16 @@
537 868 *
538 869 * @since 4.3.0
539 870 */
540 871 public function cleanupPendingAfterMail( OptIn $optIn ): void {
872 + // Managed opt-ins: CF7FollowUpAdapter::onSettled() cleans up only
873 + // once the mail was actually handed over. This hook fires after
874 + // the attempt regardless of its outcome.
875 + $coordinator = FollowUpCoordinator::instance();
876 + if ( $coordinator !== null && $coordinator->adapterFor( $optIn ) !== null ) {
877 + return;
878 + }
879 +
541 880 // Template-method's $hash arg is unused inside processFilesOnConfirm;
542 881 // passing an empty string keeps the contract tight.
543 882 $this->processFilesOnConfirm( '', $optIn );
544 883 }