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 +271 -6 5.7.0 → 5.8.1 View file →
@@ -13,8 +13,10 @@
13 13 use Forge12\DoubleOptIn\FollowUp\FollowUpAttempt;
14 14 use Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator;
15 15 use Forge12\DoubleOptIn\FollowUp\FollowUpResult;
16 16 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
17 +use Forge12\DoubleOptIn\Frontend\SubmitNotice;
18 +use Forge12\DoubleOptIn\Spam\SubmissionTrap;
17 19 use forge12\contactform7\CF7DoubleOptIn\Category;
18 20 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
19 21 use forge12\contactform7\CF7DoubleOptIn\HTMLSelect;
20 22 use forge12\contactform7\CF7DoubleOptIn\OptIn;
@@ -40,8 +42,23 @@
40 42 */
41 43 private ?OptIn $currentOptIn = null;
42 44
43 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 + /**
44 61 * {@inheritdoc}
45 62 */
46 63 public function getIdentifier(): string {
47 64 return 'cf7';
@@ -82,8 +99,16 @@
82 99 // Frontend hooks
83 100 add_action( 'wpcf7_before_send_mail', array( $this, 'onSubmit' ), $this->getHookPriority(), 3 );
84 101 add_action( 'init', array( $this, 'handleOptInConfirmation' ) );
85 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 +
86 111 // Register recipient filter
87 112 add_filter( 'f12_cf7_doubleoptin_get_recipient_cf7', array( $this, 'getRecipientFilter' ), 10, 3 );
88 113
89 114 // Confirmation mail hooks
@@ -303,9 +328,13 @@
303 328 return;
304 329 }
305 330
306 331 // Send opt-in mail
332 + $this->lastOptInMail = null;
307 333 $this->sendOptInMail( $optIn, $formData, $formParameter );
334 + if ( $this->lastOptInMail !== null ) {
335 + $this->submitted = array( 'form_id' => (int) $formId ) + $this->lastOptInMail;
336 + }
308 337
309 338 // Skip original mail
310 339 add_filter( 'wpcf7_skip_mail', '__return_true' );
311 340 do_action( 'f12_cf7_doubleoptin_sent', $form, $formId );
@@ -351,21 +380,257 @@
351 380 if ( ! empty( $args['sender_name'] ) ) {
352 381 $args['additional_headers'] .= 'From: ' . $args['sender_name'] . ' <' . $args['sender'] . '>';
353 382 }
354 383
355 - // Send via CF7 mail system
356 - \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' );
357 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 +
358 408 $this->getLogger()->info(
359 - 'OptIn mail sent via CF7',
409 + $sent ? 'OptIn mail handed to the mail server via CF7' : 'OptIn mail could not be sent via CF7',
360 410 array(
361 - 'plugin' => 'double-opt-in',
362 - 'form_id' => $formData->getFormId(),
363 - 'recipient' => $args['recipient'],
411 + 'plugin' => 'double-opt-in',
412 + 'form_id' => $formData->getFormId(),
413 + 'optin_id' => (int) $optIn->get_id(),
364 414 )
365 415 );
366 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 +
367 601 return true;
602 + }
603 +
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 + );
368 633 }
369 634
370 635 /**
371 636 * {@inheritdoc}