PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.0.0
WooCommerce v11.0.0
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Email / Unsubscribes / Storage.php
woocommerce / src / Internal / Email / Unsubscribes Last commit date
Endpoint.php 1 month ago Storage.php 1 month ago
Storage.php
273 lines
1 <?php
2 /**
3 * Email unsubscribes Storage class file.
4 */
5
6 declare( strict_types = 1 );
7
8 namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
9
10 /**
11 * Storage and lookup for customer "do not send me this kind of email" preferences.
12 *
13 * Generic across email types: each row pairs a SHA-256 hash of the normalized
14 * email with a free-form `email_kind` string (typically the email's `$this->id`,
15 * e.g. `customer_checkout_recovery`). Multiple emails can route through the same
16 * table without colliding.
17 *
18 * Stored in a dedicated table (`wp_wc_email_unsubscribes`) rather than user meta
19 * so guest checkouts — common for the abandoned-checkout case — can opt out
20 * without needing a WP_User record. The hash is computed from the lowercased +
21 * trimmed email so casing or whitespace variations resolve to the same row.
22 *
23 * The table is installed via `WC_Install::get_schema()`. `init()` is auto-called
24 * by the container after instantiation; it registers the GDPR personal-data
25 * eraser so the WP "Erase Personal Data" tool clears this table too.
26 *
27 * @internal Just for internal use.
28 *
29 * @since 11.0.0
30 */
31 class Storage {
32
33 /**
34 * The unqualified table name (no `$wpdb->prefix`).
35 */
36 private const TABLE = 'wc_email_unsubscribes';
37
38 /**
39 * Action label stored on the row. Kept as a varchar column rather than a
40 * boolean so we can add further actions (e.g. resubscribed) later without
41 * a schema migration. Today there's only one.
42 */
43 public const ACTION_UNSUBSCRIBED = 'unsubscribed';
44
45 /**
46 * Shape of a valid email hash: 64 lowercase hex chars, matching the output
47 * of `hash('sha256', …)`. Shared with the public unsubscribe endpoint so
48 * the two validation sites can't drift apart.
49 */
50 public const HASH_PATTERN = '/^[a-f0-9]{64}$/';
51
52 /**
53 * Register the GDPR personal-data eraser.
54 *
55 * The table itself is installed via `WC_Install::get_schema()` so it's
56 * present on every site (including the test bootstrap) regardless of
57 * whether the checkout-recovery feature flag is enabled.
58 *
59 * Auto-called by the WC dependency container after instantiation.
60 *
61 * @internal
62 */
63 final public function init(): void {
64 add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
65 }
66
67 /**
68 * Database schema for the unsubscribes table.
69 *
70 * Called from `WC_Install::get_schema()` so the table is created/updated
71 * alongside the rest of WC's tables on activate/upgrade.
72 *
73 * @return string SQL CREATE TABLE statement.
74 */
75 public function get_database_schema(): string {
76 global $wpdb;
77 $table = $this->get_table_name();
78 $collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
79
80 return "CREATE TABLE {$table} (
81 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
82 email_hash char(64) NOT NULL,
83 email_kind varchar(64) NOT NULL,
84 action varchar(20) NOT NULL,
85 created_at datetime NOT NULL,
86 PRIMARY KEY (id),
87 KEY email_hash_kind (email_hash, email_kind)
88 ) {$collate};";
89 }
90
91 /**
92 * Hash a raw email address for use as the lookup key.
93 *
94 * Normalizes (trim + strtolower) before hashing so equivalent addresses
95 * collide on the same row. Returns an empty string for empty input so
96 * callers can early-out without raising.
97 *
98 * @param string $email Raw email address.
99 * @return string 64-char hex SHA-256 hash, or '' if input was empty.
100 */
101 public static function hash_email( string $email ): string {
102 $normalized = strtolower( trim( $email ) );
103 if ( '' === $normalized ) {
104 return '';
105 }
106 return hash( 'sha256', $normalized );
107 }
108
109 /**
110 * Whether the given email is currently unsubscribed from a specific kind.
111 *
112 * @param string $email Raw email address.
113 * @param string $kind Email-kind identifier (the email class's `$this->id`).
114 * @return bool
115 */
116 public function is_unsubscribed( string $email, string $kind ): bool {
117 $hash = self::hash_email( $email );
118 if ( '' === $hash || '' === $kind ) {
119 return false;
120 }
121
122 global $wpdb;
123 $table = $this->get_table_name();
124
125 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name hard-coded above; values bound.
126 $action = $wpdb->get_var(
127 $wpdb->prepare(
128 "SELECT action FROM {$table} WHERE email_hash = %s AND email_kind = %s ORDER BY id DESC LIMIT 1",
129 $hash,
130 $kind
131 )
132 );
133 // phpcs:enable
134
135 return self::ACTION_UNSUBSCRIBED === $action;
136 }
137
138 /**
139 * Record an unsubscribe for the given email + kind. Idempotent — repeated
140 * calls append new rows but the lookup only cares about the most recent.
141 *
142 * @param string $email Raw email address.
143 * @param string $kind Email-kind identifier.
144 * @return bool True if a row was written, false if input was empty.
145 */
146 public function mark_unsubscribed( string $email, string $kind ): bool {
147 return $this->record_action( self::hash_email( $email ), $kind, self::ACTION_UNSUBSCRIBED );
148 }
149
150 /**
151 * Record an unsubscribe directly by SHA-256 hash, for callers (e.g. the
152 * public unsubscribe endpoint) that operate on the hash already and never
153 * need to handle the raw email.
154 *
155 * Validates the hash matches `HASH_PATTERN` as defense in depth — the
156 * Endpoint already shape-checks the URL value, but any future caller that
157 * forgets to would otherwise insert a junk row.
158 *
159 * @param string $hash SHA-256 hex digest of the normalized email.
160 * @param string $kind Email-kind identifier.
161 * @return bool True if a row was written.
162 */
163 public function mark_unsubscribed_by_hash( string $hash, string $kind ): bool {
164 if ( 1 !== preg_match( self::HASH_PATTERN, $hash ) ) {
165 return false;
166 }
167 return $this->record_action( $hash, $kind, self::ACTION_UNSUBSCRIBED );
168 }
169
170 /**
171 * Remove all rows (across every kind) for an email — used by the GDPR
172 * personal-data eraser so a customer's "right to be forgotten" request
173 * clears their opt-out record along with the rest of their data.
174 *
175 * @param string $email Raw email address.
176 * @return int Number of rows deleted.
177 */
178 public function erase_for_email( string $email ): int {
179 $hash = self::hash_email( $email );
180 if ( '' === $hash ) {
181 return 0;
182 }
183
184 global $wpdb;
185 $table = $this->get_table_name();
186
187 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name hard-coded; hash bound.
188 $deleted = $wpdb->query(
189 $wpdb->prepare(
190 "DELETE FROM {$table} WHERE email_hash = %s",
191 $hash
192 )
193 );
194 // phpcs:enable
195
196 return is_numeric( $deleted ) ? (int) $deleted : 0;
197 }
198
199 /**
200 * Filter callback that adds this repository's eraser to WP's GDPR registry.
201 *
202 * @internal
203 *
204 * @param array<string, array{eraser_friendly_name: string, callback: callable}> $erasers Existing erasers.
205 * @return array<string, array{eraser_friendly_name: string, callback: callable}>
206 */
207 public function register_personal_data_eraser( array $erasers ): array {
208 $erasers['wc-email-unsubscribes'] = array(
209 'eraser_friendly_name' => __( 'WooCommerce Email Unsubscribes', 'woocommerce' ),
210 'callback' => array( $this, 'handle_personal_data_erasure' ),
211 );
212 return $erasers;
213 }
214
215 /**
216 * Callback for the WP personal-data eraser.
217 *
218 * @internal
219 *
220 * @param string $email Email address being erased.
221 * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool}
222 */
223 public function handle_personal_data_erasure( string $email ): array {
224 $removed = $this->erase_for_email( $email ) > 0;
225
226 return array(
227 'items_removed' => $removed,
228 'items_retained' => false,
229 'messages' => array(),
230 'done' => true,
231 );
232 }
233
234 /**
235 * Append an action row.
236 *
237 * @param string $hash SHA-256 hex digest of the normalized email.
238 * @param string $kind Email-kind identifier.
239 * @param string $action `unsubscribed` or `resubscribed`.
240 * @return bool
241 */
242 private function record_action( string $hash, string $kind, string $action ): bool {
243 if ( '' === $hash || '' === $kind ) {
244 return false;
245 }
246
247 global $wpdb;
248
249 // phpcs:disable WordPress.DB.DirectDatabaseQuery -- write to an internal preference table.
250 $inserted = $wpdb->insert(
251 $this->get_table_name(),
252 array(
253 'email_hash' => $hash,
254 'email_kind' => $kind,
255 'action' => $action,
256 'created_at' => current_time( 'mysql', true ),
257 ),
258 array( '%s', '%s', '%s', '%s' )
259 );
260 // phpcs:enable
261
262 return false !== $inserted;
263 }
264
265 /**
266 * Fully-qualified table name including the wpdb prefix.
267 */
268 private function get_table_name(): string {
269 global $wpdb;
270 return $wpdb->prefix . self::TABLE;
271 }
272 }
273