class-wcmq-retention.php 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. <?php
  2. /**
  3. * Daily retention sweep.
  4. *
  5. * The queue table holds rendered customer emails and the attachment directories
  6. * hold their invoices — retention here is a privacy control, not only
  7. * housekeeping.
  8. *
  9. * @package studiou-wc-mail-queue
  10. * @copyright 2026 QUADARAX
  11. * @author Dalibor Votruba <dvotruba@quadarax.com>
  12. * @license GPL-2.0-or-later
  13. */
  14. defined( 'ABSPATH' ) || exit;
  15. /**
  16. * Class Studiou_WCMQ_Retention
  17. */
  18. class Studiou_WCMQ_Retention {
  19. const HOOK = 'studiou_wcmq_retention';
  20. const BATCH = 500;
  21. const MAX_LOOPS = 200;
  22. /**
  23. * Register hooks.
  24. */
  25. public static function init() {
  26. add_action( self::HOOK, array( __CLASS__, 'run' ) );
  27. }
  28. /**
  29. * Schedule the daily sweep, exactly once.
  30. */
  31. public static function schedule() {
  32. if ( ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
  33. return;
  34. }
  35. if ( as_next_scheduled_action( self::HOOK, array(), Studiou_WCMQ_Queue::GROUP ) ) {
  36. return;
  37. }
  38. as_schedule_recurring_action(
  39. time() + DAY_IN_SECONDS,
  40. DAY_IN_SECONDS,
  41. self::HOOK,
  42. array(),
  43. Studiou_WCMQ_Queue::GROUP,
  44. true
  45. );
  46. }
  47. /**
  48. * Sweep old rows, log entries and orphaned attachment directories.
  49. */
  50. public static function run() {
  51. Studiou_WCMQ_Reaper::run();
  52. $now = time();
  53. // Every retention setting is in DAYS; every timestamp column is in
  54. // SECONDS. Written as `sent_at < now - retention_sent_days`, a 7-day
  55. // retention would delete sent rows seven SECONDS after sending, silently,
  56. // while reporting success.
  57. $sent_days = (int) Studiou_WCMQ_Settings::get( 'retention_sent_days' );
  58. $failed_days = (int) Studiou_WCMQ_Settings::get( 'retention_failed_days' );
  59. $log_days = (int) Studiou_WCMQ_Settings::get( 'retention_log_days' );
  60. $deleted = 0;
  61. if ( $sent_days > 0 ) {
  62. $deleted += self::purge_queue_rows(
  63. Studiou_WCMQ_DB::STATE_SENT,
  64. 'sent_at',
  65. $now - ( $sent_days * DAY_IN_SECONDS )
  66. );
  67. }
  68. if ( $failed_days > 0 ) {
  69. // `created_at`, NOT `sent_at`. A failed row has sent_at IS NULL by
  70. // definition, so the obvious parallel to the rule above would match
  71. // nothing and failed rows would accumulate forever.
  72. $deleted += self::purge_queue_rows(
  73. Studiou_WCMQ_DB::STATE_FAILED,
  74. 'created_at',
  75. $now - ( $failed_days * DAY_IN_SECONDS )
  76. );
  77. }
  78. if ( $log_days > 0 ) {
  79. self::purge_log_rows( $now - ( $log_days * DAY_IN_SECONDS ) );
  80. }
  81. self::purge_orphan_attachment_dirs();
  82. if ( $deleted > 0 ) {
  83. Studiou_WCMQ_DB::log( 'info', sprintf( 'Retention sweep removed %d queue rows.', $deleted ) );
  84. }
  85. }
  86. /**
  87. * Delete queue rows in a state, older than a cutoff, in batches.
  88. *
  89. * Attachment directories are removed first: sent rows already had theirs
  90. * deleted at send, but failed rows keep theirs until now.
  91. *
  92. * @param string $state Row state.
  93. * @param string $time_column Timestamp column to compare.
  94. * @param int $cutoff Unix timestamp.
  95. * @return int Rows deleted.
  96. */
  97. private static function purge_queue_rows( $state, $time_column, $cutoff ) {
  98. global $wpdb;
  99. $table = Studiou_WCMQ_DB::queue_table();
  100. $total = 0;
  101. if ( ! in_array( $time_column, array( 'sent_at', 'created_at' ), true ) ) {
  102. return 0;
  103. }
  104. for ( $i = 0; $i < self::MAX_LOOPS; $i++ ) {
  105. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  106. $rows = $wpdb->get_results(
  107. $wpdb->prepare(
  108. "SELECT id, attachment_dir FROM {$table}
  109. WHERE state = %s AND {$time_column} IS NOT NULL AND {$time_column} < %d
  110. LIMIT %d",
  111. $state,
  112. (int) $cutoff,
  113. self::BATCH
  114. )
  115. );
  116. if ( ! is_array( $rows ) || empty( $rows ) ) {
  117. break;
  118. }
  119. $ids = array();
  120. foreach ( $rows as $row ) {
  121. Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir );
  122. $ids[] = (int) $row->id;
  123. }
  124. $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
  125. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  126. $n = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) );
  127. $total += (int) $n;
  128. if ( count( $ids ) < self::BATCH ) {
  129. break;
  130. }
  131. }
  132. return $total;
  133. }
  134. /**
  135. * Delete old log rows in batches.
  136. *
  137. * @param int $cutoff Unix timestamp.
  138. */
  139. private static function purge_log_rows( $cutoff ) {
  140. global $wpdb;
  141. $table = Studiou_WCMQ_DB::log_table();
  142. for ( $i = 0; $i < self::MAX_LOOPS; $i++ ) {
  143. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  144. $n = $wpdb->query(
  145. $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %d LIMIT %d", (int) $cutoff, self::BATCH )
  146. );
  147. if ( ! $n ) {
  148. break;
  149. }
  150. }
  151. }
  152. /**
  153. * Remove attachment directories whose token matches no surviving row.
  154. *
  155. * Collects directories orphaned by a fatal between mkdir and INSERT. Only
  156. * touches directories older than a day so an in-flight enqueue is never
  157. * raced.
  158. */
  159. private static function purge_orphan_attachment_dirs() {
  160. global $wpdb;
  161. $base = Studiou_WCMQ_Queue::attachments_basedir();
  162. if ( '' === $base || ! is_dir( $base ) ) {
  163. return;
  164. }
  165. $entries = @scandir( $base ); // phpcs:ignore
  166. if ( ! is_array( $entries ) ) {
  167. return;
  168. }
  169. $table = Studiou_WCMQ_DB::queue_table();
  170. $cutoff = time() - DAY_IN_SECONDS;
  171. foreach ( $entries as $entry ) {
  172. if ( ! preg_match( '/^[a-f0-9]{32}$/', $entry ) ) {
  173. continue;
  174. }
  175. $path = $base . '/' . $entry;
  176. if ( ! is_dir( $path ) ) {
  177. continue;
  178. }
  179. $mtime = @filemtime( $path ); // phpcs:ignore
  180. if ( $mtime && $mtime > $cutoff ) {
  181. continue;
  182. }
  183. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  184. $exists = (int) $wpdb->get_var(
  185. $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE attachment_dir = %s", $entry )
  186. );
  187. if ( $exists ) {
  188. continue;
  189. }
  190. Studiou_WCMQ_Queue::rmdir_recursive( $path );
  191. Studiou_WCMQ_DB::log( 'debug', sprintf( 'Removed orphaned attachment directory %s.', $entry ) );
  192. }
  193. }
  194. }