get_emails_by_id(); $deleted = 0; $kept = 0; foreach ( self::fetch_email_post_mappings() as $mapping ) { try { $option_name = (string) $mapping->option_name; $post_id = (int) $mapping->option_value; $email_type = self::email_type_from_option_name( $option_name ); // The SQL LIKE match is loose (each `_` is a single-char // wildcard); never touch options that only resemble the // mapping shape — they belong to someone else. if ( null === $email_type ) { continue; } if ( $post_id <= 0 ) { delete_option( $option_name ); continue; } $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) { // Orphaned mapping. delete_option( $option_name ); continue; } // A trashed copy no longer affects rendering; treat it as "revert to // default". The email editor UI never trashes woo_email posts (its // delete action permanently deletes via the // `woocommerce_email_editor_trash_modal_should_permanently_delete` // filter), so a trashed post slipped past that guard out-of-band. if ( 'trash' === $post->post_status ) { if ( wp_delete_post( $post->ID, true ) ) { delete_option( $option_name ); ++$deleted; } continue; } $email = $emails_by_id[ $email_type ] ?? null; if ( ! $email instanceof \WC_Email ) { // Cannot compute the canonical content (e.g. extension deactivated); // keep the post — it keeps rendering from the DB as before. update_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, $email_type ); ++$kept; continue; } if ( self::was_never_customized( $post, $email ) ) { // When the deletion fails (or a `pre_delete_post` filter // short-circuits it with a falsy value), keep the mapping — // otherwise the post would linger published while the email // falls back to the file template. if ( ! wp_delete_post( $post->ID, true ) ) { continue; } // `before_delete_post` also removes the mapping when the email // editor feature is active; delete the option defensively for // the case it isn't. delete_option( $option_name ); ++$deleted; } else { update_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, $email_type ); ++$kept; } } catch ( \Throwable $e ) { $logger->error( sprintf( 'Email posts cleanup failed for mapping %s: %s', (string) $mapping->option_name, $e->getMessage() ), array( 'option_name' => (string) $mapping->option_name, 'context' => 'email_posts_cleanup', ) ); continue; } } delete_transient( self::LEGACY_GENERATION_TRANSIENT ); $posts_manager->clear_caches(); $logger->info( sprintf( 'Email posts cleanup finished: %d never-customized post(s) deleted, %d customized post(s) kept.', $deleted, $kept ), array( 'context' => 'email_posts_cleanup' ) ); return false; } /** * Decide whether a stored email post was never customized by a merchant. * * True when any of the following independent signals holds: * - the content matches the canonical core render recomputed right now; * - the content still matches the sync source hash stamped at creation or * backfill time (untouched even though core moved on — deleting is * equivalent to what the auto-applier would do, just cheaper); * - no valid source hash exists (email not covered by the sync registry) * and the GMT creation/modification timestamps show no edit ever happened. * * The stored `_wc_email_template_status` meta is deliberately not trusted * on its own — it can be stale (sweeps only run after upgrades) or absent. * * @param \WP_Post $post The stored email post. * @param \WC_Email $email The registered email instance. * @return bool True when the post can safely fall back to the file template. */ private static function was_never_customized( \WP_Post $post, \WC_Email $email ): bool { $post_hash = sha1( (string) $post->post_content ); $canonical_hash = sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) ); if ( $post_hash === $canonical_hash ) { return true; } $stored_hash = get_post_meta( $post->ID, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ); if ( is_string( $stored_hash ) && 1 === preg_match( '/^[0-9a-f]{40}$/', $stored_hash ) ) { return $post_hash === $stored_hash; } // GMT columns only: the local pair is computed with the site offset at // write time, so a timezone change between creation and an edit can // make it match coincidentally — and any extra signal here widens a // hard-delete decision. return $post->post_date_gmt === $post->post_modified_gmt; } /** * Fetch all email type → post ID option mappings straight from the database. * * Bypasses the posts manager caches on purpose: this is a migration and * must observe the persisted state. * * @return \stdClass[] Rows with `option_name` and `option_value`. */ private static function fetch_email_post_mappings(): array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC", WCTransactionalEmailPostsManager::WC_OPTION_NAME ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return is_array( $rows ) ? $rows : array(); } /** * Derive the email type from a mapping option name. * * @param string $option_name Option name, e.g. `woocommerce_email_templates_customer_new_account_post_id`. * @return string|null The email type, e.g. `customer_new_account`, or null when * the name doesn't match the mapping shape exactly. */ private static function email_type_from_option_name( string $option_name ): ?string { if ( 1 !== preg_match( '/^woocommerce_email_templates_(.+)_post_id$/', $option_name, $matches ) ) { return null; } return $matches[1]; } }