PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.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
← All changes | compatibility/OptInFrontend.class.php +261 -36 5.3.2 → 5.6.2 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace forge12\contactform7\CF7DoubleOptIn;
4 4
5 5
6 +use Forge12\DoubleOptIn\Consent\ConsentGate;
6 7 use Forge12\DoubleOptIn\Container\Container;
7 8 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
8 9 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
9 10 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
@@ -8,8 +9,9 @@
8 9 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
9 10 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
10 11 use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
11 12 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
13 +use Forge12\DoubleOptIn\Integration\FormIntegrationRegistry;
12 14 use Forge12\DoubleOptIn\Integration\OptInError;
13 15 use Forge12\DoubleOptIn\Service\RateLimiter;
14 16 use Forge12\Shared\Logger;
15 17 use Forge12\Shared\LoggerInterface;
@@ -65,8 +67,34 @@
65 67 */
66 68 protected ?OptInError $lastCreationError = null;
67 69
68 70 /**
71 + * Why the last maybeCreateOptIn() returned null, or null if it did not.
72 + *
73 + * Callers use it to answer in the form plugin's own words — Elementor
74 + * adds the message to its AJAX response when the error must reach the
75 + * visitor (OptInError::shouldShowToVisitor()).
76 + *
77 + * @return OptInError|null
78 + */
79 + public function getLastCreationError(): ?OptInError {
80 + return $this->lastCreationError;
81 + }
82 +
83 + /**
84 + * Remember the refusal and hand it to the frontend toast.
85 + *
86 + * @param OptInError $error The reason.
87 + * @param int $formId The form.
88 + *
89 + * @return void
90 + */
91 + protected function storeCreationError( OptInError $error, int $formId ): void {
92 + $this->lastCreationError = $error;
93 + ErrorNotification::store( $error, $formId );
94 + }
95 +
96 + /**
69 97 * Get the validation status from the last validateOptIn() call.
70 98 *
71 99 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
72 100 */
@@ -620,8 +648,26 @@
620 648 return false;
621 649 }
622 650
623 651 /**
652 + * Enable / Disable default mail.
653 + *
654 + * @param bool $status Enable (true) or disable (false) the default mail.
655 + * @param int $postId The ID of the Post / Form.
656 + *
657 + * @since 2.3.3
658 + */
659 + $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() );
660 +
661 + /**
662 + * Bind the follow-up plan before the confirmation is saved (see
663 + * AbstractFormIntegration::validateOptIn). False when no adapter
664 + * handles this integration → previous behaviour below.
665 + */
666 + $coordinator = \Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator::instance();
667 + $managed = $coordinator !== null && $coordinator->plan( $OptIn, $sendDefaultMail );
668 +
669 + /**
624 670 * Confirm the OptIn.
625 671 */
626 672 if ( $this->updateOptInByHash( $hash, 1, $OptIn ) <= 0 ) {
627 673 $this->get_logger()->info( 'OptIn update failed', [
@@ -633,17 +679,23 @@
633 679 }
634 680
635 681 $this->setValidationStatus( 'confirmed' );
636 682
637 - /**
638 - * Enable / Disable default mail.
639 - *
640 - * @param bool $status Enable (true) or disable (false) the default mail.
641 - * @param int $postId The ID of the Post / Form.
642 - *
643 - * @since 2.3.3
644 - */
645 - if ( ! apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() ) ) {
683 + if ( $managed ) {
684 + // Planned actions (skipped ones included) are recorded by the
685 + // coordinator; trigger_default_mail is not fired for managed
686 + // opt-ins, so no listener can replay them a second time.
687 + if ( $sendDefaultMail ) {
688 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
689 + }
690 + $coordinator->run( $OptIn, \Forge12\DoubleOptIn\FollowUp\FollowUpAttempt::TRIGGER_CONFIRM );
691 + if ( $sendDefaultMail ) {
692 + do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
693 + }
694 + return true;
695 + }
696 +
697 + if ( ! $sendDefaultMail ) {
646 698 $this->get_logger()->info( 'Default mail disabled for OptIn', [
647 699 'plugin' => 'double-opt-in',
648 700 'form_id' => $OptIn->get_cf_form_id(),
649 701 'optin_id' => $OptIn->get_id(),
@@ -768,16 +820,28 @@
768 820
769 821 /**
770 822 * Create the OptIn
771 823 *
772 - * @param int $formId The identifier of the form
773 - * @param string $formHtml The HTML code of the form
774 - * @param array $parameter The Post Parameter of the form.
775 - * @param array $files The Files attached to the form.
824 + * @param int $formId The identifier of the form
825 + * @param string $formHtml The HTML code of the form
826 + * @param array $parameter The Post Parameter of the form.
827 + * @param array $files The Files attached to the form.
828 + * @param array $knownFields The fields this form declares, `name => label`
829 + * or a plain list. Only the consent gate uses
830 + * them, to tell an unticked checkbox from a
831 + * setting that points at a field which no
832 + * longer exists. A shim that knows its form
833 + * better than the registry does (Elementor
834 + * has the Form_Record in hand) passes them in;
835 + * everyone else leaves it empty and
836 + * {@see self::resolveKnownFieldNames()} asks
837 + * the integration.
776 838 *
777 839 * @return OptIn|null
778 840 */
779 - protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array() ): ?OptIn {
841 + protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array(), array $knownFields = array() ): ?OptIn {
842 + $this->lastCreationError = null;
843 +
780 844 $this->get_logger()->debug( 'maybeCreateOptIn called', [
781 845 'plugin' => 'double-opt-in',
782 846 'class' => __CLASS__,
783 847 'method' => __METHOD__,
@@ -820,9 +884,9 @@
820 884 if ( empty( $recipient ) ) {
821 885 $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [
822 886 'plugin' => 'double-opt-in',
823 887 ] );
824 - ErrorNotification::store(
888 + $this->storeCreationError(
825 889 OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ),
826 890 $formId
827 891 );
828 892 return null;
@@ -828,8 +892,70 @@
828 892 return null;
829 893 }
830 894
831 895 /**
896 + * Consent gate (GDPR Art. 7) — same position in the flow as in
897 + * AbstractFormIntegration::createOptIn(): after the recipient is
898 + * known, before rate limiting.
899 + *
900 + * Until 5.4.0 this path had no gate at all. Everything that does
901 + * not extend AbstractFormIntegration comes through here —
902 + * Elementor, plus the CF7 and Avada legacy shims — so on those
903 + * forms the acceptance checkbox was stored as consent proof
904 + * without ever having been enforced. The banner in the admin UI
905 + * said as much since 5.3.2; this closes it.
906 + *
907 + * ConsentGate::FIELD_UNKNOWN never rejects. See the class for why
908 + * that matters: a stale `consent_field` must not take a site's
909 + * registrations offline.
910 + */
911 + $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter );
912 + $consentField = $consentSnapshot['field'];
913 + $consentGate = ConsentGate::evaluate(
914 + $consentField,
915 + $parameter,
916 + $this->resolveKnownFieldNames( $formId, $knownFields )
917 + );
918 +
919 + if ( $consentGate === ConsentGate::NOT_GIVEN && ! ConsentGate::isEnforced( $formId, $this->type ) ) {
920 + $this->get_logger()->warning( 'Consent gate disabled by filter — accepting an unconfirmed submission', [
921 + 'plugin' => 'double-opt-in',
922 + 'form_id' => $formId,
923 + 'integration' => $this->type,
924 + 'consent_field' => $consentField,
925 + ] );
926 + $consentGate = ConsentGate::PASSED;
927 + }
928 +
929 + if ( $consentGate === ConsentGate::NOT_GIVEN ) {
930 + $this->get_logger()->info( 'Consent acceptance not given, rejecting OptIn', [
931 + 'plugin' => 'double-opt-in',
932 + 'form_id' => $formId,
933 + 'integration' => $this->type,
934 + 'consent_field' => $consentField,
935 + ] );
936 + do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
937 + $this->storeCreationError(
938 + OptInError::fromCode(
939 + OptInError::CONSENT_NOT_GIVEN,
940 + [ 'form_id' => $formId, 'consent_field' => $consentField ]
941 + ),
942 + $formId
943 + );
944 + return null;
945 + }
946 +
947 + if ( $consentGate === ConsentGate::FIELD_UNKNOWN ) {
948 + $this->get_logger()->warning( 'Consent field is not on this form — opt-in accepted without provable consent', [
949 + 'plugin' => 'double-opt-in',
950 + 'form_id' => $formId,
951 + 'integration' => $this->type,
952 + 'consent_field' => $consentField,
953 + ] );
954 + do_action( 'f12_doi_consent_field_unknown', $formId, $consentField, $this->type );
955 + }
956 +
957 + /**
832 958 * Rate-Limiting: Check IP and email limits before creating OptIn.
833 959 */
834 960 $rateLimiter = new RateLimiter();
835 961 $ratSettings = CF7DoubleOptIn::getInstance()->getSettings();
@@ -844,9 +970,9 @@
844 970 'ip' => $ip,
845 971 'formId' => $formId,
846 972 ] );
847 973 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId );
848 - ErrorNotification::store(
974 + $this->storeCreationError(
849 975 OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ),
850 976 $formId
851 977 );
852 978 return null;
@@ -858,9 +984,9 @@
858 984 'email' => $recipient,
859 985 'formId' => $formId,
860 986 ] );
861 987 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId );
862 - ErrorNotification::store(
988 + $this->storeCreationError(
863 989 OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ),
864 990 $formId
865 991 );
866 992 return null;
@@ -919,9 +1045,9 @@
919 1045 ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
920 1046 [ 'email' => $recipient, 'form_id' => $formId ]
921 1047 );
922 1048
923 - ErrorNotification::store( $this->lastCreationError, $formId );
1049 + $this->storeCreationError( $this->lastCreationError, $formId );
924 1050 return null;
925 1051 }
926 1052
927 1053 /**
@@ -934,22 +1060,10 @@
934 1060 * AbstractFormIntegration::buildOptInProperties() path includes
935 1061 * both; this legacy path (used by Elementor + the CF7/Avada
936 1062 * legacy compat shims) used to only carry consent_text.
937 1063 */
938 - $consentText = '';
939 - $consentField = '';
940 - try {
941 - $container = \Forge12\DoubleOptIn\Container\Container::getInstance();
942 - $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class );
943 - $formSettings = $settingsService->getSettings( $formId );
944 - $consentText = $formSettings->consentText ?? '';
945 - $consentField = $formSettings->consentField ?? '';
946 - } catch ( \Exception $e ) {
947 - $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [
948 - 'plugin' => 'double-opt-in',
949 - 'error' => $e->getMessage(),
950 - ] );
951 - }
1064 + $consentText = $consentSnapshot['text'];
1065 + $consentField = $consentSnapshot['field'];
952 1066
953 1067 /**
954 1068 * Eigenschaften des OptIn-Objekts festlegen
955 1069 */
@@ -998,9 +1112,9 @@
998 1112 ] );
999 1113
1000 1114 do_action( 'f12_cf7_doubleoptin_creation_failed', $formId, $recipient );
1001 1115
1002 - ErrorNotification::store(
1116 + $this->storeCreationError(
1003 1117 OptInError::fromCode( OptInError::SAVE_FAILED, [ 'form_id' => $formId ] ),
1004 1118 $formId
1005 1119 );
1006 1120
@@ -1011,11 +1125,13 @@
1011 1125 /**
1012 1126 * Validate if the optin is enabled.
1013 1127 */
1014 1128 protected function isOptinEnabled( int $formId ): bool {
1015 - // Disable optin sending if the optin flag is set.
1016 - if ( isset( $_GET['optin'] ) ) {
1017 - $this->get_logger()->debug( 'Optin disabled due to optin flag in GET request', [
1129 + // Disable optin sending while our own post-confirmation replay runs.
1130 + // Not `isset( $_GET['optin'] )`: that is client input and let a
1131 + // submitter switch the double opt-in off.
1132 + if ( \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::isReplaying() ) {
1133 + $this->get_logger()->debug( 'Optin disabled during post-confirmation replay', [
1018 1134 'plugin' => 'double-opt-in',
1019 1135 'class' => __CLASS__,
1020 1136 'method' => __METHOD__,
1021 1137 ] );
@@ -1128,8 +1244,23 @@
1128 1244 return;
1129 1245 }
1130 1246
1131 1247 /**
1248 + * Only for this integration's own opt-in, only right after it was
1249 + * confirmed in this request, and only when no follow-up adapter
1250 + * owns the files. Previously any `?optin=` request — an expired
1251 + * link, a second click, another integration's hash — deleted the
1252 + * stored files, including ones a pending action still needed.
1253 + */
1254 + if ( ! $OptIn->isType( $this->type ) || self::$validationStatus !== 'confirmed' ) {
1255 + return;
1256 + }
1257 + $coordinator = \Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator::instance();
1258 + if ( $coordinator !== null && $coordinator->adapterFor( $OptIn ) !== null ) {
1259 + return;
1260 + }
1261 +
1262 + /**
1132 1263 * Load all files
1133 1264 */
1134 1265 $files = maybe_unserialize( $OptIn->get_files() );
1135 1266
@@ -1290,8 +1421,102 @@
1290 1421 $this->get_logger()->warning( 'Failed to dispatch OptInConfirmedEvent', [
1291 1422 'plugin' => 'double-opt-in',
1292 1423 'error' => $e->getMessage(),
1293 1424 ] );
1425 + }
1426 + }
1427 +
1428 + /**
1429 + * Load the consent snapshot (wording + acceptance field) for a form.
1430 + *
1431 + * The authoritative source is `FormSettingsService`, not the
1432 + * `$formParameter` array this legacy path carries — the settings the
1433 + * admin edits in the React UI land in post_meta and only some of them
1434 + * make it into `getParameter()`. `$formParameter` is used purely as a
1435 + * fallback for the case where the container is not available.
1436 + *
1437 + * Read once per submission and used twice: by the consent gate before
1438 + * the opt-in is created, and by the snapshot that is persisted with
1439 + * it. They must agree — a gate that reads a different field than the
1440 + * record stores would produce a proof of the wrong checkbox.
1441 + *
1442 + * @param int $formId The form being submitted.
1443 + * @param array $formParameter The legacy form-parameter array.
1444 + *
1445 + * @return array{text:string,field:string}
1446 + */
1447 + protected function loadConsentSnapshot( int $formId, array $formParameter = array() ): array {
1448 + $snapshot = [
1449 + 'text' => (string) ( $formParameter['consent_text'] ?? '' ),
1450 + 'field' => (string) ( $formParameter['consent_field'] ?? '' ),
1451 + ];
1452 +
1453 + try {
1454 + $container = Container::getInstance();
1455 + $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class );
1456 + $formSettings = $settingsService->getSettings( $formId );
1457 +
1458 + $snapshot['text'] = (string) ( $formSettings->consentText ?? '' );
1459 + $snapshot['field'] = (string) ( $formSettings->consentField ?? '' );
1460 + } catch ( \Throwable $e ) {
1461 + $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [
1462 + 'plugin' => 'double-opt-in',
1463 + 'form_id' => $formId,
1464 + 'error' => $e->getMessage(),
1465 + ] );
1466 + }
1467 +
1468 + return $snapshot;
1469 + }
1470 +
1471 + /**
1472 + * The field names this form declares, for the consent gate.
1473 + *
1474 + * An unticked checkbox never reaches the server, so the payload alone
1475 + * cannot distinguish "the visitor left the box alone" from "the
1476 + * configured field does not exist any more". The form's own
1477 + * definition can, and every integration exposes it through
1478 + * `FormIntegrationInterface::getFormFields()`.
1479 + *
1480 + * A shim may pass the inventory in directly — Elementor does, because
1481 + * its `Form_Record` lists every declared field including the empty
1482 + * ones, while the composite form ID its `getFormFields()` wants
1483 + * (`{postId}_{widgetId}`) is not what this legacy path carries.
1484 + * Otherwise we ask the registry for the integration behind
1485 + * `$this->type`.
1486 + *
1487 + * Returning an empty array is a valid answer and means "unknown" —
1488 + * the gate then rejects nothing it cannot prove.
1489 + *
1490 + * @param int $formId The form being submitted.
1491 + * @param array $explicit Inventory supplied by the caller, if any.
1492 + *
1493 + * @return array<int,string>
1494 + */
1495 + protected function resolveKnownFieldNames( int $formId, array $explicit = array() ): array {
1496 + if ( $explicit !== array() ) {
1497 + return ConsentGate::normalizeFieldNames( $explicit );
1498 + }
1499 +
1500 + if ( $this->type === '' || ! class_exists( FormIntegrationRegistry::class ) ) {
1501 + return array();
1502 + }
1503 +
1504 + try {
1505 + $integration = FormIntegrationRegistry::getInstance()->get( $this->type );
1506 + if ( $integration === null ) {
1507 + return array();
1508 + }
1509 +
1510 + return ConsentGate::normalizeFieldNames( $integration->getFormFields( $formId ) );
1511 + } catch ( \Throwable $e ) {
1512 + $this->get_logger()->warning( 'Could not read the form field inventory for the consent gate', [
1513 + 'plugin' => 'double-opt-in',
1514 + 'form_id' => $formId,
1515 + 'error' => $e->getMessage(),
1516 + ] );
1517 +
1518 + return array();
1294 1519 }
1295 1520 }
1296 1521
1297 1522 /**