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.12.0 5.13.0 5.13.1 5.11.0 5.10.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 All 47 releases
← All changes | core/CleanUp.class.php +637 -301 3.1.0 → 5.5.0 View file →
@@ -1,302 +1,638 @@
1 -<?php
2 -
3 -namespace forge12\contactform7\CF7DoubleOptIn {
4 -
5 - use Forge12\Shared\LoggerInterface;
6 -
7 - if(!defined('ABSPATH')){
8 - exit;
9 - }
10 - /**
11 - * This class will handle the clean up of the database
12 - * as defined by the user settings.
13 - */
14 - class CleanUp
15 - {
16 - private LoggerInterface $logger;
17 -
18 - public function __construct(LoggerInterface $logger)
19 - {
20 - $this->logger = $logger;
21 -
22 - $this->get_logger()->info( 'Daily opt-in cleaner initialized.', [
23 - 'plugin' => 'double-opt-in',
24 - 'class' => __CLASS__,
25 - ] );
26 -
27 - $this->get_logger()->debug( 'Scheduling daily cleanup action for unconfirmed opt-ins.', [
28 - 'plugin' => 'double-opt-in',
29 - 'hook' => 'dailyOptinClear',
30 - 'callback' => 'removeUnconfirmedOptins',
31 - ] );
32 - add_action('dailyOptinClear', array($this, 'removeUnconfirmedOptins'));
33 -
34 - $this->get_logger()->debug( 'Scheduling daily cleanup action for confirmed opt-ins.', [
35 - 'plugin' => 'double-opt-in',
36 - 'hook' => 'dailyOptinClear',
37 - 'callback' => 'removeConfirmedOptins',
38 - ] );
39 - add_action('dailyOptinClear', array($this, 'removeConfirmedOptins'));
40 -
41 - $this->get_logger()->info( 'All daily cleanup actions have been scheduled.', [
42 - 'plugin' => 'double-opt-in',
43 - ] );
44 - }
45 -
46 - public function get_logger(): LoggerInterface{
47 - return $this->logger;
48 - }
49 -
50 - /**
51 - * Delete the given hash entry
52 - */
53 - public function deleteByHash($hash){
54 - // Log the start of the deletion attempt.
55 - $this->get_logger()->info( 'Attempting to delete opt-in entry by hash.', [
56 - 'plugin' => 'double-opt-in',
57 - 'hash' => $hash,
58 - ] );
59 -
60 - if(current_user_can('manage_options')){
61 - // Log the user's permission.
62 - $this->get_logger()->info( 'User has sufficient permissions to perform the deletion.', [
63 - 'plugin' => 'double-opt-in',
64 - 'user_id' => get_current_user_id(),
65 - ] );
66 -
67 - global $wpdb;
68 - $tableName = 'f12_cf7_doubleoptin';
69 - $wpTableName = $wpdb->prefix . $tableName;
70 -
71 - // Using a prepared statement is safer to prevent SQL injection. The original code is vulnerable.
72 - $query = $wpdb->prepare('DELETE FROM ' . $wpTableName . ' WHERE hash=%s', $hash);
73 -
74 - // Log the query before execution.
75 - $this->get_logger()->debug( 'Executing database query to delete by hash.', [
76 - 'plugin' => 'double-opt-in',
77 - 'query' => $query,
78 - ] );
79 -
80 - $result = $wpdb->query($query);
81 -
82 - if ($result === false) {
83 - // Log an error if the query failed.
84 - $this->get_logger()->error( 'Failed to delete opt-in by hash.', [
85 - 'plugin' => 'double-opt-in',
86 - 'hash' => $hash,
87 - 'error' => $wpdb->last_error,
88 - ] );
89 - } else if ($result === 0) {
90 - // Log a warning if no rows were affected.
91 - $this->get_logger()->warning( 'No rows were deleted for the provided hash. It might not exist.', [
92 - 'plugin' => 'double-opt-in',
93 - 'hash' => $hash,
94 - ] );
95 - } else {
96 - // Log a successful deletion.
97 - $this->get_logger()->notice( 'Opt-in entry successfully deleted by hash.', [
98 - 'plugin' => 'double-opt-in',
99 - 'hash' => $hash,
100 - 'rows_deleted' => $result,
101 - ] );
102 - }
103 -
104 - return $result;
105 - }
106 -
107 - // Log the permission failure.
108 - $this->get_logger()->warning( 'User does not have sufficient permissions to delete an opt-in entry.', [
109 - 'plugin' => 'double-opt-in',
110 - 'user_id' => get_current_user_id(),
111 - 'required_capability' => 'manage_options',
112 - ] );
113 -
114 - return false;
115 - }
116 -
117 - /**
118 - * Delete all Rows from the Database which are older than the given timestamp and where the optin value matches as defined.
119 - * @param $timestamp
120 - * @param int $optin
121 - */
122 - private function removeOlderThan($timestamp, $optin = 0)
123 - {
124 - // Log the start of the cleanup process.
125 - $this->get_logger()->info( 'Starting cleanup of opt-in entries older than a specific timestamp.', [
126 - 'plugin' => 'double-opt-in',
127 - 'timestamp' => $timestamp,
128 - 'status' => $optin === 1 ? 'confirmed' : 'unconfirmed',
129 - ] );
130 -
131 - global $wpdb;
132 - $tableName = 'f12_cf7_doubleoptin';
133 - $wpTableName = $wpdb->prefix . $tableName;
134 -
135 - // Use a prepared statement to prevent SQL injection. The original code is vulnerable.
136 - $query = $wpdb->prepare( 'DELETE FROM ' . $wpTableName . ' WHERE createtime < %s AND doubleoptin = %d', date('Y-m-d H:i:s', $timestamp), $optin );
137 -
138 - // Log the query before execution.
139 - $this->get_logger()->debug( 'Executing database query to remove old entries.', [
140 - 'plugin' => 'double-opt-in',
141 - 'query' => $query,
142 - ] );
143 -
144 - $result = $wpdb->query($query);
145 -
146 - if ($result === false) {
147 - // Log an error if the query failed.
148 - $this->get_logger()->error( 'Failed to remove old opt-in entries.', [
149 - 'plugin' => 'double-opt-in',
150 - 'error' => $wpdb->last_error,
151 - ] );
152 - } else {
153 - // Log the successful cleanup and the number of rows deleted.
154 - $this->get_logger()->notice( 'Successfully removed ' . $result . ' old opt-in entries.', [
155 - 'plugin' => 'double-opt-in',
156 - 'rows_deleted' => $result,
157 - 'status' => $optin === 1 ? 'confirmed' : 'unconfirmed',
158 - ] );
159 - }
160 - }
161 -
162 - /**
163 - * Delete all DOI entries
164 - */
165 - public function reset(){
166 - $this->get_logger()->critical( 'A full reset of the double-opt-in table has been requested.', [
167 - 'plugin' => 'double-opt-in',
168 - 'action' => 'reset',
169 - 'note' => 'This action will permanently delete ALL data from the table.',
170 - ] );
171 -
172 - global $wpdb;
173 - $tableName = 'f12_cf7_doubleoptin';
174 - $wpTableName = $wpdb->prefix . $tableName;
175 -
176 - // Use a prepared statement even for a full table reset to log the action properly.
177 - $query = 'DELETE FROM ' . $wpTableName;
178 -
179 - $this->get_logger()->warning( 'Executing a full table reset query. This is irreversible.', [
180 - 'plugin' => 'double-opt-in',
181 - 'table' => $wpTableName,
182 - 'query' => $query,
183 - ] );
184 -
185 - $result = $wpdb->query($query);
186 -
187 - if ($result === false) {
188 - $this->get_logger()->error( 'Failed to perform a full table reset.', [
189 - 'plugin' => 'double-opt-in',
190 - 'error' => $wpdb->last_error,
191 - ] );
192 - } else {
193 - $this->get_logger()->emergency( 'Full table reset successfully completed.', [
194 - 'plugin' => 'double-opt-in',
195 - 'rows_deleted' => $result,
196 - ] );
197 - }
198 - }
199 -
200 - /**
201 - * Clear all unconfirmed database entries if the period selected
202 - * is reached.
203 - *
204 - * Use force to delete confirmed opt-ins in the database settings.
205 - */
206 - public function removeUnconfirmedOptins($force = false)
207 - {
208 - $this->get_logger()->info( 'Starting removal process for unconfirmed opt-ins.', [
209 - 'plugin' => 'double-opt-in',
210 - 'force_mode' => $force,
211 - ] );
212 -
213 - $Settings = CF7DoubleOptIn::getInstance()->getSettings();
214 -
215 - // Log the configuration settings for unconfirmed opt-in removal.
216 - $this->get_logger()->debug( 'Checking plugin settings for unconfirmed opt-in removal.', [
217 - 'plugin' => 'double-opt-in',
218 - 'delete_unconfirmed' => $Settings['delete_unconfirmed'] ?? 'not set',
219 - ] );
220 -
221 - if (($Settings['delete_unconfirmed'] == 0 || !is_numeric($Settings['delete_unconfirmed'])) && !$force) {
222 - $this->get_logger()->notice( 'Unconfirmed opt-in removal is disabled based on settings and not in force mode. Aborting.', [
223 - 'plugin' => 'double-opt-in',
224 - ] );
225 - return;
226 - }
227 -
228 - $period = $Settings['delete_unconfirmed_period'];
229 -
230 - $this->get_logger()->info( 'Calculating timestamp for unconfirmed opt-in removal.', [
231 - 'plugin' => 'double-opt-in',
232 - 'age_threshold' => $Settings['delete_unconfirmed'] . ' ' . $period,
233 - ] );
234 -
235 - $timestamp = strtotime("- " . (int)$Settings['delete_unconfirmed'] . " " . $period, time());
236 -
237 - // Log the calculated timestamp.
238 - $this->get_logger()->debug( 'Calculated timestamp: ' . date('Y-m-d H:i:s', $timestamp), [
239 - 'plugin' => 'double-opt-in',
240 - 'timestamp' => $timestamp,
241 - ] );
242 -
243 - $this->removeOlderThan($timestamp, 0);
244 -
245 - $this->get_logger()->notice( 'Unconfirmed opt-in removal process completed.', [
246 - 'plugin' => 'double-opt-in',
247 - ] );
248 - }
249 -
250 - /**
251 - * Clear all confimred database entries if the period selected
252 - * is reached.
253 - *
254 - * Use force to delete confirmed opt-ins in the database settings.
255 - */
256 - public function removeConfirmedOptins($force = false)
257 - {
258 - $this->get_logger()->info( 'Starting removal process for confirmed opt-ins.', [
259 - 'plugin' => 'double-opt-in',
260 - 'force_mode' => $force,
261 - ] );
262 -
263 - $Settings = CF7DoubleOptIn::getInstance()->getSettings();
264 -
265 - // Log the configuration for confirmed opt-in removal.
266 - $this->get_logger()->debug( 'Checking plugin settings for confirmed opt-in removal.', [
267 - 'plugin' => 'double-opt-in',
268 - 'delete_setting' => $Settings['delete'] ?? 'not set',
269 - ] );
270 -
271 - if (($Settings['delete'] == 0 || !is_numeric($Settings['delete'])) && !$force) {
272 - $this->get_logger()->notice( 'Confirmed opt-in removal is disabled based on settings and not in force mode. Aborting.', [
273 - 'plugin' => 'double-opt-in',
274 - ] );
275 - return;
276 - }
277 -
278 - $period = $Settings['delete_period'];
279 -
280 - // Log the parameters being used for the time calculation.
281 - $this->get_logger()->info( 'Calculating timestamp for confirmed opt-in removal.', [
282 - 'plugin' => 'double-opt-in',
283 - 'age_threshold' => $Settings['delete'] . ' ' . $period,
284 - ] );
285 -
286 - $timestamp = strtotime("- " . (int)$Settings['delete'] . " " . $period, time());
287 -
288 - // Log the resulting timestamp.
289 - $this->get_logger()->debug( 'Calculated timestamp: ' . date('Y-m-d H:i:s', $timestamp), [
290 - 'plugin' => 'double-opt-in',
291 - 'timestamp' => $timestamp,
292 - ] );
293 -
294 - $this->removeOlderThan($timestamp, 1);
295 -
296 - $this->get_logger()->notice( 'Confirmed opt-in removal process completed.', [
297 - 'plugin' => 'double-opt-in',
298 - ] );
299 - }
300 -
301 - }
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 + }
302 638 }