PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
double-opt-in / src / Integration / CF7Integration.php

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

1,112 lines 32.9 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 {
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 * Add editor panel to CF7.
914 *
915 * @param array $panels The panels array.
916 *
917 * @return array Modified panels.
918 */
919 public function addEditorPanel( array $panels ): array {
920 $panels['optin'] = array(
921 'title' => $this->getPanelTitle(),
922 'callback' => array( $this, 'renderPanel' ),
923 );
924 return $panels;
925 }
926
927 /**
928 * {@inheritdoc}
929 */
930 public function render( $form, array $metadata ): void {
931 $this->renderPanel( $form );
932 }
933
934 /**
935 * Render the CF7 editor panel.
936 *
937 * Displays a notice with link to central form management.
938 * Full settings are now managed centrally in the Forms admin page.
939 *
940 * @param \WPCF7_ContactForm $post The contact form.
941 *
942 * @return void
943 */
944 public function renderPanel( $post ): void {
945 if ( ! $post || ! $post->id() ) {
946 ?>
947 <div class="doi-cf7-notice" style="padding: 20px;">
948 <div style="background: #fff; border: 1px solid #c3c4c7; border-radius: 4px; padding: 20px;">
949 <h2 style="margin-top: 0;"><?php _e( 'Double Opt-In Settings', 'double-opt-in' ); ?></h2>
950 <p style="color: #666;">
951 <?php _e( 'Please save the contact form first before configuring Double Opt-In.', 'double-opt-in' ); ?>
952 </p>
953 </div>
954 </div>
955 <?php
956 return;
957 }
958
959 $metadata = $this->getFormParameter( $post->id() );
960 $centralUrl = admin_url( 'admin.php?page=f12-doi-admin#/forms' );
961 $isEnabled = $this->isOptInEnabled( $post->id() );
962
963 $this->getLogger()->debug(
964 'Rendering CF7 panel notice',
965 array(
966 'plugin' => 'double-opt-in',
967 'form_id' => $post->id(),
968 'enabled' => $isEnabled,
969 )
970 );
971 ?>
972 <div class="doi-cf7-notice" style="padding: 20px;">
973 <div style="background: #fff; border: 1px solid #c3c4c7; border-radius: 4px; padding: 20px;">
974 <h2 style="margin-top: 0;"><?php _e( 'Double Opt-In Settings', 'double-opt-in' ); ?></h2>
975
976 <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 20px;">
977 <span style="font-weight: 600;"><?php _e( 'Status:', 'double-opt-in' ); ?></span>
978 <?php if ( $isEnabled ) : ?>
979 <span style="display: inline-block; padding: 4px 12px; background: #d4edda; color: #155724; border-radius: 3px; font-weight: 500;">
980 <?php _e( 'Enabled', 'double-opt-in' ); ?>
981 </span>
982 <?php else : ?>
983 <span style="display: inline-block; padding: 4px 12px; background: #f8d7da; color: #721c24; border-radius: 3px; font-weight: 500;">
984 <?php _e( 'Disabled', 'double-opt-in' ); ?>
985 </span>
986 <?php endif; ?>
987 </div>
988
989 <p style="color: #666; margin-bottom: 20px;">
990 <?php _e( 'Double Opt-In settings are now managed centrally. Use the button below to configure this form.', 'double-opt-in' ); ?>
991 </p>
992
993 <a href="<?php echo esc_url( $centralUrl ); ?>" class="button button-primary" target="_blank">
994 <?php _e( 'Configure Double Opt-In', 'double-opt-in' ); ?>
995 </a>
996 </div>
997 </div>
998 <?php
999 }
1000
1001 /**
1002 * {@inheritdoc}
1003 */
1004 public function save( int $formId, array $data ): bool {
1005 if ( ! isset( $data['doubleoptin'] ) ) {
1006 update_post_meta( $formId, 'f12-cf7-doubleoptin', array() );
1007 return true;
1008 }
1009
1010 $parameter = SanitizeHelper::sanitize_array( $data['doubleoptin'] );
1011 $metadata = $this->getFormParameter( $formId );
1012
1013 foreach ( $metadata as $key => $value ) {
1014 if ( isset( $parameter[ $key ] ) ) {
1015 $metadata[ $key ] = $key === 'enable' ? (int) $parameter[ $key ] : $parameter[ $key ];
1016 } elseif ( $key === 'enable' ) {
1017 $metadata[ $key ] = 0;
1018 }
1019 }
1020
1021 $metadata = apply_filters( 'f12_cf7_doubleoptin_metadata_cf7', $metadata );
1022 $metadata = apply_filters( 'f12_cf7_doubleoptin_save_form', $metadata );
1023
1024 update_post_meta( $formId, 'f12-cf7-doubleoptin', $metadata );
1025
1026 // Save placeholder mapping
1027 if ( isset( $data['doubleoptin']['placeholder_mapping'] ) ) {
1028 $mapping = array_map( 'sanitize_text_field', $data['doubleoptin']['placeholder_mapping'] );
1029 PlaceholderMapper::saveCustomMapping( $formId, $mapping, 'cf7' );
1030 }
1031
1032 return true;
1033 }
1034
1035 /**
1036 * Save form settings callback.
1037 *
1038 * @param \WPCF7_ContactForm $contactForm The contact form.
1039 * @param array $args The arguments.
1040 * @param string $context The context.
1041 *
1042 * @return void
1043 */
1044 public function saveFormSettings( $contactForm, $args, $context ): void {
1045 $formId = $contactForm->id();
1046
1047 // Verify nonce
1048 if ( ! isset( $_POST['f12_cf7_doubleoptin_save_form_nonce'] ) ||
1049 ! wp_verify_nonce( wp_unslash( $_POST['f12_cf7_doubleoptin_save_form_nonce'] ), 'f12_cf7_doubleoptin_save_form_action' ) ) {
1050 return;
1051 }
1052
1053 $this->save( $formId, $_POST );
1054 }
1055
1056 /**
1057 * {@inheritdoc}
1058 */
1059 public function getPanelTitle(): string {
1060 return __( 'Double-Opt-in', 'double-opt-in' );
1061 }
1062
1063 /**
1064 * {@inheritdoc}
1065 */
1066 public function getAvailableTemplates(): array {
1067 $templates = array(
1068 'blank' => 'blank',
1069 'newsletter_en' => 'newsletter_en',
1070 'newsletter_en_2' => 'newsletter_en_2',
1071 'newsletter_en_3' => 'newsletter_en_3',
1072 );
1073
1074 // Add custom templates
1075 try {
1076 $container = Container::getInstance();
1077 $integration = $container->get( \Forge12\DoubleOptIn\EmailTemplates\EmailTemplateIntegration::class );
1078 $custom = $integration->getCustomTemplates();
1079
1080 foreach ( $custom as $template ) {
1081 $templates[ 'custom_' . $template['id'] ] = $template['title'] . ' (' . __( 'Custom', 'double-opt-in' ) . ')';
1082 }
1083 } catch ( \Exception $e ) {
1084 // Ignore if custom templates not available
1085 }
1086
1087 return $templates;
1088 }
1089
1090 /**
1091 * {@inheritdoc}
1092 */
1093 public function getAvailableCategories(): array {
1094 $categories = array( 0 => __( 'Please select', 'double-opt-in' ) );
1095
1096 $list = Category::get_list(
1097 array(
1098 'perPage' => -1,
1099 'orderBy' => 'name',
1100 'order' => 'ASC',
1101 ),
1102 $numberOfPages
1103 );
1104
1105 foreach ( $list as $category ) {
1106 $categories[ $category->get_id() ] = $category->get_name();
1107 }
1108
1109 return $categories;
1110 }
1111 }
1112