Skip to content

Commit 6cf36ac

Browse files
authored
Merge pull request #11709 from google/enhancement/11544-add-email-log-cpt
Enhancement/11544 add email log cpt
2 parents f0b4883 + b28a825 commit 6cf36ac

File tree

6 files changed

+620
-32
lines changed

6 files changed

+620
-32
lines changed
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
<?php
2+
/**
3+
* Class Google\Site_Kit\Core\Email_Reporting\Email_Log
4+
*
5+
* @package Google\Site_Kit\Core\Email_Reporting
6+
* @copyright 2025 Google LLC
7+
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
8+
* @link https://sitekit.withgoogle.com
9+
*/
10+
11+
namespace Google\Site_Kit\Core\Email_Reporting;
12+
13+
use Google\Site_Kit\Core\User\Email_Reporting_Settings as Reporting_Settings;
14+
use Google\Site_Kit\Core\Util\Method_Proxy_Trait;
15+
16+
/**
17+
* Registers the internal Email Reporting log storage.
18+
*
19+
* @since n.e.x.t
20+
* @access private
21+
* @ignore
22+
*/
23+
final class Email_Log {
24+
25+
use Method_Proxy_Trait;
26+
27+
/**
28+
* Post type slug.
29+
*/
30+
const POST_TYPE = 'gsk_email_log';
31+
32+
/**
33+
* Report frequency meta key.
34+
*/
35+
const META_REPORT_FREQUENCY = '_report_frequency';
36+
37+
/**
38+
* Batch ID meta key.
39+
*/
40+
const META_BATCH_ID = '_batch_id';
41+
42+
/**
43+
* Maximum length for stored log strings (MySQL utf8mb4 index safety).
44+
*/
45+
const META_STRING_MAX_LENGTH = 191;
46+
47+
/**
48+
* Send attempts meta key.
49+
*/
50+
const META_SEND_ATTEMPTS = '_send_attempts';
51+
52+
/**
53+
* Error details meta key.
54+
*/
55+
const META_ERROR_DETAILS = '_error_details';
56+
57+
/**
58+
* Report reference dates meta key.
59+
*/
60+
const META_REPORT_REFERENCE_DATES = '_report_reference_dates';
61+
62+
/**
63+
* Email log post statuses.
64+
*
65+
* Slugs must stay within the posts table varchar(20) limit.
66+
*/
67+
const STATUS_SENT = 'gsk_email_sent';
68+
const STATUS_FAILED = 'gsk_email_failed';
69+
const STATUS_SCHEDULED = 'gsk_email_scheduled';
70+
71+
/**
72+
* Registers functionality through WordPress hooks.
73+
*
74+
* @since n.e.x.t
75+
*/
76+
public function register() {
77+
add_action( 'init', $this->get_method_proxy_once( 'register_email_log' ) );
78+
}
79+
80+
/**
81+
* Registers the email log post type, statuses, and meta.
82+
*
83+
* @since n.e.x.t
84+
*/
85+
protected function register_email_log() {
86+
$this->register_post_type();
87+
$this->register_post_statuses();
88+
$this->register_post_meta();
89+
}
90+
91+
/**
92+
* Registers the internal email log post type.
93+
*
94+
* @since n.e.x.t
95+
*/
96+
protected function register_post_type() {
97+
if ( post_type_exists( self::POST_TYPE ) ) {
98+
return;
99+
}
100+
101+
register_post_type(
102+
self::POST_TYPE,
103+
array(
104+
'public' => false,
105+
'map_meta_cap' => true,
106+
'rewrite' => false,
107+
'query_var' => false,
108+
)
109+
);
110+
}
111+
112+
/**
113+
* Registers internal delivery statuses.
114+
*
115+
* @since n.e.x.t
116+
*/
117+
protected function register_post_statuses() {
118+
$statuses = array(
119+
self::STATUS_SENT,
120+
self::STATUS_FAILED,
121+
self::STATUS_SCHEDULED,
122+
);
123+
124+
foreach ( $statuses as $key => $status ) {
125+
register_post_status(
126+
$status,
127+
array(
128+
'public' => false,
129+
'internal' => true,
130+
'exclude_from_search' => true,
131+
'show_in_admin_all_list' => false,
132+
'show_in_admin_status_list' => false,
133+
)
134+
);
135+
}
136+
}
137+
138+
/**
139+
* Registers meta data for the email log post type.
140+
*
141+
* @since n.e.x.t
142+
*/
143+
protected function register_post_meta() {
144+
$auth_callback = array( __CLASS__, 'meta_auth_callback' );
145+
146+
register_post_meta(
147+
self::POST_TYPE,
148+
self::META_REPORT_FREQUENCY,
149+
array(
150+
'type' => 'string',
151+
'single' => true,
152+
'auth_callback' => $auth_callback,
153+
'sanitize_callback' => array( __CLASS__, 'sanitize_frequency' ),
154+
)
155+
);
156+
157+
register_post_meta(
158+
self::POST_TYPE,
159+
self::META_BATCH_ID,
160+
array(
161+
'type' => 'string',
162+
'single' => true,
163+
'auth_callback' => $auth_callback,
164+
'sanitize_callback' => array( __CLASS__, 'sanitize_batch_id' ),
165+
)
166+
);
167+
168+
register_post_meta(
169+
self::POST_TYPE,
170+
self::META_SEND_ATTEMPTS,
171+
array(
172+
'type' => 'integer',
173+
'single' => true,
174+
'auth_callback' => $auth_callback,
175+
'sanitize_callback' => array( __CLASS__, 'sanitize_attempts' ),
176+
)
177+
);
178+
179+
register_post_meta(
180+
self::POST_TYPE,
181+
self::META_ERROR_DETAILS,
182+
array(
183+
'type' => 'string',
184+
'single' => true,
185+
'auth_callback' => $auth_callback,
186+
'sanitize_callback' => array( __CLASS__, 'sanitize_error_details' ),
187+
)
188+
);
189+
190+
register_post_meta(
191+
self::POST_TYPE,
192+
self::META_REPORT_REFERENCE_DATES,
193+
array(
194+
'type' => 'string',
195+
'single' => true,
196+
'auth_callback' => $auth_callback,
197+
'sanitize_callback' => array( __CLASS__, 'sanitize_reference_dates' ),
198+
)
199+
);
200+
}
201+
202+
/**
203+
* Sanitizes the report frequency meta value.
204+
*
205+
* Allows only known scheduling frequencies, normalizing strings to lowercase.
206+
*
207+
* @since n.e.x.t
208+
*
209+
* @param mixed $value Meta value.
210+
* @return string Sanitized value.
211+
*/
212+
public static function sanitize_frequency( $value ) {
213+
$allowed = array(
214+
Reporting_Settings::FREQUENCY_WEEKLY,
215+
Reporting_Settings::FREQUENCY_MONTHLY,
216+
Reporting_Settings::FREQUENCY_QUARTERLY,
217+
);
218+
$value = is_string( $value ) ? strtolower( $value ) : '';
219+
220+
return in_array( $value, $allowed, true ) ? $value : '';
221+
}
222+
223+
/**
224+
* Sanitizes the batch ID meta value.
225+
*
226+
* Strips unsafe characters and limits identifier string length so IDs
227+
* remain index-safe in MySQL databases.
228+
*
229+
* @since n.e.x.t
230+
*
231+
* @param mixed $value Meta value.
232+
* @return string Sanitized value.
233+
*/
234+
public static function sanitize_batch_id( $value ) {
235+
$value = sanitize_text_field( (string) $value );
236+
237+
return substr( $value, 0, self::META_STRING_MAX_LENGTH );
238+
}
239+
240+
/**
241+
* Sanitizes the send attempts meta value.
242+
*
243+
* @since n.e.x.t
244+
*
245+
* @param mixed $value Meta value.
246+
* @return int Sanitized value.
247+
*/
248+
public static function sanitize_attempts( $value ) {
249+
if ( (int) $value < 0 ) {
250+
return 0;
251+
}
252+
253+
return absint( $value );
254+
}
255+
256+
/**
257+
* Sanitizes the error details meta value.
258+
*
259+
* Converts WP_Error instances and other payloads into JSON for storage.
260+
*
261+
* @since n.e.x.t
262+
*
263+
* @param mixed $value Meta value.
264+
* @return string Sanitized value.
265+
*/
266+
public static function sanitize_error_details( $value ) {
267+
if ( is_wp_error( $value ) ) {
268+
$value = array(
269+
'errors' => $value->errors,
270+
'error_data' => $value->error_data,
271+
);
272+
}
273+
274+
if ( is_array( $value ) || is_object( $value ) ) {
275+
$encoded = wp_json_encode( $value, JSON_UNESCAPED_UNICODE );
276+
return is_string( $encoded ) ? $encoded : '';
277+
}
278+
279+
if ( is_string( $value ) ) {
280+
// Treat existing JSON strings as-is by checking decode status instead of rebuilding them.
281+
json_decode( $value, true );
282+
if ( json_last_error() === JSON_ERROR_NONE ) {
283+
return $value;
284+
}
285+
286+
$encoded = wp_json_encode(
287+
array(
288+
'message' => $value,
289+
),
290+
JSON_UNESCAPED_UNICODE
291+
);
292+
293+
return is_string( $encoded ) ? $encoded : '';
294+
}
295+
296+
return '';
297+
}
298+
299+
/**
300+
* Sanitizes the report reference dates meta value.
301+
*
302+
* Extracts known timestamps, coercing them to integers before encoding.
303+
*
304+
* @since n.e.x.t
305+
*
306+
* @param mixed $value Meta value.
307+
* @return string Sanitized value.
308+
*/
309+
public static function sanitize_reference_dates( $value ) {
310+
if ( ! is_array( $value ) && ! is_object( $value ) ) {
311+
return '';
312+
}
313+
314+
$keys = array( 'startDate', 'sendDate', 'compareStartDate', 'compareEndDate' );
315+
$raw_dates = (array) $value;
316+
// Pre-seed ( 'startDate', 'sendDate', 'compareStartDate', 'compareEndDate' ) keys
317+
// so missing timestamps normalize to 0 and consumers always see a full schema.
318+
$normalized = array_fill_keys( $keys, 0 );
319+
320+
foreach ( $keys as $key ) {
321+
if ( isset( $raw_dates[ $key ] ) ) {
322+
$normalized[ $key ] = absint( $raw_dates[ $key ] );
323+
}
324+
}
325+
326+
$encoded = wp_json_encode( $normalized, JSON_UNESCAPED_UNICODE );
327+
328+
return is_string( $encoded ) ? $encoded : '';
329+
}
330+
331+
/**
332+
* Authorization callback for protected log meta.
333+
*
334+
* Ensures only internal workflows (cron/init) or administrators touch the
335+
* private log metadata so the CPT stays non-public.
336+
*
337+
* @since n.e.x.t
338+
*
339+
* @return bool
340+
*/
341+
public static function meta_auth_callback() {
342+
if ( current_user_can( 'manage_options' ) ) {
343+
return true;
344+
}
345+
346+
if ( wp_doing_cron() ) {
347+
return true;
348+
}
349+
350+
if ( doing_action( 'init' ) ) {
351+
return true;
352+
}
353+
354+
return false;
355+
}
356+
}

