PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.0
5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 All 42 releases
← All changes | src/Setup/SetupService.php +224 -90 5.8.1 → 5.9.0 View file →
@@ -52,21 +52,33 @@
52 52
53 53 /** @var SetupMailComposer */
54 54 private $composer;
55 55
56 - /** @var callable():array<int, array{id:int, title:string, enabled:bool}> */
57 - private $cf7Forms;
56 + /**
57 + * Lists the forms of every available integration. Entries without an
58 + * `integration` are Contact Form 7 forms.
59 + *
60 + * @var callable():array<int, array{id:int|string, title:string, enabled:bool, integration?:string, integrationName?:string}>
61 + */
62 + private $formLister;
58 63
59 - /** @var callable(int):string[] */
60 - private $cf7Fields;
64 + /**
65 + * Lists the fields of one form as key => label.
66 + *
67 + * @var callable(int|string, string):array<int|string, string>
68 + */
69 + private $fieldLister;
61 70
62 71 /** @var callable():array<int, array{id:int, title:string}> */
63 72 private $pages;
64 73
74 + /** @var array<string, array<string, string>> Fields per form key, per request. */
75 + private $fieldCache = array();
76 +
65 77 /**
66 - * @param callable|null $cf7Forms Lists CF7 forms (test seam).
67 - * @param callable|null $cf7Fields Lists the field names of a CF7 form (test seam).
68 - * @param callable|null $pages Lists published pages (test seam).
78 + * @param callable|null $forms Lists the forms of all integrations (test seam).
79 + * @param callable|null $fields Lists the fields of a form (test seam).
80 + * @param callable|null $pages Lists published pages (test seam).
69 81 */
70 82 public function __construct(
71 83 SetupState $state,
72 84 FormSettingsService $settings,
@@ -71,22 +83,101 @@
71 83 SetupState $state,
72 84 FormSettingsService $settings,
73 85 FormPluginDetector $detector,
74 86 SetupMailComposer $composer,
75 - ?callable $cf7Forms = null,
76 - ?callable $cf7Fields = null,
87 + ?callable $forms = null,
88 + ?callable $fields = null,
77 89 ?callable $pages = null
78 90 ) {
79 - $this->state = $state;
80 - $this->settings = $settings;
81 - $this->detector = $detector;
82 - $this->composer = $composer;
83 - $this->cf7Forms = $cf7Forms ?? array( self::class, 'listCf7Forms' );
84 - $this->cf7Fields = $cf7Fields ?? array( self::class, 'listCf7Fields' );
85 - $this->pages = $pages ?? array( self::class, 'listPublishedPages' );
91 + $this->state = $state;
92 + $this->settings = $settings;
93 + $this->detector = $detector;
94 + $this->composer = $composer;
95 + $this->formLister = $forms ?? array( self::class, 'listForms' );
96 + $this->fieldLister = $fields ?? array( self::class, 'listFields' );
97 + $this->pages = $pages ?? array( self::class, 'listPublishedPages' );
86 98 }
87 99
88 100 /**
101 + * All forms, keyed "integration:id".
102 + *
103 + * @return array<string, array{key:string, integration:string, integrationName:string, id:int|string, title:string, enabled:bool}>
104 + */
105 + private function forms(): array {
106 + $forms = array();
107 + foreach ( (array) call_user_func( $this->formLister ) as $form ) {
108 + if ( ! is_array( $form ) || ! isset( $form['id'] ) ) {
109 + continue;
110 + }
111 + $integration = (string) ( $form['integration'] ?? 'cf7' );
112 + $id = $form['id'];
113 + $key = self::formKey( $integration, $id );
114 + $forms[ $key ] = array(
115 + 'key' => $key,
116 + 'integration' => $integration,
117 + 'integrationName' => (string) ( $form['integrationName'] ?? ( $integration === 'cf7' ? 'Contact Form 7' : $integration ) ),
118 + 'id' => $id,
119 + 'title' => (string) ( $form['title'] ?? '' ),
120 + 'enabled' => ! empty( $form['enabled'] ),
121 + );
122 + }
123 + return $forms;
124 + }
125 +
126 + /**
127 + * Fields of a form as key => label.
128 + *
129 + * @param array{key:string, integration:string, id:int|string} $form
130 + *
131 + * @return array<string, string>
132 + */
133 + private function fieldsOf( array $form ): array {
134 + if ( isset( $this->fieldCache[ $form['key'] ] ) ) {
135 + return $this->fieldCache[ $form['key'] ];
136 + }
137 + $fields = array();
138 + foreach ( (array) call_user_func( $this->fieldLister, $form['id'], $form['integration'] ) as $key => $label ) {
139 + if ( ! is_scalar( $label ) ) {
140 + continue;
141 + }
142 + $name = (string) $key;
143 + $fields[ $name ] = trim( (string) $label ) !== '' ? (string) $label : $name;
144 + }
145 + $this->fieldCache[ $form['key'] ] = $fields;
146 + return $fields;
147 + }
148 +
149 + public static function formKey( string $integration, $id ): string {
150 + return $integration . ':' . (string) $id;
151 + }
152 +
153 + /**
154 + * Where a form's settings live: the post ID, also for Elementor's
155 + * "page_widget" IDs — the same rule the Forms page uses.
156 + *
157 + * @param int|string $id
158 + */
159 + private static function storageId( $id ): int {
160 + return (int) explode( '_', (string) $id )[0];
161 + }
162 +
163 + /**
164 + * Draft forms keyed by form key. Drafts from 5.7 used bare CF7 IDs.
165 + *
166 + * @param array<int|string, mixed> $forms
167 + *
168 + * @return array<string, string>
169 + */
170 + private static function normalizeDraftForms( array $forms ): array {
171 + $out = array();
172 + foreach ( $forms as $key => $field ) {
173 + $key = is_int( $key ) || ctype_digit( (string) $key ) ? self::formKey( 'cf7', $key ) : (string) $key;
174 + $out[ $key ] = (string) $field;
175 + }
176 + return $out;
177 + }
178 +
179 + /**
89 180 * Everything the wizard needs to render, with prefilled values.
90 181 *
91 182 * @return array<string, mixed>
92 183 */
@@ -94,32 +185,44 @@
94 185 $state = $this->state->get();
95 186 $draft = $state['draft'];
96 187 $defaults = FormDefaults::get();
97 188
98 - $forms = array();
99 - $draftForms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? $draft['forms'] : null;
100 - foreach ( (array) call_user_func( $this->cf7Forms ) as $form ) {
101 - $id = (int) $form['id'];
102 - $fields = array_values( array_map( 'strval', (array) call_user_func( $this->cf7Fields, $id ) ) );
103 - $detected = self::detectEmailField( $fields );
104 - $enabled = ! empty( $form['enabled'] );
189 + $forms = array();
190 + $integrations = array();
191 + $draftForms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? self::normalizeDraftForms( $draft['forms'] ) : null;
192 + foreach ( $this->forms() as $key => $form ) {
193 + $fields = $this->fieldsOf( $form );
194 + $detected = self::detectEmailFieldIn( $fields );
195 + $enabled = $form['enabled'];
105 196
106 197 if ( $draftForms !== null ) {
107 - $selected = array_key_exists( (string) $id, $draftForms ) || array_key_exists( $id, $draftForms );
108 - $field = $selected ? (string) ( $draftForms[ $id ] ?? $draftForms[ (string) $id ] ?? '' ) : $detected;
198 + $selected = array_key_exists( $key, $draftForms );
199 + $field = $selected ? $draftForms[ $key ] : $detected;
109 200 } else {
110 201 $selected = ! $enabled && $detected !== '';
111 202 $field = $detected;
112 203 }
113 204
114 - $forms[] = array(
115 - 'id' => $id,
116 - 'title' => (string) $form['title'],
117 - 'enabled' => $enabled,
118 - 'fields' => $fields,
119 - 'detectedField' => $detected,
120 - 'field' => $field,
121 - 'selected' => ! $enabled && $selected,
205 + $fieldList = array();
206 + foreach ( $fields as $name => $label ) {
207 + $fieldList[] = array(
208 + 'key' => (string) $name,
209 + 'label' => $label,
210 + );
211 + }
212 +
213 + $integrations[ $form['integration'] ] = $form['integrationName'];
214 + $forms[] = array(
215 + 'key' => $key,
216 + 'id' => $form['id'],
217 + 'integration' => $form['integration'],
218 + 'integrationName' => $form['integrationName'],
219 + 'title' => $form['title'],
220 + 'enabled' => $enabled,
221 + 'fields' => $fieldList,
222 + 'detectedField' => $detected,
223 + 'field' => $field,
224 + 'selected' => ! $enabled && $selected,
122 225 );
123 226 }
124 227
125 228 $plugins = array();
@@ -130,35 +233,37 @@
130 233
131 234 $pageId = (int) ( $draft['pageId'] ?? 0 );
132 235
133 236 return array(
134 - 'status' => $state['status'],
135 - 'step' => $state['step'],
136 - 'steps' => SetupState::STEPS,
137 - 'sender' => array(
237 + 'status' => $state['status'],
238 + 'step' => $state['step'],
239 + 'steps' => SetupState::STEPS,
240 + 'sender' => array(
138 241 'email' => (string) ( $draft['sender'] ?? ( $defaults['sender'] !== '' ? $defaults['sender'] : get_bloginfo( 'admin_email' ) ) ),
139 242 'name' => (string) ( $draft['sender_name'] ?? ( $defaults['sender_name'] !== '' ? $defaults['sender_name'] : wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ) ),
140 243 'domain' => self::siteDomain(),
141 244 ),
142 - 'cf7' => array(
245 + 'cf7' => array(
143 246 'installed' => defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7_ContactForm' ),
144 247 'newFormUrl' => admin_url( 'admin.php?page=wpcf7-new' ),
145 248 'installUrl' => admin_url( 'plugin-install.php?s=contact+form+7&tab=search&type=term' ),
146 - 'forms' => $forms,
147 249 ),
148 - 'mail' => array(
250 + // Every available integration, not only Contact Form 7 (5.9.0).
251 + 'forms' => $forms,
252 + 'integrations' => $integrations,
253 + 'mail' => array(
149 254 'subject' => (string) ( $draft['subject'] ?? $this->composer->subject() ),
150 255 'design' => SetupMailComposer::isDesign( (string) ( $draft['design'] ?? '' ) ) ? (string) $draft['design'] : SetupMailComposer::DEFAULT_DESIGN,
151 256 'designs' => SetupMailComposer::DESIGNS,
152 257 ),
153 - 'page' => array(
258 + 'page' => array(
154 259 'mode' => $pageId > 0 && empty( $draft['pageCreated'] ) ? 'existing' : 'new',
155 260 'pageId' => $this->isPublishedPage( $pageId ) ? $pageId : 0,
156 261 'pages' => array_values( (array) call_user_func( $this->pages ) ),
157 262 ),
158 - 'testMailTo' => self::currentUserEmail(),
159 - 'formPlugins' => $plugins,
160 - 'editorUrl' => self::productUrl( 'wizard-editor' ),
263 + 'testMailTo' => self::currentUserEmail(),
264 + 'formPlugins' => $plugins,
265 + 'editorUrl' => self::productUrl( 'wizard-editor' ),
161 266 );
162 267 }
163 268
164 269 /**
@@ -204,17 +309,17 @@
204 309 return self::ok();
205 310 }
206 311
207 312 /**
208 - * @param array<string, mixed> $data `forms`: list of {id, field}.
313 + * @param array<string, mixed> $data `forms`: list of {key, field}; a bare `id` means a CF7 form.
209 314 */
210 315 private function saveForms( array $data ): array {
211 - $known = array();
212 - foreach ( (array) call_user_func( $this->cf7Forms ) as $form ) {
213 - if ( empty( $form['enabled'] ) ) {
214 - $known[ (int) $form['id'] ] = true;
316 + $known = array_filter(
317 + $this->forms(),
318 + static function ( array $form ): bool {
319 + return ! $form['enabled'];
215 320 }
216 - }
321 + );
217 322
218 323 $chosen = array();
219 324 foreach ( (array) ( $data['forms'] ?? array() ) as $entry ) {
220 325 if ( ! is_array( $entry ) ) {
@@ -219,22 +324,21 @@
219 324 foreach ( (array) ( $data['forms'] ?? array() ) as $entry ) {
220 325 if ( ! is_array( $entry ) ) {
221 326 continue;
222 327 }
223 - $id = (int) ( $entry['id'] ?? 0 );
328 + $key = isset( $entry['key'] ) ? sanitize_text_field( (string) $entry['key'] ) : self::formKey( 'cf7', (int) ( $entry['id'] ?? 0 ) );
224 329 $field = sanitize_text_field( (string) ( $entry['field'] ?? '' ) );
225 330
226 - if ( ! isset( $known[ $id ] ) ) {
331 + if ( ! isset( $known[ $key ] ) ) {
227 332 continue;
228 333 }
229 - $fields = array_map( 'strval', (array) call_user_func( $this->cf7Fields, $id ) );
230 - if ( ! in_array( $field, $fields, true ) ) {
334 + if ( ! array_key_exists( $field, $this->fieldsOf( $known[ $key ] ) ) ) {
231 335 return self::fail(
232 336 'forms',
233 337 __( 'Please choose the email field for every selected form.', 'double-opt-in' )
234 338 );
235 339 }
236 - $chosen[ $id ] = $field;
340 + $chosen[ $key ] = $field;
237 341 }
238 342
239 343 $this->state->saveStep( self::STEPS[ self::STEP_FORMS ], array( 'forms' => $chosen ) );
240 344 return self::ok();
@@ -399,9 +503,9 @@
399 503 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
400 504 */
401 505 public function finish(): array {
402 506 $draft = $this->state->draft();
403 - $forms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? $draft['forms'] : array();
507 + $forms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? self::normalizeDraftForms( $draft['forms'] ) : array();
404 508 $design = (string) ( $draft['design'] ?? SetupMailComposer::DEFAULT_DESIGN );
405 509 $subject = (string) ( $draft['subject'] ?? $this->composer->subject() );
406 510 $pageId = (int) ( $draft['pageId'] ?? 0 );
407 511 $sender = FormDefaults::get();
@@ -406,22 +510,23 @@
406 510 $pageId = (int) ( $draft['pageId'] ?? 0 );
407 511 $sender = FormDefaults::get();
408 512 $body = $this->composer->body( $design );
409 513
410 - $stillFree = array();
411 - foreach ( (array) call_user_func( $this->cf7Forms ) as $form ) {
412 - if ( empty( $form['enabled'] ) ) {
413 - $stillFree[ (int) $form['id'] ] = true;
414 - }
415 - }
514 + $known = $this->forms();
416 515
417 516 $enabled = array();
418 517 $incomplete = array();
419 - foreach ( $forms as $formId => $field ) {
420 - $formId = (int) $formId;
421 - if ( ! isset( $stillFree[ $formId ] ) ) {
518 + $stored = array();
519 + foreach ( $forms as $key => $field ) {
520 + if ( ! isset( $known[ $key ] ) || $known[ $key ]['enabled'] ) {
422 521 continue;
423 522 }
523 + $formId = self::storageId( $known[ $key ]['id'] );
524 + // Elementor forms on one page share their settings.
525 + if ( $formId <= 0 || isset( $stored[ $formId ] ) ) {
526 + continue;
527 + }
528 + $stored[ $formId ] = true;
424 529
425 530 $dto = $this->settings->getSettings( $formId );
426 531 $dto->recipient = '[' . (string) $field . ']';
427 532 $dto->subject = $subject;
@@ -439,9 +544,9 @@
439 544
440 545 $missing = $dto->getMissingRequiredFields();
441 546 if ( $missing !== array() ) {
442 547 $incomplete[] = array(
443 - 'id' => $formId,
548 + 'id' => $key,
444 549 'missing' => $missing,
445 550 );
446 551 continue;
447 552 }
@@ -447,9 +552,9 @@
447 552 }
448 553
449 554 $dto->enabled = true;
450 555 $this->settings->saveSettings( $formId, $dto );
451 - $enabled[] = $formId;
556 + $enabled[] = $key;
452 557 }
453 558
454 559 $this->state->complete();
455 560
@@ -473,8 +578,27 @@
473 578 $mapping = PlaceholderMapper::autoDetectMapping( $fields );
474 579 return isset( $mapping['doi_email'] ) ? (string) $mapping['doi_email'] : '';
475 580 }
476 581
582 + /**
583 + * Same, for key => label fields. WPForms, Gravity Forms and Elementor
584 + * key their fields by number or random ID, so the label decides there.
585 + *
586 + * @param array<string, string> $fields
587 + */
588 + public static function detectEmailFieldIn( array $fields ): string {
589 + $byKey = self::detectEmailField( array_map( 'strval', array_keys( $fields ) ) );
590 + if ( $byKey !== '' ) {
591 + return $byKey;
592 + }
593 + $byLabel = self::detectEmailField( array_values( $fields ) );
594 + if ( $byLabel === '' ) {
595 + return '';
596 + }
597 + $key = array_search( $byLabel, $fields, true );
598 + return $key === false ? '' : (string) $key;
599 + }
600 +
477 601 private function isPublishedPage( int $pageId ): bool {
478 602 if ( $pageId <= 0 ) {
479 603 return false;
480 604 }
@@ -486,35 +610,53 @@
486 610 return false;
487 611 }
488 612
489 613 /**
490 - * @return array<int, array{id: int, title: string, enabled: bool}>
614 + * Forms of every available integration (Contact Form 7 and the form
615 + * add-ons that registered one).
616 + *
617 + * @return array<int, array{id: int|string, title: string, enabled: bool, integration: string, integrationName: string}>
491 618 */
492 - public static function listCf7Forms(): array {
493 - $integration = self::cf7Integration();
494 - if ( $integration === null ) {
619 + public static function listForms(): array {
620 + if ( ! class_exists( FormIntegrationRegistry::class ) ) {
495 621 return array();
496 622 }
497 623 $forms = array();
498 - foreach ( $integration->getForms() as $form ) {
499 - $forms[] = array(
500 - 'id' => (int) $form['id'],
501 - 'title' => (string) $form['title'],
502 - 'enabled' => ! empty( $form['enabled'] ),
503 - );
624 + foreach ( FormIntegrationRegistry::getInstance()->getAvailable() as $identifier => $integration ) {
625 + foreach ( $integration->getForms() as $form ) {
626 + if ( ! is_array( $form ) || ! isset( $form['id'] ) ) {
627 + continue;
628 + }
629 + $forms[] = array(
630 + 'id' => is_int( $form['id'] ) ? $form['id'] : (string) $form['id'],
631 + 'title' => (string) ( $form['title'] ?? '' ),
632 + 'enabled' => ! empty( $form['enabled'] ),
633 + 'integration' => (string) $identifier,
634 + 'integrationName' => $integration->getName(),
635 + );
636 + }
504 637 }
505 638 return $forms;
506 639 }
507 640
508 641 /**
509 - * @return string[]
642 + * @param int|string $formId
643 + *
644 + * @return array<string, string> key => label
510 645 */
511 - public static function listCf7Fields( int $formId ): array {
512 - $integration = self::cf7Integration();
513 - if ( $integration === null ) {
646 + public static function listFields( $formId, string $integration = 'cf7' ): array {
647 + if ( ! class_exists( FormIntegrationRegistry::class ) ) {
514 648 return array();
515 649 }
516 - return array_values( array_map( 'strval', array_keys( $integration->getFormFields( $formId ) ) ) );
650 + $instance = FormIntegrationRegistry::getInstance()->get( $integration );
651 + if ( $instance === null || ! $instance->isAvailable() ) {
652 + return array();
653 + }
654 + $fields = array();
655 + foreach ( $instance->getFormFields( $formId ) as $key => $label ) {
656 + $fields[ (string) $key ] = is_scalar( $label ) ? (string) $label : (string) $key;
657 + }
658 + return $fields;
517 659 }
518 660
519 661 /**
520 662 * @return array<int, array{id: int, title: string}>
@@ -527,16 +669,8 @@
527 669 'title' => (string) $page->post_title,
528 670 );
529 671 }
530 672 return $pages;
531 - }
532 -
533 - private static function cf7Integration(): ?\Forge12\DoubleOptIn\Integration\FormIntegrationInterface {
534 - if ( ! class_exists( FormIntegrationRegistry::class ) ) {
535 - return null;
536 - }
537 - $integration = FormIntegrationRegistry::getInstance()->get( 'cf7' );
538 - return ( $integration !== null && $integration->isAvailable() ) ? $integration : null;
539 673 }
540 674
541 675 private static function currentUserEmail(): string {
542 676 $user = get_userdata( get_current_user_id() );