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
double-opt-in / src / Setup / SetupService.php

SetupService.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.9.0, at src/Setup/SetupService.php

716 lines 21.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * What the setup wizard reads and writes.
4 *
5 * Four steps (sender, forms, mail, page), each saved as it is confirmed, and a
6 * finish that turns the collected values into form settings. Forms that are
7 * already configured are left alone: the wizard only switches on forms that
8 * have no Double Opt-In yet.
9 *
10 * @package Forge12\DoubleOptIn\Setup
11 * @since 5.7.0
12 */
13
14 declare( strict_types=1 );
15
16 namespace Forge12\DoubleOptIn\Setup;
17
18 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
19 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
20 use Forge12\DoubleOptIn\Integration\FormIntegrationRegistry;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 class SetupService {
27
28 public const STEP_SENDER = 'sender';
29 public const STEP_FORMS = 'forms';
30 public const STEP_MAIL = 'mail';
31 public const STEP_PAGE = 'page';
32
33 /** Step name => zero-based position. */
34 public const STEPS = array(
35 self::STEP_SENDER => 0,
36 self::STEP_FORMS => 1,
37 self::STEP_MAIL => 2,
38 self::STEP_PAGE => 3,
39 );
40
41 public const TEST_MAIL_LIMIT = 5;
42 public const TEST_MAIL_WINDOW = 3600;
43
44 /** @var SetupState */
45 private $state;
46
47 /** @var FormSettingsService */
48 private $settings;
49
50 /** @var FormPluginDetector */
51 private $detector;
52
53 /** @var SetupMailComposer */
54 private $composer;
55
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;
63
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;
70
71 /** @var callable():array<int, array{id:int, title:string}> */
72 private $pages;
73
74 /** @var array<string, array<string, string>> Fields per form key, per request. */
75 private $fieldCache = array();
76
77 /**
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).
81 */
82 public function __construct(
83 SetupState $state,
84 FormSettingsService $settings,
85 FormPluginDetector $detector,
86 SetupMailComposer $composer,
87 ?callable $forms = null,
88 ?callable $fields = null,
89 ?callable $pages = null
90 ) {
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' );
98 }
99
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 /**
180 * Everything the wizard needs to render, with prefilled values.
181 *
182 * @return array<string, mixed>
183 */
184 public function overview(): array {
185 $state = $this->state->get();
186 $draft = $state['draft'];
187 $defaults = FormDefaults::get();
188
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'];
196
197 if ( $draftForms !== null ) {
198 $selected = array_key_exists( $key, $draftForms );
199 $field = $selected ? $draftForms[ $key ] : $detected;
200 } else {
201 $selected = ! $enabled && $detected !== '';
202 $field = $detected;
203 }
204
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,
225 );
226 }
227
228 $plugins = array();
229 foreach ( $this->detector->withoutAddon() as $plugin ) {
230 $plugin['productUrl'] = self::productUrl( 'wizard-detected-' . $plugin['id'] );
231 $plugins[] = $plugin;
232 }
233
234 $pageId = (int) ( $draft['pageId'] ?? 0 );
235
236 return array(
237 'status' => $state['status'],
238 'step' => $state['step'],
239 'steps' => SetupState::STEPS,
240 'sender' => array(
241 'email' => (string) ( $draft['sender'] ?? ( $defaults['sender'] !== '' ? $defaults['sender'] : get_bloginfo( 'admin_email' ) ) ),
242 'name' => (string) ( $draft['sender_name'] ?? ( $defaults['sender_name'] !== '' ? $defaults['sender_name'] : wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ) ),
243 'domain' => self::siteDomain(),
244 ),
245 'cf7' => array(
246 'installed' => defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7_ContactForm' ),
247 'newFormUrl' => admin_url( 'admin.php?page=wpcf7-new' ),
248 'installUrl' => admin_url( 'plugin-install.php?s=contact+form+7&tab=search&type=term' ),
249 ),
250 // Every available integration, not only Contact Form 7 (5.9.0).
251 'forms' => $forms,
252 'integrations' => $integrations,
253 'mail' => array(
254 'subject' => (string) ( $draft['subject'] ?? $this->composer->subject() ),
255 'design' => SetupMailComposer::isDesign( (string) ( $draft['design'] ?? '' ) ) ? (string) $draft['design'] : SetupMailComposer::DEFAULT_DESIGN,
256 'designs' => SetupMailComposer::DESIGNS,
257 ),
258 'page' => array(
259 'mode' => $pageId > 0 && empty( $draft['pageCreated'] ) ? 'existing' : 'new',
260 'pageId' => $this->isPublishedPage( $pageId ) ? $pageId : 0,
261 'pages' => array_values( (array) call_user_func( $this->pages ) ),
262 ),
263 'testMailTo' => self::currentUserEmail(),
264 'formPlugins' => $plugins,
265 'editorUrl' => self::productUrl( 'wizard-editor' ),
266 );
267 }
268
269 /**
270 * Save one step.
271 *
272 * @param array<string, mixed> $data
273 *
274 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
275 */
276 public function saveStep( string $step, array $data ): array {
277 switch ( $step ) {
278 case self::STEP_SENDER:
279 return $this->saveSender( $data );
280 case self::STEP_FORMS:
281 return $this->saveForms( $data );
282 case self::STEP_MAIL:
283 return $this->saveMail( $data );
284 case self::STEP_PAGE:
285 return $this->savePage( $data );
286 }
287 return self::fail( 'step', __( 'Unknown setup step.', 'double-opt-in' ) );
288 }
289
290 /**
291 * @param array<string, mixed> $data
292 */
293 private function saveSender( array $data ): array {
294 $email = sanitize_email( (string) ( $data['email'] ?? '' ) );
295 $name = sanitize_text_field( (string) ( $data['name'] ?? '' ) );
296
297 if ( ! is_email( $email ) ) {
298 return self::fail( 'email', __( 'Please enter a valid email address.', 'double-opt-in' ) );
299 }
300
301 FormDefaults::save( $email, $name );
302 $this->state->saveStep(
303 self::STEPS[ self::STEP_SENDER ],
304 array(
305 'sender' => $email,
306 'sender_name' => $name,
307 )
308 );
309 return self::ok();
310 }
311
312 /**
313 * @param array<string, mixed> $data `forms`: list of {key, field}; a bare `id` means a CF7 form.
314 */
315 private function saveForms( array $data ): array {
316 $known = array_filter(
317 $this->forms(),
318 static function ( array $form ): bool {
319 return ! $form['enabled'];
320 }
321 );
322
323 $chosen = array();
324 foreach ( (array) ( $data['forms'] ?? array() ) as $entry ) {
325 if ( ! is_array( $entry ) ) {
326 continue;
327 }
328 $key = isset( $entry['key'] ) ? sanitize_text_field( (string) $entry['key'] ) : self::formKey( 'cf7', (int) ( $entry['id'] ?? 0 ) );
329 $field = sanitize_text_field( (string) ( $entry['field'] ?? '' ) );
330
331 if ( ! isset( $known[ $key ] ) ) {
332 continue;
333 }
334 if ( ! array_key_exists( $field, $this->fieldsOf( $known[ $key ] ) ) ) {
335 return self::fail(
336 'forms',
337 __( 'Please choose the email field for every selected form.', 'double-opt-in' )
338 );
339 }
340 $chosen[ $key ] = $field;
341 }
342
343 $this->state->saveStep( self::STEPS[ self::STEP_FORMS ], array( 'forms' => $chosen ) );
344 return self::ok();
345 }
346
347 /**
348 * @param array<string, mixed> $data
349 */
350 private function saveMail( array $data ): array {
351 $subject = sanitize_text_field( (string) ( $data['subject'] ?? '' ) );
352 $design = (string) ( $data['design'] ?? '' );
353
354 if ( trim( $subject ) === '' ) {
355 return self::fail( 'subject', __( 'Please enter a subject.', 'double-opt-in' ) );
356 }
357 if ( ! SetupMailComposer::isDesign( $design ) ) {
358 $design = SetupMailComposer::DEFAULT_DESIGN;
359 }
360
361 $this->state->saveStep(
362 self::STEPS[ self::STEP_MAIL ],
363 array(
364 'subject' => $subject,
365 'design' => $design,
366 )
367 );
368 return self::ok();
369 }
370
371 /**
372 * @param array<string, mixed> $data `mode`: new|existing, `pageId` for existing.
373 */
374 private function savePage( array $data ): array {
375 $mode = (string) ( $data['mode'] ?? 'new' );
376
377 if ( $mode === 'existing' ) {
378 $pageId = (int) ( $data['pageId'] ?? 0 );
379 if ( ! $this->isPublishedPage( $pageId ) ) {
380 return self::fail( 'pageId', __( 'Please choose a published page.', 'double-opt-in' ) );
381 }
382 $this->state->saveStep(
383 self::STEPS[ self::STEP_PAGE ],
384 array(
385 'pageId' => $pageId,
386 'pageCreated' => false,
387 )
388 );
389 return self::ok( array( 'pageId' => $pageId ) );
390 }
391
392 $pageId = $this->ensureConfirmationPage();
393 if ( $pageId <= 0 ) {
394 return self::fail( 'page', __( 'The page could not be created. Please choose an existing page instead.', 'double-opt-in' ) );
395 }
396 $this->state->saveStep(
397 self::STEPS[ self::STEP_PAGE ],
398 array(
399 'pageId' => $pageId,
400 'pageCreated' => true,
401 )
402 );
403 return self::ok( array( 'pageId' => $pageId ) );
404 }
405
406 /**
407 * Create the confirmation page once, published. Reuses the page from an
408 * earlier run while it is still published.
409 */
410 private function ensureConfirmationPage(): int {
411 $draft = $this->state->draft();
412 $existing = (int) ( $draft['pageId'] ?? 0 );
413 if ( ! empty( $draft['pageCreated'] ) && $this->isPublishedPage( $existing ) ) {
414 return $existing;
415 }
416
417 if ( ! current_user_can( 'publish_pages' ) ) {
418 return 0;
419 }
420
421 // The shortcode tells a confirmed link from an expired or invalid one
422 // (5.8.0); a fixed "confirmed" text said so for every click.
423 $content = '<!-- wp:shortcode -->[doi_confirmation_status]<!-- /wp:shortcode -->';
424
425 $id = wp_insert_post(
426 array(
427 'post_type' => 'page',
428 'post_status' => 'publish',
429 'post_title' => __( 'Email address confirmed', 'double-opt-in' ),
430 'post_content' => $content,
431 'comment_status' => 'closed',
432 ),
433 true
434 );
435
436 return is_int( $id ) ? $id : 0;
437 }
438
439 /**
440 * Send the configured mail to the current user, with sample values.
441 *
442 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
443 */
444 public function sendTestMail(): array {
445 $to = self::currentUserEmail();
446 if ( ! is_email( $to ) ) {
447 return self::fail( 'testMail', __( 'Your user account has no valid email address.', 'double-opt-in' ) );
448 }
449
450 $key = 'f12_doi_setup_test_mail_' . get_current_user_id();
451 $count = (int) get_transient( $key );
452 if ( $count >= self::TEST_MAIL_LIMIT ) {
453 return self::fail( 'testMail', __( 'You have sent several test emails in a short time. Please try again in an hour.', 'double-opt-in' ) );
454 }
455 set_transient( $key, $count + 1, self::TEST_MAIL_WINDOW );
456
457 $overview = $this->overview();
458 $draft = $this->state->draft();
459 $pageId = (int) ( $draft['pageId'] ?? 0 );
460 // Points at the confirmation page without an opt-in hash: the admin
461 // sees where visitors land, and nothing gets confirmed.
462 $link = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
463
464 $body = strtr(
465 $this->composer->body( (string) $overview['mail']['design'] ),
466 array(
467 '[doubleoptinlink]' => esc_url( $link ),
468 '[doubleoptin_form_url]' => esc_url( home_url( '/' ) ),
469 '[doubleoptin_form_date]' => (string) wp_date( (string) get_option( 'date_format', 'Y-m-d' ) ),
470 '[doubleoptin_form_time]' => (string) wp_date( (string) get_option( 'time_format', 'H:i' ) ),
471 '[doubleoptin_form_email]' => $to,
472 )
473 );
474
475 $headers = array( 'Content-Type: text/html; charset=UTF-8' );
476 $sender = (string) $overview['sender']['email'];
477 $name = (string) $overview['sender']['name'];
478 if ( is_email( $sender ) ) {
479 $headers[] = 'From: ' . ( $name !== '' ? self::headerName( $name ) . ' <' . $sender . '>' : $sender );
480 }
481
482 $sent = wp_mail(
483 $to,
484 sprintf(
485 /* translators: %s: subject of the confirmation email */
486 __( '[Test] %s', 'double-opt-in' ),
487 (string) $overview['mail']['subject']
488 ),
489 $body,
490 $headers
491 );
492
493 if ( $sent === false ) {
494 return self::fail( 'testMail', __( 'WordPress could not send the email. Please check the mail settings of your site, for example with an SMTP plugin.', 'double-opt-in' ) );
495 }
496
497 return self::ok( array( 'to' => $to ) );
498 }
499
500 /**
501 * Turn the collected values into form settings and switch the forms on.
502 *
503 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
504 */
505 public function finish(): array {
506 $draft = $this->state->draft();
507 $forms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? self::normalizeDraftForms( $draft['forms'] ) : array();
508 $design = (string) ( $draft['design'] ?? SetupMailComposer::DEFAULT_DESIGN );
509 $subject = (string) ( $draft['subject'] ?? $this->composer->subject() );
510 $pageId = (int) ( $draft['pageId'] ?? 0 );
511 $sender = FormDefaults::get();
512 $body = $this->composer->body( $design );
513
514 $known = $this->forms();
515
516 $enabled = array();
517 $incomplete = array();
518 $stored = array();
519 foreach ( $forms as $key => $field ) {
520 if ( ! isset( $known[ $key ] ) || $known[ $key ]['enabled'] ) {
521 continue;
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;
529
530 $dto = $this->settings->getSettings( $formId );
531 $dto->recipient = '[' . (string) $field . ']';
532 $dto->subject = $subject;
533 $dto->body = $body;
534 $dto->template = '';
535 if ( $sender['sender'] !== '' ) {
536 $dto->sender = $sender['sender'];
537 }
538 if ( $sender['sender_name'] !== '' ) {
539 $dto->senderName = $sender['sender_name'];
540 }
541 if ( $this->isPublishedPage( $pageId ) ) {
542 $dto->confirmationPage = $pageId;
543 }
544
545 $missing = $dto->getMissingRequiredFields();
546 if ( $missing !== array() ) {
547 $incomplete[] = array(
548 'id' => $key,
549 'missing' => $missing,
550 );
551 continue;
552 }
553
554 $dto->enabled = true;
555 $this->settings->saveSettings( $formId, $dto );
556 $enabled[] = $key;
557 }
558
559 $this->state->complete();
560
561 return self::ok(
562 array(
563 'enabled' => $enabled,
564 'incomplete' => $incomplete,
565 )
566 );
567 }
568
569 /**
570 * The field that most likely holds the visitor's address.
571 *
572 * @param string[] $fields
573 */
574 public static function detectEmailField( array $fields ): string {
575 if ( $fields === array() ) {
576 return '';
577 }
578 $mapping = PlaceholderMapper::autoDetectMapping( $fields );
579 return isset( $mapping['doi_email'] ) ? (string) $mapping['doi_email'] : '';
580 }
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
601 private function isPublishedPage( int $pageId ): bool {
602 if ( $pageId <= 0 ) {
603 return false;
604 }
605 foreach ( (array) call_user_func( $this->pages ) as $page ) {
606 if ( (int) $page['id'] === $pageId ) {
607 return true;
608 }
609 }
610 return false;
611 }
612
613 /**
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}>
618 */
619 public static function listForms(): array {
620 if ( ! class_exists( FormIntegrationRegistry::class ) ) {
621 return array();
622 }
623 $forms = array();
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 }
637 }
638 return $forms;
639 }
640
641 /**
642 * @param int|string $formId
643 *
644 * @return array<string, string> key => label
645 */
646 public static function listFields( $formId, string $integration = 'cf7' ): array {
647 if ( ! class_exists( FormIntegrationRegistry::class ) ) {
648 return array();
649 }
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;
659 }
660
661 /**
662 * @return array<int, array{id: int, title: string}>
663 */
664 public static function listPublishedPages(): array {
665 $pages = array();
666 foreach ( get_pages( array( 'post_status' => 'publish' ) ) as $page ) {
667 $pages[] = array(
668 'id' => (int) $page->ID,
669 'title' => (string) $page->post_title,
670 );
671 }
672 return $pages;
673 }
674
675 private static function currentUserEmail(): string {
676 $user = get_userdata( get_current_user_id() );
677 return ( $user && isset( $user->user_email ) ) ? (string) $user->user_email : '';
678 }
679
680 private static function siteDomain(): string {
681 $host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST );
682 return strpos( $host, 'www.' ) === 0 ? substr( $host, 4 ) : $host;
683 }
684
685 private static function productUrl( string $from ): string {
686 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_product_url';
687 return function_exists( $fn ) ? (string) $fn( $from ) : '';
688 }
689
690 /**
691 * A display name safe for a mail header.
692 */
693 private static function headerName( string $name ): string {
694 $name = str_replace( array( "\r", "\n", '"' ), '', $name );
695 return '"' . $name . '"';
696 }
697
698 /**
699 * @param array<string, mixed> $data
700 */
701 private static function ok( array $data = array() ): array {
702 return array(
703 'ok' => true,
704 'errors' => array(),
705 'data' => $data,
706 );
707 }
708
709 private static function fail( string $field, string $message ): array {
710 return array(
711 'ok' => false,
712 'errors' => array( $field => $message ),
713 );
714 }
715 }
716