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 / Integration / CF7Integration.php

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

1,149 lines 34.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Contact Form 7 Integration
4 *
5 * @package Forge12\DoubleOptIn\Integration
6 * @since 4.0.0
7 */
8
9 namespace Forge12\DoubleOptIn\Integration;
10
11 use Forge12\DoubleOptIn\Container\Container;
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;
19 use forge12\contactform7\CF7DoubleOptIn\Category;
20 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
21 use forge12\contactform7\CF7DoubleOptIn\HTMLSelect;
22 use forge12\contactform7\CF7DoubleOptIn\OptIn;
23 use forge12\contactform7\CF7DoubleOptIn\SanitizeHelper;
24 use Forge12\Shared\LoggerInterface;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Class CF7Integration
32 *
33 * Integration for Contact Form 7.
34 * Handles opt-in creation, confirmation mail sending, and admin panel.
35 */
36 class CF7Integration extends AbstractFormIntegration implements AdminPanelInterface, FieldTextProviderInterface {
37
38 /**
39 * Current OptIn for mail attachment handling.
40 *
41 * @var OptIn|null
42 */
43 private ?OptIn $currentOptIn = null;
44
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 /**
61 * {@inheritdoc}
62 */
63 public function getIdentifier(): string {
64 return 'cf7';
65 }
66
67 /**
68 * {@inheritdoc}
69 */
70 public function getName(): string {
71 return __( 'Contact Form 7', 'double-opt-in' );
72 }
73
74 /**
75 * {@inheritdoc}
76 */
77 public function isAvailable(): bool {
78 return function_exists( 'wpcf7' ) || class_exists( '\WPCF7_ContactForm' );
79 }
80
81 /**
82 * {@inheritdoc}
83 */
84 protected function getPostType(): string {
85 return 'wpcf7_contact_form';
86 }
87
88 /**
89 * {@inheritdoc}
90 */
91 public function getFormEditUrl( $formId ): string {
92 return admin_url( 'admin.php?page=wpcf7&post=' . (int) $formId . '&action=edit' );
93 }
94
95 /**
96 * {@inheritdoc}
97 */
98 public function registerHooks(): void {
99 // Frontend hooks
100 add_action( 'wpcf7_before_send_mail', array( $this, 'onSubmit' ), $this->getHookPriority(), 3 );
101 add_action( 'init', array( $this, 'handleOptInConfirmation' ) );
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
111 // Register recipient filter
112 add_filter( 'f12_cf7_doubleoptin_get_recipient_cf7', array( $this, 'getRecipientFilter' ), 10, 3 );
113
114 // Confirmation mail hooks
115 add_action( 'f12_cf7_doubleoptin_before_send_default_mail', array( $this, 'beforeSendDefaultMail' ) );
116 add_action( 'f12_cf7_doubleoptin_after_send_default_mail', array( $this, 'afterSendDefaultMail' ) );
117 add_action( 'f12_cf7_doubleoptin_trigger_default_mail', array( $this, 'onTriggerDefaultMail' ) );
118
119 // File hand-off + pending-cleanup. CF7 attaches files to the
120 // confirmation mail in attachExtraAttachments (hooked on
121 // wpcf7_before_send_mail during sendConfirmationMail). Cleanup
122 // MUST run AFTER the mail is sent — otherwise we'd be deleting
123 // files before they're attached. Hence `after_send_default_mail`,
124 // not `after_confirm` like the other integrations. Priority 20
125 // (default 10) so any third-party listener that introspects the
126 // attachments still sees them.
127 // File-lifecycle plan, Schritt 2c (2026-05-08).
128 add_action( 'f12_cf7_doubleoptin_after_send_default_mail', array( $this, 'cleanupPendingAfterMail' ), 20, 1 );
129
130 // Admin hooks
131 $this->registerAdminHooks();
132
133 $this->getLogger()->debug(
134 'CF7 integration hooks registered',
135 array(
136 'plugin' => 'double-opt-in',
137 )
138 );
139 }
140
141 /**
142 * {@inheritdoc}
143 */
144 public function registerAdminHooks(): void {
145 add_action( 'admin_init', array( $this, 'setupAdminPanel' ) );
146 add_action( 'admin_enqueue_scripts', array( $this, 'enqueueAdminAssets' ) );
147 }
148
149 /**
150 * Setup admin panel hooks.
151 *
152 * @return void
153 */
154 public function setupAdminPanel(): void {
155 add_filter( 'wpcf7_editor_panels', array( $this, 'addEditorPanel' ), 10, 1 );
156 add_action( 'wpcf7_save_contact_form', array( $this, 'saveFormSettings' ), 10, 3 );
157 }
158
159 /**
160 * {@inheritdoc}
161 */
162 public function enqueueAdminAssets( string $hook ): void {
163 wp_enqueue_script(
164 'f12-cf7-doubleoptin-admin',
165 plugins_url( 'compatibility/cf7/assets/f12-cf7-popup.js', F12_DOUBLEOPTIN_PLUGIN_FILE ),
166 array( 'jquery' )
167 );
168
169 wp_localize_script(
170 'f12-cf7-doubleoptin-admin',
171 'doi',
172 array(
173 'ajax_url' => admin_url( 'admin-ajax.php' ),
174 'nonce' => wp_create_nonce( 'f12_doi_details' ),
175 )
176 );
177
178 wp_enqueue_script(
179 'f12-cf7-doubleoptin-templateloader',
180 plugins_url( 'compatibility/cf7/assets/f12-cf7-templateloader.js', F12_DOUBLEOPTIN_PLUGIN_FILE ),
181 array( 'jquery' )
182 );
183
184 wp_localize_script(
185 'f12-cf7-doubleoptin-templateloader',
186 'templateloader',
187 array(
188 'ajax_url' => admin_url( 'admin-ajax.php' ),
189 'nonce' => wp_create_nonce( 'f12_doi_templateloader' ),
190 'label_placeholder' => __( 'Please wait while we load the template...', 'double-opt-in' ),
191 )
192 );
193 }
194
195 /**
196 * {@inheritdoc}
197 */
198 public function getHookPriority(): int {
199 return 5;
200 }
201
202 /**
203 * {@inheritdoc}
204 */
205 public function processSubmission( $context ): ?FormDataInterface {
206 if ( ! is_array( $context ) || ! isset( $context['form'] ) || ! isset( $context['submission'] ) ) {
207 return null;
208 }
209
210 $form = $context['form'];
211 $submission = $context['submission'];
212
213 return FormData::fromCF7( $form, $submission );
214 }
215
216 /**
217 * {@inheritdoc}
218 */
219 public function resolveRecipient( FormDataInterface $formData, array $formParameter ): string {
220 if ( ! isset( $formParameter['recipient'] ) ) {
221 return '';
222 }
223
224 $recipientField = str_replace( array( '[', ']' ), '', $formParameter['recipient'] );
225 $fields = $formData->getFields();
226
227 if ( isset( $fields[ $recipientField ] ) ) {
228 return sanitize_email( $fields[ $recipientField ] );
229 }
230
231 return '';
232 }
233
234 /**
235 * Recipient filter callback for legacy compatibility.
236 *
237 * @param string $recipient Current recipient.
238 * @param array $formParameter Form parameters.
239 * @param array $postParameter Post data.
240 *
241 * @return string The resolved recipient.
242 */
243 public function getRecipientFilter( string $recipient, array $formParameter, array $postParameter ): string {
244 if ( ! isset( $formParameter['recipient'] ) ) {
245 return $recipient;
246 }
247
248 $recipientField = str_replace( array( '[', ']' ), '', $formParameter['recipient'] );
249
250 if ( isset( $postParameter[ $recipientField ] ) ) {
251 return sanitize_email( $postParameter[ $recipientField ] );
252 }
253
254 return $recipient;
255 }
256
257 /**
258 * Handle form submission.
259 *
260 * @param \WPCF7_ContactForm $form The contact form.
261 * @param bool $abort Whether to abort submission.
262 * @param \WPCF7_Submission $submission The submission.
263 *
264 * @return void
265 */
266 public function onSubmit( $form, &$abort, $submission ): void {
267 $formId = $form->id();
268
269 $this->getLogger()->debug(
270 'CF7 form submission received',
271 array(
272 'plugin' => 'double-opt-in',
273 'form_id' => $formId,
274 )
275 );
276
277 if ( ! $this->isOptInEnabled( $formId ) ) {
278 // Our own post-confirmation replay: attach the stored files.
279 if ( self::isReplaying() ) {
280 $this->attachStoredFiles( $submission );
281 }
282 return;
283 }
284
285 // Remove CF7 DB integration
286 remove_action( 'wpcf7_before_send_mail', 'cfdb7_before_send_mail' );
287
288 // Create form data
289 $formData = FormData::fromCF7( $form, $submission );
290 $formParameter = $this->getFormParameter( $formId );
291
292 // Check skip filter
293 if ( apply_filters( 'f12_cf7_doubleoptin_skip_option', false, $formId, $formData->getFields(), 'cf7' ) ) {
294 $this->getLogger()->info(
295 'OptIn skipped by filter',
296 array(
297 'plugin' => 'double-opt-in',
298 'form_id' => $formId,
299 )
300 );
301 return;
302 }
303
304 // Set recipient
305 $recipient = $this->resolveRecipient( $formData, $formParameter );
306 $formData = $formData->withRecipientEmail( $recipient );
307
308 // Create OptIn
309 $optIn = $this->createOptIn( $formData, $formParameter );
310
311 if ( ! $optIn ) {
312 // Always prevent the original CF7 mail from being sent when opt-in creation fails
313 add_filter( 'wpcf7_skip_mail', '__return_true' );
314
315 $error = self::getLastError();
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 ) ) {
320 $message = apply_filters( 'f12_cf7_doubleoptin_error_message', $error->getMessage(), $error, $formId );
321 if ( method_exists( $submission, 'set_response' ) ) {
322 $submission->set_response( $message );
323 }
324 $abort = true;
325 // The form shows it now — no second copy in the toast.
326 ErrorNotification::forget();
327 }
328 return;
329 }
330
331 // Send opt-in mail
332 $this->lastOptInMail = null;
333 $this->sendOptInMail( $optIn, $formData, $formParameter );
334 if ( $this->lastOptInMail !== null ) {
335 $this->submitted = array( 'form_id' => (int) $formId ) + $this->lastOptInMail;
336 }
337
338 // Skip original mail
339 add_filter( 'wpcf7_skip_mail', '__return_true' );
340 do_action( 'f12_cf7_doubleoptin_sent', $form, $formId );
341 }
342
343 /**
344 * {@inheritdoc}
345 */
346 public function sendOptInMail( OptIn $optIn, FormDataInterface $formData, array $formParameter ): bool {
347 $formParameter['formUrl'] = $formData->getMetaValue( 'source_url', '' );
348
349 // Get template body
350 $body = apply_filters(
351 'f12_cf7_doubleoptin_template_body',
352 $formParameter['body'],
353 $formParameter['template'] ?? 'blank',
354 $formParameter,
355 $optIn
356 );
357
358 // Process placeholders
359 $body = $this->prepareMailBody( $body, $optIn, $formParameter );
360 $body = apply_filters( 'f12_cf7_doubleoptin_body', $body );
361
362 // Store mail content in OptIn
363 $optIn->set_mail_optin( $body );
364 $optIn->save();
365
366 // Prepare mail arguments
367 $args = apply_filters(
368 'f12-cf7-doubleoptin-cf7-args',
369 array(
370 'subject' => $formParameter['subject'] ?? '',
371 'body' => $body,
372 'sender' => $formParameter['sender'] ?? '',
373 'sender_name' => $formParameter['sender_name'] ?? '',
374 'recipient' => $optIn->get_email(),
375 'use_html' => true,
376 'additional_headers' => '',
377 )
378 );
379
380 if ( ! empty( $args['sender_name'] ) ) {
381 $args['additional_headers'] .= 'From: ' . $args['sender_name'] . ' <' . $args['sender'] . '>';
382 }
383
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' );
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
408 $this->getLogger()->info(
409 $sent ? 'OptIn mail handed to the mail server via CF7' : 'OptIn mail could not be sent via CF7',
410 array(
411 'plugin' => 'double-opt-in',
412 'form_id' => $formData->getFormId(),
413 'optin_id' => (int) $optIn->get_id(),
414 )
415 );
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
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 );
633 }
634
635 /**
636 * {@inheritdoc}
637 */
638 public function sendConfirmationMail( OptIn $optIn ): void {
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' ) ) {
682 $this->getLogger()->warning(
683 'CF7 not available for confirmation mail',
684 array(
685 'plugin' => 'double-opt-in',
686 )
687 );
688 return FollowUpResult::failedRetryable( 'integration_unavailable' );
689 }
690
691 $contactForm = \WPCF7_ContactForm::get_instance( $optIn->get_cf_form_id() );
692 if ( ! $contactForm ) {
693 $this->getLogger()->warning(
694 'CF7 form not found for confirmation mail',
695 array(
696 'plugin' => 'double-opt-in',
697 'form_id' => $optIn->get_cf_form_id(),
698 )
699 );
700 return FollowUpResult::failedPermanent( 'form_missing' );
701 }
702
703 $data = maybe_unserialize( $optIn->get_content() );
704 if ( ! is_array( $data ) ) {
705 return FollowUpResult::failedPermanent( 'payload_missing' );
706 }
707
708 $previousPost = $_POST;
709 $previousOptIn = $this->currentOptIn;
710 $this->currentOptIn = $optIn;
711 $status = '';
712
713 try {
714 $_POST = SanitizeHelper::sanitize_array( $data );
715
716 // Disable validation and spam checks before creating submission
717 $this->beforeSendConfirmationMail();
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
733 $this->getLogger()->info(
734 'Confirmation mail triggered via CF7',
735 array(
736 'plugin' => 'double-opt-in',
737 'form_id' => $optIn->get_cf_form_id(),
738 'optin_id' => $optIn->get_id(),
739 'cf7_status' => $status,
740 )
741 );
742
743 return CF7FollowUpAdapter::mapStatus( $status );
744 }
745
746 /**
747 * Handle opt-in confirmation from URL.
748 *
749 * @return void
750 */
751 public function handleOptInConfirmation(): void {
752 if ( ! isset( $_GET['optin'] ) ) {
753 return;
754 }
755
756 $hash = sanitize_text_field( $_GET['optin'] );
757 $this->validateOptIn( $hash );
758 }
759
760 /**
761 * Before sending default mail callback.
762 *
763 * @return void
764 */
765 public function beforeSendDefaultMail(): void {
766 $this->beforeSendConfirmationMail();
767 }
768
769 /**
770 * After sending default mail callback.
771 *
772 * @return void
773 */
774 public function afterSendDefaultMail(): void {
775 $this->afterSendConfirmationMail();
776 }
777
778 /**
779 * Attach extra attachments to the mail.
780 *
781 * @param \WPCF7_ContactForm $form The contact form.
782 * @param bool $abort Whether to abort.
783 * @param \WPCF7_Submission $submission The submission.
784 *
785 * @return void
786 */
787 public function attachExtraAttachments( $form, $abort, $submission ): void {
788 if ( $this->currentOptIn && $this->currentOptIn->get_files() ) {
789 $files = maybe_unserialize( $this->currentOptIn->get_files() );
790 if ( is_array( $files ) ) {
791 foreach ( $files as $file ) {
792 $submission->add_extra_attachments( $file );
793 }
794 }
795 }
796 }
797
798 /**
799 * Attach stored files to submission during confirmation.
800 *
801 * @param \WPCF7_Submission $submission The submission.
802 *
803 * @return void
804 */
805 private function attachStoredFiles( $submission ): void {
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;
809
810 if ( ! $optIn ) {
811 return;
812 }
813
814 $files = maybe_unserialize( $optIn->get_files() );
815 if ( empty( $files ) ) {
816 return;
817 }
818
819 foreach ( $files as $file ) {
820 if ( empty( $file ) ) {
821 continue;
822 }
823
824 if ( apply_filters( 'f12_cf7_doubleoptin_files_mail_1', true, $optIn ) ) {
825 $submission->add_extra_attachments( $file );
826 }
827
828 if ( apply_filters( 'f12_cf7_doubleoptin_files_mail_2', true, $optIn ) ) {
829 $submission->add_extra_attachments( $file, 'mail_2' );
830 }
831 }
832 }
833
834 /**
835 * File hand-off — for CF7, the "form system" is the confirmation
836 * mail itself. The hand-off completes when `attachExtraAttachments`
837 * has added the pending files as mail attachments and CF7 has sent
838 * the mail. By the time `cleanupPendingAfterMail` invokes the
839 * template-method (registered on `after_send_default_mail`), this
840 * has already happened and returning true triggers the pending/
841 * cleanup — single source of truth = the recipient's mailbox.
842 *
843 * Differs from WPForms/GF (where the integration's own DB owns the
844 * file URL) in WHEN the hand-off happens, not WHAT it returns. The
845 * hook timing in registerHooks() is the actual difference.
846 *
847 * {@inheritdoc}
848 */
849 public function handOffFilesToFormSystem( OptIn $optIn ): bool {
850 return true;
851 }
852
853 /**
854 * CF7-specific cleanup wrapper. Hooks the file-lifecycle template-
855 * method onto `f12_cf7_doubleoptin_after_send_default_mail` rather
856 * than `f12_cf7_doubleoptin_after_confirm` (the timing the other
857 * integrations use), so pending files survive long enough for
858 * `attachExtraAttachments` to attach them to the confirmation mail.
859 *
860 * Edge case: if `f12_cf7_doubleoptin_send_default_mail` filter is
861 * false (admin opted out of the CF7 confirmation mail), this hook
862 * never fires and pending/ stays populated until the OptIn-deletion
863 * cron sweeps it via cascade-delete. Acceptable per the plan's
864 * fault-tolerance pattern — no silent data loss, just delayed
865 * cleanup.
866 *
867 * @param OptIn $optIn The just-confirmed opt-in (action arg).
868 *
869 * @since 4.3.0
870 */
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
880 // Template-method's $hash arg is unused inside processFilesOnConfirm;
881 // passing an empty string keeps the contract tight.
882 $this->processFilesOnConfirm( '', $optIn );
883 }
884
885 /**
886 * {@inheritdoc}
887 */
888 public function getFormFields( $formId ): array {
889 $formId = (int) $formId;
890 $post = get_post( $formId );
891 if ( ! $post || $post->post_type !== 'wpcf7_contact_form' ) {
892 return array();
893 }
894
895 $contactForm = \WPCF7_ContactForm::get_instance( $formId );
896 if ( ! $contactForm ) {
897 return array();
898 }
899
900 $fields = array();
901 $tags = $contactForm->scan_form_tags();
902
903 foreach ( $tags as $tag ) {
904 if ( ! empty( $tag->name ) ) {
905 $fields[ $tag->name ] = $tag->name;
906 }
907 }
908
909 return $fields;
910 }
911
912 /**
913 * The text of each [acceptance]…[/acceptance] tag, as the visitor reads it.
914 *
915 * @param int|string $formId
916 *
917 * @return array<string, string>
918 */
919 public function getFieldTexts( $formId ): array {
920 $formId = (int) $formId;
921 $post = get_post( $formId );
922 if ( ! $post || $post->post_type !== 'wpcf7_contact_form' || ! class_exists( '\WPCF7_ContactForm' ) ) {
923 return array();
924 }
925
926 $contactForm = \WPCF7_ContactForm::get_instance( $formId );
927 if ( ! $contactForm ) {
928 return array();
929 }
930
931 $texts = array();
932 foreach ( $contactForm->scan_form_tags( array( 'basetype' => 'acceptance' ) ) as $tag ) {
933 $text = self::plainText( (string) ( $tag->content ?? '' ) );
934 if ( ! empty( $tag->name ) && $text !== '' ) {
935 $texts[ (string) $tag->name ] = $text;
936 }
937 }
938 return $texts;
939 }
940
941 /**
942 * Markup as the visitor reads it: tags removed, whitespace collapsed.
943 */
944 public static function plainText( string $html ): string {
945 $text = html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' );
946 return trim( (string) preg_replace( '/\s+/u', ' ', $text ) );
947 }
948
949 /**
950 * Add editor panel to CF7.
951 *
952 * @param array $panels The panels array.
953 *
954 * @return array Modified panels.
955 */
956 public function addEditorPanel( array $panels ): array {
957 $panels['optin'] = array(
958 'title' => $this->getPanelTitle(),
959 'callback' => array( $this, 'renderPanel' ),
960 );
961 return $panels;
962 }
963
964 /**
965 * {@inheritdoc}
966 */
967 public function render( $form, array $metadata ): void {
968 $this->renderPanel( $form );
969 }
970
971 /**
972 * Render the CF7 editor panel.
973 *
974 * Displays a notice with link to central form management.
975 * Full settings are now managed centrally in the Forms admin page.
976 *
977 * @param \WPCF7_ContactForm $post The contact form.
978 *
979 * @return void
980 */
981 public function renderPanel( $post ): void {
982 if ( ! $post || ! $post->id() ) {
983 ?>
984 <div class="doi-cf7-notice" style="padding: 20px;">
985 <div style="background: #fff; border: 1px solid #c3c4c7; border-radius: 4px; padding: 20px;">
986 <h2 style="margin-top: 0;"><?php _e( 'Double Opt-In Settings', 'double-opt-in' ); ?></h2>
987 <p style="color: #666;">
988 <?php _e( 'Please save the contact form first before configuring Double Opt-In.', 'double-opt-in' ); ?>
989 </p>
990 </div>
991 </div>
992 <?php
993 return;
994 }
995
996 $metadata = $this->getFormParameter( $post->id() );
997 $centralUrl = admin_url( 'admin.php?page=f12-doi-admin#/forms' );
998 $isEnabled = $this->isOptInEnabled( $post->id() );
999
1000 $this->getLogger()->debug(
1001 'Rendering CF7 panel notice',
1002 array(
1003 'plugin' => 'double-opt-in',
1004 'form_id' => $post->id(),
1005 'enabled' => $isEnabled,
1006 )
1007 );
1008 ?>
1009 <div class="doi-cf7-notice" style="padding: 20px;">
1010 <div style="background: #fff; border: 1px solid #c3c4c7; border-radius: 4px; padding: 20px;">
1011 <h2 style="margin-top: 0;"><?php _e( 'Double Opt-In Settings', 'double-opt-in' ); ?></h2>
1012
1013 <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 20px;">
1014 <span style="font-weight: 600;"><?php _e( 'Status:', 'double-opt-in' ); ?></span>
1015 <?php if ( $isEnabled ) : ?>
1016 <span style="display: inline-block; padding: 4px 12px; background: #d4edda; color: #155724; border-radius: 3px; font-weight: 500;">
1017 <?php _e( 'Enabled', 'double-opt-in' ); ?>
1018 </span>
1019 <?php else : ?>
1020 <span style="display: inline-block; padding: 4px 12px; background: #f8d7da; color: #721c24; border-radius: 3px; font-weight: 500;">
1021 <?php _e( 'Disabled', 'double-opt-in' ); ?>
1022 </span>
1023 <?php endif; ?>
1024 </div>
1025
1026 <p style="color: #666; margin-bottom: 20px;">
1027 <?php _e( 'Double Opt-In settings are now managed centrally. Use the button below to configure this form.', 'double-opt-in' ); ?>
1028 </p>
1029
1030 <a href="<?php echo esc_url( $centralUrl ); ?>" class="button button-primary" target="_blank">
1031 <?php _e( 'Configure Double Opt-In', 'double-opt-in' ); ?>
1032 </a>
1033 </div>
1034 </div>
1035 <?php
1036 }
1037
1038 /**
1039 * {@inheritdoc}
1040 */
1041 public function save( int $formId, array $data ): bool {
1042 if ( ! isset( $data['doubleoptin'] ) ) {
1043 update_post_meta( $formId, 'f12-cf7-doubleoptin', array() );
1044 return true;
1045 }
1046
1047 $parameter = SanitizeHelper::sanitize_array( $data['doubleoptin'] );
1048 $metadata = $this->getFormParameter( $formId );
1049
1050 foreach ( $metadata as $key => $value ) {
1051 if ( isset( $parameter[ $key ] ) ) {
1052 $metadata[ $key ] = $key === 'enable' ? (int) $parameter[ $key ] : $parameter[ $key ];
1053 } elseif ( $key === 'enable' ) {
1054 $metadata[ $key ] = 0;
1055 }
1056 }
1057
1058 $metadata = apply_filters( 'f12_cf7_doubleoptin_metadata_cf7', $metadata );
1059 $metadata = apply_filters( 'f12_cf7_doubleoptin_save_form', $metadata );
1060
1061 update_post_meta( $formId, 'f12-cf7-doubleoptin', $metadata );
1062
1063 // Save placeholder mapping
1064 if ( isset( $data['doubleoptin']['placeholder_mapping'] ) ) {
1065 $mapping = array_map( 'sanitize_text_field', $data['doubleoptin']['placeholder_mapping'] );
1066 PlaceholderMapper::saveCustomMapping( $formId, $mapping, 'cf7' );
1067 }
1068
1069 return true;
1070 }
1071
1072 /**
1073 * Save form settings callback.
1074 *
1075 * @param \WPCF7_ContactForm $contactForm The contact form.
1076 * @param array $args The arguments.
1077 * @param string $context The context.
1078 *
1079 * @return void
1080 */
1081 public function saveFormSettings( $contactForm, $args, $context ): void {
1082 $formId = $contactForm->id();
1083
1084 // Verify nonce
1085 if ( ! isset( $_POST['f12_cf7_doubleoptin_save_form_nonce'] ) ||
1086 ! wp_verify_nonce( wp_unslash( $_POST['f12_cf7_doubleoptin_save_form_nonce'] ), 'f12_cf7_doubleoptin_save_form_action' ) ) {
1087 return;
1088 }
1089
1090 $this->save( $formId, $_POST );
1091 }
1092
1093 /**
1094 * {@inheritdoc}
1095 */
1096 public function getPanelTitle(): string {
1097 return __( 'Double-Opt-in', 'double-opt-in' );
1098 }
1099
1100 /**
1101 * {@inheritdoc}
1102 */
1103 public function getAvailableTemplates(): array {
1104 $templates = array(
1105 'blank' => 'blank',
1106 'newsletter_en' => 'newsletter_en',
1107 'newsletter_en_2' => 'newsletter_en_2',
1108 'newsletter_en_3' => 'newsletter_en_3',
1109 );
1110
1111 // Add custom templates
1112 try {
1113 $container = Container::getInstance();
1114 $integration = $container->get( \Forge12\DoubleOptIn\EmailTemplates\EmailTemplateIntegration::class );
1115 $custom = $integration->getCustomTemplates();
1116
1117 foreach ( $custom as $template ) {
1118 $templates[ 'custom_' . $template['id'] ] = $template['title'] . ' (' . __( 'Custom', 'double-opt-in' ) . ')';
1119 }
1120 } catch ( \Exception $e ) {
1121 // Ignore if custom templates not available
1122 }
1123
1124 return $templates;
1125 }
1126
1127 /**
1128 * {@inheritdoc}
1129 */
1130 public function getAvailableCategories(): array {
1131 $categories = array( 0 => __( 'Please select', 'double-opt-in' ) );
1132
1133 $list = Category::get_list(
1134 array(
1135 'perPage' => -1,
1136 'orderBy' => 'name',
1137 'order' => 'ASC',
1138 ),
1139 $numberOfPages
1140 );
1141
1142 foreach ( $list as $category ) {
1143 $categories[ $category->get_id() ] = $category->get_name();
1144 }
1145
1146 return $categories;
1147 }
1148 }
1149