PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / api / email-log.php

email-log.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/api/email-log.php

372 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST endpoints for the email log (customer-communication audit).
4 *
5 * GET /storeengine/v1/email-logs — paged list with filters
6 * GET /storeengine/v1/email-logs/{id} — one row
7 * DELETE /storeengine/v1/email-logs/{id} — manual delete (cleanup cron handles bulk)
8 *
9 * @version 1.0.0
10 */
11
12 namespace StoreEngine\API;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 use StoreEngine\Addons\Email\ResendRegistry;
19 use StoreEngine\Classes\EmailLog as EmailLogEntity;
20 use StoreEngine\Classes\EmailLogCleanup;
21 use StoreEngine\Classes\EmailLogCollection;
22 use StoreEngine\Utils\Helper;
23 use WP_Error;
24 use WP_REST_Controller;
25 use WP_REST_Request;
26 use WP_REST_Server;
27
28 class EmailLog extends WP_REST_Controller {
29
30 public function __construct() {
31 $this->namespace = STOREENGINE_PLUGIN_SLUG . '/v1';
32 $this->rest_base = 'email-logs';
33 }
34
35 public static function init() {
36 $self = new self();
37 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
38 }
39
40 public function register_routes() {
41 register_rest_route( $this->namespace, '/' . $this->rest_base, [
42 [
43 'methods' => WP_REST_Server::READABLE,
44 'callback' => [ $this, 'get_items' ],
45 'permission_callback' => [ $this, 'permissions_check' ],
46 'args' => $this->get_collection_params(),
47 ],
48 ] );
49
50 register_rest_route( $this->namespace, '/' . $this->rest_base . '/settings', [
51 [
52 'methods' => WP_REST_Server::READABLE,
53 'callback' => [ $this, 'get_settings' ],
54 'permission_callback' => [ $this, 'permissions_check' ],
55 ],
56 [
57 'methods' => WP_REST_Server::CREATABLE,
58 'callback' => [ $this, 'save_settings' ],
59 'permission_callback' => [ $this, 'permissions_check' ],
60 'args' => [
61 'retention_days_sent' => [
62 'required' => true,
63 'type' => 'integer',
64 'sanitize_callback' => 'absint',
65 ],
66 'retention_days_failed' => [
67 'required' => true,
68 'type' => 'integer',
69 'sanitize_callback' => 'absint',
70 ],
71 ],
72 ],
73 ] );
74
75 register_rest_route( $this->namespace, '/' . $this->rest_base . '/purge', [
76 [
77 'methods' => WP_REST_Server::CREATABLE,
78 'callback' => [ $this, 'purge_now' ],
79 'permission_callback' => [ $this, 'permissions_check' ],
80 ],
81 ] );
82
83 register_rest_route( $this->namespace, '/' . $this->rest_base . '/resendable-types', [
84 [
85 'methods' => WP_REST_Server::READABLE,
86 'callback' => [ $this, 'get_resendable_types' ],
87 'permission_callback' => [ $this, 'permissions_check' ],
88 ],
89 ] );
90
91 register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/resend', [
92 'args' => [
93 'id' => [
94 'description' => __( 'Email log entry ID.', 'storeengine' ),
95 'type' => 'integer',
96 ],
97 ],
98 [
99 'methods' => WP_REST_Server::CREATABLE,
100 'callback' => [ $this, 'resend_item' ],
101 'permission_callback' => [ $this, 'permissions_check' ],
102 ],
103 ] );
104
105 register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', [
106 'args' => [
107 'id' => [
108 'description' => __( 'Email log entry ID.', 'storeengine' ),
109 'type' => 'integer',
110 ],
111 ],
112 [
113 'methods' => WP_REST_Server::READABLE,
114 'callback' => [ $this, 'get_item' ],
115 'permission_callback' => [ $this, 'permissions_check' ],
116 ],
117 [
118 'methods' => WP_REST_Server::DELETABLE,
119 'callback' => [ $this, 'delete_item' ],
120 'permission_callback' => [ $this, 'permissions_check' ],
121 ],
122 ] );
123 }
124
125 public function get_items( $request ) {
126 $args = [
127 'page' => max( 1, (int) $request->get_param( 'page' ) ),
128 'per_page' => max( 1, (int) $request->get_param( 'per_page' ) ),
129 'orderby' => 'sent_at_gmt',
130 'order' => 'DESC',
131 'where' => [],
132 ];
133
134 if ( $email_type = $request->get_param( 'email_type' ) ) {
135 $args['where'][] = [ 'key' => 'email_type', 'value' => sanitize_text_field( $email_type ) ];
136 }
137
138 if ( $status = $request->get_param( 'status' ) ) {
139 $args['where'][] = [ 'key' => 'status', 'value' => sanitize_text_field( $status ) ];
140 }
141
142 if ( $customer_id = (int) $request->get_param( 'customer_id' ) ) {
143 $args['where'][] = [ 'key' => 'customer_id', 'value' => $customer_id ];
144 }
145
146 if ( $order_id = (int) $request->get_param( 'order_id' ) ) {
147 $args['where'][] = [ 'key' => 'order_id', 'value' => $order_id ];
148 }
149
150 if ( $related_entity_type = $request->get_param( 'related_entity_type' ) ) {
151 $args['where'][] = [ 'key' => 'related_entity_type', 'value' => sanitize_text_field( $related_entity_type ) ];
152 }
153
154 if ( $related_entity_id = (int) $request->get_param( 'related_entity_id' ) ) {
155 $args['where'][] = [ 'key' => 'related_entity_id', 'value' => $related_entity_id ];
156 }
157
158 if ( $recipient = $request->get_param( 'recipient' ) ) {
159 // LIKE so admins can search a partial recipient — e.g. domain only.
160 $args['where'][] = [
161 'key' => 'recipient',
162 'value' => '%' . sanitize_text_field( $recipient ) . '%',
163 'compare' => 'LIKE',
164 ];
165 }
166
167 if ( $search = $request->get_param( 'search' ) ) {
168 $search = sanitize_text_field( $search );
169 $args['where'][] = [
170 'relation' => 'OR',
171 [ 'key' => 'subject', 'value' => '%' . $search . '%', 'compare' => 'LIKE' ],
172 [ 'key' => 'recipient', 'value' => '%' . $search . '%', 'compare' => 'LIKE' ],
173 ];
174 }
175
176 if ( $date_from = $request->get_param( 'date_from' ) ) {
177 $args['where'][] = [ 'key' => 'sent_at_gmt', 'value' => sanitize_text_field( $date_from ), 'compare' => '>=' ];
178 }
179
180 if ( $date_to = $request->get_param( 'date_to' ) ) {
181 $args['where'][] = [ 'key' => 'sent_at_gmt', 'value' => sanitize_text_field( $date_to ), 'compare' => '<=' ];
182 }
183
184 $collection = new EmailLogCollection( $args );
185
186 $data = [];
187 foreach ( $collection->get_results() as $row ) {
188 $data[] = $this->format_row( $row );
189 }
190
191 $response = rest_ensure_response( $data );
192 $response->header( 'X-WP-Total', (string) $collection->get_found_results() );
193 $response->header( 'X-WP-TotalPages', (string) $collection->get_max_num_pages() );
194
195 return $response;
196 }
197
198 public function get_item( $request ) {
199 $id = (int) $request->get_param( 'id' );
200
201 try {
202 $row = new EmailLogEntity( $id );
203 } catch ( \Throwable $e ) {
204 return new WP_Error( 'storeengine_email_log_not_found', __( 'Email log entry not found.', 'storeengine' ), [ 'status' => 404 ] );
205 }
206
207 if ( ! $row->get_id() ) {
208 return new WP_Error( 'storeengine_email_log_not_found', __( 'Email log entry not found.', 'storeengine' ), [ 'status' => 404 ] );
209 }
210
211 return rest_ensure_response( $this->format_row( $row ) );
212 }
213
214 public function get_settings( $request ) {
215 return rest_ensure_response( EmailLogCleanup::get_settings() );
216 }
217
218 public function save_settings( $request ) {
219 $settings = [
220 'retention_days_sent' => max( 1, (int) $request->get_param( 'retention_days_sent' ) ),
221 'retention_days_failed' => max( 1, (int) $request->get_param( 'retention_days_failed' ) ),
222 ];
223
224 update_option( 'storeengine_email_log_settings', $settings );
225
226 return rest_ensure_response( [
227 'message' => __( 'Email log settings saved.', 'storeengine' ),
228 'settings' => $settings,
229 ] );
230 }
231
232 public function purge_now( $request ) {
233 EmailLogCleanup::execute_cleanup();
234
235 return rest_ensure_response( [ 'purged' => true ] );
236 }
237
238 public function get_resendable_types( $request ) {
239 return rest_ensure_response( [ 'types' => ResendRegistry::init()->get_resendable_types() ] );
240 }
241
242 public function resend_item( $request ) {
243 $id = (int) $request->get_param( 'id' );
244
245 try {
246 $row = new EmailLogEntity( $id );
247 } catch ( \Throwable $e ) {
248 return new WP_Error( 'storeengine_email_log_not_found', __( 'Email log entry not found.', 'storeengine' ), [ 'status' => 404 ] );
249 }
250
251 if ( ! $row->get_id() ) {
252 return new WP_Error( 'storeengine_email_log_not_found', __( 'Email log entry not found.', 'storeengine' ), [ 'status' => 404 ] );
253 }
254
255 if ( 'queued' === $row->get_status() ) {
256 // queued means a previous send is still in-flight (or got stuck). Resending
257 // here would create a confusing duplicate row.
258 return new WP_Error( 'storeengine_email_log_still_queued', __( 'This email is still being processed. Wait for it to complete before resending.', 'storeengine' ), [ 'status' => 409 ] );
259 }
260
261 $handler = ResendRegistry::init()->get_handler( (string) $row->get_email_type() );
262 if ( ! $handler ) {
263 return new WP_Error( 'storeengine_email_log_not_resendable', __( 'This email type cannot be resent automatically.', 'storeengine' ), [ 'status' => 400 ] );
264 }
265
266 // Mark provenance on the next captured row so the UI can render a
267 // "resent from #N" indicator. We tag a transient bookmark; the
268 // universal logger merges it into the new row's payload on its next
269 // capture and then clears it.
270 $current_user = wp_get_current_user();
271 add_filter( 'storeengine/email_log/next_capture_payload', static function ( array $extra ) use ( $row, $current_user ) {
272 $extra['resent_from_log_id'] = $row->get_id();
273 $extra['resent_by_user_id'] = $current_user ? (int) $current_user->ID : 0;
274 $extra['resent_at_gmt'] = gmdate( 'Y-m-d H:i:s' );
275 return $extra;
276 } );
277
278 try {
279 $result = $handler( $row );
280 } catch ( \Throwable $e ) {
281 Helper::log_error( $e );
282
283 return new WP_Error( 'storeengine_email_log_resend_failed', $e->getMessage() ?: __( 'Resend failed unexpectedly.', 'storeengine' ), [ 'status' => 500 ] );
284 }
285
286 if ( is_wp_error( $result ) ) {
287 return $result;
288 }
289
290 // The handler dispatched via mail_send() → the universal logger captured
291 // a new row. Return the most recent one (descending by id) for the same
292 // email_type + recipient as the original — gives the UI a fresh entity
293 // to render without an extra round-trip.
294 global $wpdb;
295 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared lookup of the freshly-written log row on a custom StoreEngine table; not cacheable.
296 $new_id = (int) $wpdb->get_var(
297 $wpdb->prepare(
298 "SELECT id FROM {$wpdb->prefix}storeengine_email_log WHERE email_type = %s AND id > %d ORDER BY id DESC LIMIT 1",
299 $row->get_email_type(),
300 $row->get_id()
301 )
302 );
303
304 return rest_ensure_response( [
305 'resent' => true,
306 'original_id' => $row->get_id(),
307 'new_log_id' => $new_id ?: null,
308 ] );
309 }
310
311 public function delete_item( $request ) {
312 $id = (int) $request->get_param( 'id' );
313
314 try {
315 $row = new EmailLogEntity( $id );
316 } catch ( \Throwable $e ) {
317 return new WP_Error( 'storeengine_email_log_not_found', __( 'Email log entry not found.', 'storeengine' ), [ 'status' => 404 ] );
318 }
319
320 if ( ! $row->get_id() ) {
321 return new WP_Error( 'storeengine_email_log_not_found', __( 'Email log entry not found.', 'storeengine' ), [ 'status' => 404 ] );
322 }
323
324 $row->delete( true );
325
326 return rest_ensure_response( [ 'deleted' => true, 'id' => $id ] );
327 }
328
329 /**
330 * Shape an EmailLog entity into the REST response payload.
331 * Includes the decoded payload (not the raw JSON) so frontend doesn't
332 * have to double-decode.
333 */
334 protected function format_row( EmailLogEntity $row ): array {
335 return [
336 'id' => $row->get_id(),
337 'sent_at_gmt' => $row->get_sent_at_gmt(),
338 'email_type' => $row->get_email_type(),
339 'recipient' => $row->get_recipient(),
340 'subject' => $row->get_subject(),
341 'status' => $row->get_status(),
342 'customer_id' => $row->get_customer_id() ?: null,
343 'order_id' => $row->get_order_id() ?: null,
344 'related_entity_type' => $row->get_related_entity_type(),
345 'related_entity_id' => $row->get_related_entity_id() ?: null,
346 'error_message' => $row->get_error_message(),
347 'payload' => $row->get_meta_payload(),
348 ];
349 }
350
351 public function permissions_check( $request ) {
352 return Helper::check_rest_user_cap( 'manage_options' );
353 }
354
355 public function get_collection_params(): array {
356 return [
357 'page' => [ 'default' => 1, 'sanitize_callback' => 'absint' ],
358 'per_page' => [ 'default' => 20, 'sanitize_callback' => 'absint' ],
359 'email_type' => [ 'sanitize_callback' => 'sanitize_text_field' ],
360 'status' => [ 'sanitize_callback' => 'sanitize_text_field' ],
361 'customer_id' => [ 'sanitize_callback' => 'absint' ],
362 'order_id' => [ 'sanitize_callback' => 'absint' ],
363 'related_entity_type' => [ 'sanitize_callback' => 'sanitize_text_field' ],
364 'related_entity_id' => [ 'sanitize_callback' => 'absint' ],
365 'recipient' => [ 'sanitize_callback' => 'sanitize_text_field' ],
366 'search' => [ 'sanitize_callback' => 'sanitize_text_field' ],
367 'date_from' => [ 'sanitize_callback' => 'sanitize_text_field' ],
368 'date_to' => [ 'sanitize_callback' => 'sanitize_text_field' ],
369 ];
370 }
371 }
372