PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 3.0.70 3.0.71 3.0.72 All 35 releases
← All changes | core/CleanUp.class.php +637 -97 2.155.5.0 View file →
@@ -1,98 +1,638 @@
1 -<?php
2 -
3 -namespace forge12\contactform7\CF7DoubleOptIn {
4 - if(!defined('ABSPATH')){
5 - exit;
6 - }
7 - /**
8 - * This class will handle the clean up of the database
9 - * as defined by the user settings.
10 - */
11 - class CleanUp
12 - {
13 - public function __construct()
14 - {
15 - add_action('dailyOptinClear', array($this, 'removeUnconfirmedOptins'));
16 - add_action('dailyOptinClear', array($this, 'removeConfirmedOptins'));
17 - }
18 -
19 - /**
20 - * Delete the given hash entry
21 - */
22 - public function deleteByHash($hash){
23 - if(current_user_can('manage_options')){
24 - global $wpdb;
25 - $tableName = 'f12_cf7_doubleoptin';
26 - $wpTableName = $wpdb->prefix . $tableName;
27 -
28 - return $wpdb->query('DELETE FROM ' . $wpTableName . ' WHERE hash="' . $hash . '"');
29 - }
30 - return false;
31 - }
32 -
33 - /**
34 - * Delete all Rows from the Database which are older than the given timestamp and where the optin value matches as defined.
35 - * @param $timestamp
36 - * @param int $optin
37 - */
38 - private function removeOlderThan($timestamp, $optin = 0)
39 - {
40 - global $wpdb;
41 - $tableName = 'f12_cf7_doubleoptin';
42 - $wpTableName = $wpdb->prefix . $tableName;
43 -
44 - //$sql = 'DELETE FROM ' . $wpTableName . ' WHERE createtime < "' . $timestamp . '" AND doubleoptin="' . $optin . '"';
45 - $wpdb->query('DELETE FROM ' . $wpTableName . ' WHERE createtime < "' . $timestamp . '" AND doubleoptin="' . $optin . '"');
46 - }
47 -
48 - /**
49 - * Delete all DOI entries
50 - */
51 - public function reset(){
52 - global $wpdb;
53 - $tableName = 'f12_cf7_doubleoptin';
54 - $wpTableName = $wpdb->prefix . $tableName;
55 -
56 - $wpdb->query('DELETE FROM ' . $wpTableName);
57 - }
58 -
59 - /**
60 - * Clear all unconfirmed database entries if the period selected
61 - * is reached.
62 - */
63 - public function removeUnconfirmedOptins()
64 - {
65 - $Settings = CF7DoubleOptIn::getInstance()->getSettings();
66 -
67 - if ($Settings['delete_unconfirmed'] == 0 || !is_numeric($Settings['delete_unconfirmed'])) {
68 - return;
69 - }
70 -
71 - $period = $Settings['delete_unconfirmed_period'];
72 -
73 - $timestamp = strtotime("- " . (int)$Settings['delete_unconfirmed'] . " ".$period, time());
74 -
75 - $this->removeOlderThan($timestamp, 0);
76 - }
77 -
78 - /**
79 - * Clear all confimred database entries if the period selected
80 - * is reached.
81 - */
82 - public function removeConfirmedOptins()
83 - {
84 - $Settings = CF7DoubleOptIn::getInstance()->getSettings();
85 -
86 - if ($Settings['delete'] == 0 || !is_numeric($Settings['delete'])) {
87 - return;
88 - }
89 -
90 - $period = $Settings['delete_period'];
91 -
92 - $timestamp = strtotime("- " . (int)$Settings['delete'] . " ".$period, time());
93 -
94 - $this->removeOlderThan($timestamp, 1);
95 - }
96 -
97 - }
1 +<?php
2 +
3 +namespace forge12\contactform7\CF7DoubleOptIn {
4 +
5 + use Forge12\DoubleOptIn\Container\Container;
6 + use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
7 + use Forge12\DoubleOptIn\Events\Lifecycle\OptInDeletedEvent;
8 + use Forge12\DoubleOptIn\Events\Lifecycle\OptInExpiredEvent;
9 + use Forge12\Shared\LoggerInterface;
10 +
11 + if(!defined('ABSPATH')){
12 + exit;
13 + }
14 + /**
15 + * This class will handle the clean up of the database
16 + * as defined by the user settings.
17 + */
18 + class CleanUp
19 + {
20 + private LoggerInterface $logger;
21 +
22 + public function __construct(LoggerInterface $logger)
23 + {
24 + $this->logger = $logger;
25 +
26 + $this->logger->info(
27 + 'Daily opt-in cleaner initialized.',
28 + [
29 + 'plugin' => 'double-opt-in',
30 + 'class' => static::class,
31 + ]
32 + );
33 +
34 + $this->registerHooks();
35 + }
36 +
37 + /**
38 + * Register all WordPress hooks for the opt-in cleanup.
39 + */
40 + private function registerHooks(): void
41 + {
42 + add_action('dailyOptinClear', [$this, 'handleDailyOptinCleanup']);
43 +
44 + $this->logger->debug(
45 + 'Daily opt-in cleanup hook registered.',
46 + [
47 + 'plugin' => 'double-opt-in',
48 + 'hook' => 'dailyOptinClear',
49 + 'handler'=> 'handleDailyOptinCleanup',
50 + ]
51 + );
52 + }
53 +
54 + /**
55 + * Central dispatcher for daily opt-in cleanup.
56 + * Keeps cron infrastructure separated from business logic.
57 + */
58 + public function handleDailyOptinCleanup(): void
59 + {
60 + $this->logger->info(
61 + 'Executing daily opt-in cleanup.',
62 + [
63 + 'plugin' => 'double-opt-in',
64 + ]
65 + );
66 +
67 + $this->removeUnconfirmedOptins();
68 + $this->removeConfirmedOptins();
69 +
70 + $this->logger->info(
71 + 'Daily opt-in cleanup finished.',
72 + [
73 + 'plugin' => 'double-opt-in',
74 + ]
75 + );
76 + }
77 +
78 + public function get_logger(): LoggerInterface{
79 + return $this->logger;
80 + }
81 +
82 + /**
83 + * Delete the given hash entry
84 + *
85 + * No native return type: `int|false` is a PHP 8.0 union type and the
86 + * plugin supports PHP 7.4.
87 + *
88 + * @param string $hash The opt-in hash to delete.
89 + *
90 + * @return int|false Number of deleted rows, or false on failure.
91 + */
92 + public function deleteByHash(string $hash){
93 + // Authorization check (fail fast)
94 + if (!current_user_can('manage_options')) {
95 + $this->get_logger()->warning('Unauthorized attempt to delete opt-in entry.', [
96 + 'plugin' => 'double-opt-in',
97 + 'method' => __METHOD__,
98 + 'user_id' => get_current_user_id(),
99 + 'required_capability' => 'manage_options',
100 + ]);
101 +
102 + return false;
103 + }
104 +
105 + // Input validation
106 + $hash = trim($hash);
107 + if ($hash === '') {
108 + $this->get_logger()->error('Deletion aborted due to invalid hash value.', [
109 + 'plugin' => 'double-opt-in',
110 + 'method' => __METHOD__,
111 + ]);
112 +
113 + return false;
114 + }
115 +
116 + global $wpdb;
117 +
118 + // Log the start of the deletion attempt.
119 + $this->get_logger()->info( 'Attempting to delete opt-in entry by hash.', [
120 + 'plugin' => 'double-opt-in',
121 + 'hash' => $hash,
122 + ] );
123 +
124 + $table_name = $wpdb->prefix . 'f12_cf7_doubleoptin';
125 +
126 + // Pre-fetch the row before the DELETE so the pre-delete cascade
127 + // hook can fire with the full payload. Listeners need
128 + // content/files/cf_form_id to clean up form-system data
129 + // (pre-doi-data-retention Step 1, 2026-05-08).
130 + $row = $wpdb->get_row(
131 + $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table_name} WHERE hash = %s", $hash ),
132 + ARRAY_A
133 + );
134 +
135 + $this->get_logger()->info('Deleting opt-in entry by hash.', [
136 + 'plugin' => 'double-opt-in',
137 + 'method' => __METHOD__,
138 + 'hash' => $hash,
139 + ]);
140 +
141 + if ( is_array( $row ) ) {
142 + /** @see f12_doi_optin_pre_delete in CleanUp::removeOlderThan for contract. */
143 + do_action( 'f12_doi_optin_pre_delete', $row );
144 + }
145 +
146 + $sql = $wpdb->prepare(
147 + "DELETE FROM {$table_name} WHERE hash = %s",
148 + $hash
149 + );
150 +
151 + $result = $wpdb->query($sql);
152 +
153 + if ($result === false) {
154 + $this->get_logger()->error('Database error while deleting opt-in entry.', [
155 + 'plugin' => 'double-opt-in',
156 + 'method' => __METHOD__,
157 + 'hash' => $hash,
158 + 'error' => $wpdb->last_error,
159 + ]);
160 +
161 + return false;
162 + }
163 +
164 + if ($result === 0) {
165 + $this->get_logger()->notice('No opt-in entry found for provided hash.', [
166 + 'plugin' => 'double-opt-in',
167 + 'method' => __METHOD__,
168 + 'hash' => $hash,
169 + ]);
170 +
171 + return 0;
172 + }
173 +
174 + $this->get_logger()->notice('Opt-in entry successfully deleted.', [
175 + 'plugin' => 'double-opt-in',
176 + 'method' => __METHOD__,
177 + 'hash' => $hash,
178 + 'rows_deleted' => $result,
179 + ]);
180 +
181 + // Dispatch typed event for new event-driven architecture
182 + $this->dispatchOptInDeletedEvent( $hash, 'manual', get_current_user_id() );
183 +
184 + return $result;
185 + }
186 +
187 + /**
188 + * Delete all Rows from the Database which are older than the given timestamp and where the optin value matches as defined.
189 + * @param $timestamp
190 + * @param int $optin
191 + */
192 + protected function removeOlderThan(int $timestamp, int $optin = 0): void
193 + {
194 + // Defensive validation: timestamp must be plausible
195 + if ($timestamp <= 0) {
196 + $this->get_logger()->warning(
197 + 'Aborted opt-in cleanup due to invalid timestamp.',
198 + [
199 + 'plugin' => 'double-opt-in',
200 + 'timestamp' => $timestamp,
201 + ]
202 + );
203 + return;
204 + }
205 +
206 + // Normalize opt-in status (allow only 0 or 1)
207 + $optin = ($optin === 1) ? 1 : 0;
208 +
209 + $statusLabel = ($optin === 1) ? 'confirmed' : 'unconfirmed';
210 +
211 + $this->get_logger()->info(
212 + 'Starting cleanup of opt-in entries older than timestamp.',
213 + [
214 + 'plugin' => 'double-opt-in',
215 + 'timestamp' => $timestamp,
216 + 'status' => $statusLabel,
217 + ]
218 + );
219 +
220 + global $wpdb;
221 + $tableName = $wpdb->prefix . 'f12_cf7_doubleoptin';
222 + $dateTime = gmdate('Y-m-d H:i:s', $timestamp);
223 +
224 + // Pre-fetch the rows that are about to be deleted so we can
225 + // dispatch per-row events around the DELETE. Listeners need
226 + // the hash for post-delete coordination (file-storage cascade
227 + // from file-lifecycle Schritt 0) AND the full row payload
228 + // for pre-delete cascade-cleanup of form-system data
229 + // (pre-doi-data-retention Step 1, 2026-05-08): the integration
230 + // listener reads content/files/cf_form_id to find what to
231 + // delete in the form plugin's own storage (WPForms entries,
232 + // GF entries, Avada/Elementor file URLs).
233 + //
234 + // ARRAY_A so $rowsToDelete elements are associative arrays —
235 + // simpler for listeners than juggling stdClass.
236 + $selectSql = $wpdb->prepare(
237 + "SELECT id, hash, content, files, cf_form_id FROM {$tableName} WHERE createtime < %s AND doubleoptin = %d",
238 + $dateTime,
239 + $optin
240 + );
241 + $rowsToDelete = (array) $wpdb->get_results( $selectSql, ARRAY_A );
242 +
243 + // Pre-delete cascade hook fires BEFORE the DELETE so listeners
244 + // can read the row's content/files/cf_form_id and clean up
245 + // their integration-specific side-effects in form-system
246 + // storage. The post-delete event below is too late — the row
247 + // is gone, listeners can't read its payload.
248 + foreach ( $rowsToDelete as $row ) {
249 + /**
250 + * Fires per row BEFORE an opt-in is deleted (cron path).
251 + *
252 + * Listeners cascade-delete form-system data (entries +
253 + * uploaded files in form-plugin storage). See
254 + * plan/pre-doi-data-retention.md.
255 + *
256 + * @since 4.3.0
257 + *
258 + * @param array $row Associative row: id, hash, content,
259 + * files, cf_form_id.
260 + */
261 + do_action( 'f12_doi_optin_pre_delete', $row );
262 + }
263 +
264 + // Prepare SQL securely (OWASP-compliant)
265 + $sql = $wpdb->prepare("DELETE FROM {$tableName} WHERE createtime < %s AND doubleoptin = %d",
266 + $dateTime,
267 + $optin
268 + );
269 +
270 + $this->get_logger()->debug(
271 + 'Executing opt-in cleanup query.',
272 + [
273 + 'plugin' => 'double-opt-in',
274 + 'sql' => $sql,
275 + 'datetime' => $dateTime,
276 + 'matched' => count( $rowsToDelete ),
277 + ]
278 + );
279 +
280 + $result = $wpdb->query($sql);
281 +
282 + if ($result === false) {
283 + $this->get_logger()->error(
284 + 'Database error while removing old opt-in entries.',
285 + [
286 + 'plugin' => 'double-opt-in',
287 + 'wpdb_error' => $wpdb->last_error,
288 + 'status' => $statusLabel,
289 + ]
290 + );
291 + return;
292 + }
293 +
294 + $this->get_logger()->notice(
295 + 'Opt-in cleanup completed successfully.',
296 + [
297 + 'plugin' => 'double-opt-in',
298 + 'rows_deleted' => (int) $result,
299 + 'status' => $statusLabel,
300 + 'older_than' => $dateTime,
301 + ]
302 + );
303 +
304 + // Dispatch per-row OptInDeletedEvent for every actually-deleted
305 + // hash. Reason carries cron context so listeners can distinguish
306 + // expired vs manual deletion if needed (e.g. for audit logs).
307 + if ( (int) $result > 0 ) {
308 + $reason = 'cron_expired_' . $statusLabel;
309 + foreach ( $rowsToDelete as $row ) {
310 + $hash = is_object( $row ) ? ( $row->hash ?? '' ) : ( $row['hash'] ?? '' );
311 + if ( $hash !== '' ) {
312 + $this->dispatchOptInDeletedEvent( (string) $hash, $reason, null );
313 + }
314 + }
315 +
316 + // Keep the legacy aggregate event for back-compat —
317 + // existing listeners (audit dashboard, telemetry) expect it.
318 + $this->dispatchOptInExpiredEvent( $statusLabel, (int) $result, $timestamp );
319 + }
320 + }
321 +
322 + /**
323 + * Delete all DOI entries
324 + */
325 + public function reset(): bool{
326 + // Authorization check (fail fast)
327 + if (!current_user_can('manage_options')) {
328 + $this->get_logger()->warning('Unauthorized attempt to reset double-opt-in table.', [
329 + 'plugin' => 'double-opt-in',
330 + 'method' => __METHOD__,
331 + 'user_id' => get_current_user_id(),
332 + ]);
333 +
334 + return false;
335 + }
336 + global $wpdb;
337 +
338 + $table_name = $wpdb->prefix . 'f12_cf7_doubleoptin';
339 +
340 + $this->get_logger()->critical('Confirmed full reset of double-opt-in table requested.', [
341 + 'plugin' => 'double-opt-in',
342 + 'method' => __METHOD__,
343 + 'table' => $table_name,
344 + 'note' => 'This operation will permanently delete ALL records.',
345 + ]);
346 +
347 + // TRUNCATE is faster and safer for full table resets
348 + $sql = "TRUNCATE TABLE {$table_name}";
349 +
350 + $result = $wpdb->query($sql);
351 +
352 + if ($result === false) {
353 + $this->get_logger()->error('Failed to reset double-opt-in table.', [
354 + 'plugin' => 'double-opt-in',
355 + 'method' => __METHOD__,
356 + 'table' => $table_name,
357 + 'error' => $wpdb->last_error,
358 + ]);
359 +
360 + return false;
361 + }
362 +
363 + $this->get_logger()->notice('Double-opt-in table has been fully reset.', [
364 + 'plugin' => 'double-opt-in',
365 + 'method' => __METHOD__,
366 + 'table' => $table_name,
367 + ]);
368 +
369 + return true;
370 + }
371 +
372 + /**
373 + * Clear all unconfirmed database entries if the period selected
374 + * is reached.
375 + *
376 + * Use force to delete confirmed opt-ins in the database settings.
377 + */
378 + public function removeUnconfirmedOptins(bool $force = false): void
379 + {
380 + $logger = $this->get_logger();
381 +
382 + $logger->info('Starting unconfirmed opt-in cleanup process.', [
383 + 'plugin' => 'double-opt-in',
384 + 'force_mode' => $force,
385 + ]);
386 +
387 + $settings = CF7DoubleOptIn::getInstance()->getSettings();
388 +
389 + $deleteAfter = isset($settings['delete_unconfirmed'])
390 + ? (int) $settings['delete_unconfirmed']
391 + : 0;
392 + $period = isset($settings['delete_unconfirmed_period'])
393 + ? (string) $settings['delete_unconfirmed_period']
394 + : '';
395 +
396 + $logger->debug('Loaded cleanup configuration.', [
397 + 'plugin' => 'double-opt-in',
398 + 'delete_unconfirmed' => $deleteAfter,
399 + 'period' => $period,
400 + ]);
401 +
402 + // Guard clause: feature disabled and not forced
403 + if (!$force && $deleteAfter <= 0) {
404 + $logger->notice('Unconfirmed opt-in cleanup is disabled. Aborting.', [
405 + 'plugin' => 'double-opt-in',
406 + ]);
407 + return;
408 + }
409 +
410 + // Force-mode short-circuit: an explicit user-button click
411 + // ("Delete Unconfirmed Only" on the Database Management page)
412 + // means "delete every unconfirmed opt-in NOW", regardless of
413 + // the configured retention window. Reported by user 2026-04-30:
414 + // "Delete Unconfirmed Only doesn't work" — the previous flow
415 + // fell through to the configured period below, so an install
416 + // with `delete_unconfirmed=30` + `period=days` would only
417 + // delete unconfirmed entries older than 30 days, leaving every
418 + // recent pending opt-in behind. force=true is the explicit
419 + // override; cron-driven calls (force=false) continue to honour
420 + // the configured retention period.
421 + if ($force) {
422 + $logger->info('Force mode enabled - deleting ALL unconfirmed opt-ins regardless of period.', [
423 + 'plugin' => 'double-opt-in',
424 + ]);
425 + // time()+86400 ensures every row's createtime < cutoff.
426 + $timestamp = time() + 86400;
427 + $this->removeOlderThan($timestamp, 0);
428 + return;
429 + }
430 +
431 + // Log the configuration settings for unconfirmed opt-in removal.
432 + $this->get_logger()->debug( 'Checking plugin settings for unconfirmed opt-in removal.', [
433 + 'plugin' => 'double-opt-in',
434 + 'delete_unconfirmed' => $settings['delete_unconfirmed'] ?? 'not set',
435 + ] );
436 +
437 + // Validate period
438 + $allowedPeriods = ['days', 'weeks', 'months', 'years'];
439 + if (!in_array($period, $allowedPeriods, true)) {
440 + $logger->error('Invalid cleanup period configuration.', [
441 + 'plugin' => 'double-opt-in',
442 + 'period' => $period,
443 + 'allowed' => $allowedPeriods,
444 + ]);
445 + return;
446 + }
447 +
448 + $deleteAfter = max(0, (int)$deleteAfter);
449 +
450 + $timestamp = strtotime(sprintf('-%d %s', $deleteAfter, $period));
451 +
452 + if ($timestamp === false) {
453 + $logger->error('Failed to calculate cleanup timestamp.', [
454 + 'plugin' => 'double-opt-in',
455 + 'delete_unconfirmed' => $deleteAfter,
456 + 'period' => $period,
457 + ]);
458 + return;
459 + }
460 +
461 + $logger->info('Removing unconfirmed opt-ins older than threshold.', [
462 + 'plugin' => 'double-opt-in',
463 + 'threshold_datetime' => date('Y-m-d H:i:s', $timestamp),
464 + 'threshold_timestamp' => $timestamp,
465 + ]);
466 +
467 + $this->removeOlderThan($timestamp, 0);
468 +
469 + $logger->notice('Unconfirmed opt-in cleanup completed successfully.', [
470 + 'plugin' => 'double-opt-in',
471 + ]);
472 + }
473 +
474 + /**
475 + * Clear all confimred database entries if the period selected
476 + * is reached.
477 + *
478 + * Use force to delete confirmed opt-ins in the database settings.
479 + */
480 + public function removeConfirmedOptins(bool $force = false): void
481 + {
482 + $this->get_logger()->info('Starting removal process for confirmed opt-ins.', [
483 + 'plugin' => 'double-opt-in',
484 + 'force_mode' => $force,
485 + ]);
486 + $settings = CF7DoubleOptIn::getInstance()->getSettings();
487 +
488 + // Normalize and validate configuration values (settings are returned as strings)
489 + $deleteAmount = isset($settings['delete']) ? (int) $settings['delete'] : 0;
490 + $deletePeriod = isset($settings['delete_period']) ? trim((string) $settings['delete_period']) : '';
491 +
492 + $this->get_logger()->debug('Loaded confirmed opt-in cleanup configuration.', [
493 + 'plugin' => 'double-opt-in',
494 + 'delete_amount' => $deleteAmount,
495 + 'delete_period' => $deletePeriod ?: 'not set',
496 + ]);
497 +
498 + // Guard clause: cleanup disabled and not forced
499 + if ($deleteAmount <= 0 && !$force) {
500 + $this->get_logger()->notice(
501 + 'Confirmed opt-in removal is disabled via configuration and not running in force mode. Aborting.',
502 + ['plugin' => 'double-opt-in']
503 + );
504 + return;
505 + }
506 +
507 + // Force-mode short-circuit — same rationale as
508 + // removeUnconfirmedOptins above. The "Delete Confirmed Only"
509 + // admin button means "delete every confirmed opt-in NOW",
510 + // not "respect the configured retention window".
511 + if ($force) {
512 + $this->get_logger()->info('Force mode enabled - deleting ALL confirmed opt-ins regardless of period.', [
513 + 'plugin' => 'double-opt-in',
514 + ]);
515 + $timestamp = time() + 86400;
516 + $this->removeOlderThan($timestamp, 1);
517 + return;
518 + }
519 +
520 + // Validate period to avoid invalid strtotime() behavior
521 + $allowedPeriods = ['days', 'weeks', 'months', 'years'];
522 + if (!in_array($deletePeriod, $allowedPeriods, true)) {
523 + $this->get_logger()->error('Invalid delete period configured for confirmed opt-ins.', [
524 + 'plugin' => 'double-opt-in',
525 + 'period' => $deletePeriod,
526 + ]);
527 + return;
528 + }
529 +
530 + $this->get_logger()->info('Calculating cutoff timestamp for confirmed opt-in removal.', [
531 + 'plugin' => 'double-opt-in',
532 + 'age_threshold' => sprintf('%d %s', $deleteAmount, $deletePeriod),
533 + ]);
534 +
535 + $timestamp = strtotime(sprintf('-%d %s', $deleteAmount, $deletePeriod));
536 +
537 + if ($timestamp === false) {
538 + $this->get_logger()->error('Failed to calculate timestamp for confirmed opt-in cleanup.', [
539 + 'plugin' => 'double-opt-in',
540 + 'delete_amount' => $deleteAmount,
541 + 'delete_period' => $deletePeriod,
542 + ]);
543 + return;
544 + }
545 +
546 + $this->get_logger()->debug('Calculated cutoff timestamp for confirmed opt-ins.', [
547 + 'plugin' => 'double-opt-in',
548 + 'timestamp' => $timestamp,
549 + 'datetime' => date('Y-m-d H:i:s', $timestamp),
550 + ]);
551 +
552 + // Status "1" = confirmed opt-ins
553 + $this->removeOlderThan($timestamp, 1);
554 +
555 + $this->get_logger()->notice('Confirmed opt-in removal process completed successfully.', [
556 + 'plugin' => 'double-opt-in',
557 + ]);
558 + }
559 +
560 + /**
561 + * Dispatch OptInDeletedEvent via the new event system.
562 + *
563 + * @param string $hash The hash of the deleted opt-in.
564 + * @param string $reason The reason for deletion.
565 + * @param int|null $deletedBy The user ID who deleted (null for system).
566 + *
567 + * @since 4.0.0
568 + */
569 + /**
570 + * Public so other deletion sites (REST handler, addon cleanup
571 + * actors) can route through the same event-dispatch pipeline
572 + * — single integration point for OptIn-deletion listeners,
573 + * including the file-storage cascade-cleanup.
574 + */
575 + public function dispatchOptInDeletedEvent( string $hash, string $reason, ?int $deletedBy = null ): void {
576 + try {
577 + $container = Container::getInstance();
578 + if ( $container->has( EventDispatcherInterface::class ) ) {
579 + $dispatcher = $container->get( EventDispatcherInterface::class );
580 + // OptInDeletedEvent's third arg is `string $deletedBy`
581 + // — cron-context callers pass null (no logged-in
582 + // user). Map null to 'system' so the typed
583 + // constructor doesn't fatal. Also coerce int
584 + // (manual REST: get_current_user_id()) to string
585 + // explicitly.
586 + $deletedByLabel = $deletedBy !== null ? (string) $deletedBy : 'system';
587 + $event = new OptInDeletedEvent( $hash, $reason, $deletedByLabel );
588 + $dispatcher->dispatch( $event );
589 +
590 + $this->get_logger()->debug( 'OptInDeletedEvent dispatched', [
591 + 'plugin' => 'double-opt-in',
592 + 'hash' => $hash,
593 + 'reason' => $reason,
594 + ] );
595 + }
596 + } catch ( \Exception $e ) {
597 + $this->get_logger()->warning( 'Failed to dispatch OptInDeletedEvent', [
598 + 'plugin' => 'double-opt-in',
599 + 'error' => $e->getMessage(),
600 + ] );
601 + }
602 + }
603 +
604 + /**
605 + * Dispatch OptInExpiredEvent via the new event system.
606 + *
607 + * @param string $cleanupType The type of cleanup (confirmed/unconfirmed).
608 + * @param int $rowsDeleted Number of rows deleted.
609 + * @param int $cutoffTime The cutoff timestamp.
610 + *
611 + * @since 4.0.0
612 + */
613 + private function dispatchOptInExpiredEvent( string $cleanupType, int $rowsDeleted, int $cutoffTime ): void {
614 + try {
615 + $container = Container::getInstance();
616 + if ( $container->has( EventDispatcherInterface::class ) ) {
617 + $dispatcher = $container->get( EventDispatcherInterface::class );
618 + // Convert timestamp to DateTimeImmutable as expected by OptInExpiredEvent
619 + $threshold = ( new \DateTimeImmutable() )->setTimestamp( $cutoffTime );
620 + $event = new OptInExpiredEvent( $cleanupType, $rowsDeleted, $threshold );
621 + $dispatcher->dispatch( $event );
622 +
623 + $this->get_logger()->debug( 'OptInExpiredEvent dispatched', [
624 + 'plugin' => 'double-opt-in',
625 + 'cleanup_type' => $cleanupType,
626 + 'rows_deleted' => $rowsDeleted,
627 + ] );
628 + }
629 + } catch ( \Exception $e ) {
630 + $this->get_logger()->warning( 'Failed to dispatch OptInExpiredEvent', [
631 + 'plugin' => 'double-opt-in',
632 + 'error' => $e->getMessage(),
633 + ] );
634 + }
635 + }
636 +
637 + }
98 638 }