includes/Core/Email_Reporting/Email_Reporting.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,18 @@ class Email_Reporting {
4242
* REST_Email_Reporting_Controller instance.
4343
*
4444
* @since 1.162.0
45-
* @var REST_Email_Reporting_Controller|null
45+
* @var REST_Email_Reporting_Controller
4646
*/
4747
protected $rest_controller;
4848

49+
/**
50+
* Email_Log instance.
51+
*
52+
* @since n.e.x.t
53+
* @var Email_Log
54+
*/
55+
protected $email_log;
56+
4957
/**
5058
* Constructor.
5159
*
@@ -55,9 +63,11 @@ class Email_Reporting {
5563
* @param Options|null $options Optional. Options instance. Default is a new instance.
5664
*/
5765
public function __construct( Context $context, ?Options $options = null ) {
58-
$options = $options ?: new Options( $context );
66+
$this->context = $context;
67+
$options = $options ?: new Options( $this->context );
5968
$this->settings = new Email_Reporting_Settings( $options );
6069
$this->rest_controller = new REST_Email_Reporting_Controller( $this->settings );
70+
$this->email_log = new Email_Log( $this->context );
6171
}
6272

6373
/**
@@ -68,5 +78,6 @@ public function __construct( Context $context, ?Options $options = null ) {
6878
public function register() {
6979
$this->settings->register();
7080
$this->rest_controller->register();
81+
$this->email_log->register();
7182
}
7283
}

0 commit comments

Comments
 (0)