WordPress Security Updates Email Alert Without a Plugin

WordPress security releases now get attacked within hours. After the recent core security release, attackers were probing sites the same day the patch shipped. If you look after client sites, or if you’ve hidden update notices from editors, you can easily miss a pending update for days. WordPress only emails you after it runs a background auto-update. It doesn’t email you when an update is waiting for you to install it.

This snippet emails the site admin when a core, plugin or theme update is pending. It lists each item with its installed and new versions and links to Dashboard → Updates. It sends one email per new set of updates, not one every day, so people keep reading it.

The snippet

Save this as wp-content/mu-plugins/dpc-update-alerts.php. Using an mu-plugin means nobody can deactivate it from the Plugins screen. It also works in a normal plugin or your theme’s functions.php.

<?php
/**
 * Plugin Name: DPC Update Alerts
 * Description: Emails the site admin once when core, plugin or theme updates are pending.
 */

defined( 'ABSPATH' ) || exit;

add_action( 'init', 'dpc_update_alert_schedule' );
function dpc_update_alert_schedule(): void {
	if ( ! wp_next_scheduled( 'dpc_update_alert_check' ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', 'dpc_update_alert_check' );
	}
}

add_action( 'dpc_update_alert_check', 'dpc_update_alert_run' );

/**
 * Collect pending updates from the transients WordPress already maintains.
 *
 * @return string[] One human-readable line per pending update.
 */
function dpc_update_alert_pending(): array {
	$items = array();

	$core = get_site_transient( 'update_core' );
	if ( isset( $core->updates ) && is_array( $core->updates ) ) {
		foreach ( $core->updates as $offer ) {
			if ( isset( $offer->response, $offer->current ) && 'upgrade' === $offer->response ) {
				$items[] = sprintf( 'WordPress core: %s -> %s', get_bloginfo( 'version' ), $offer->current );
				break;
			}
		}
	}

	$plugins = get_site_transient( 'update_plugins' );
	if ( ! empty( $plugins->response ) && is_array( $plugins->response ) ) {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$installed = get_plugins();
		foreach ( $plugins->response as $file => $data ) {
			$data    = (object) $data;
			$name    = $installed[ $file ]['Name'] ?? ( $data->slug ?? $file );
			$current = $installed[ $file ]['Version'] ?? '?';
			$items[] = sprintf( 'Plugin %s: %s -> %s', $name, $current, $data->new_version ?? '?' );
		}
	}

	$themes = get_site_transient( 'update_themes' );
	if ( ! empty( $themes->response ) && is_array( $themes->response ) ) {
		foreach ( $themes->response as $stylesheet => $data ) {
			$data    = (array) $data;
			$theme   = wp_get_theme( $stylesheet );
			$name    = $theme->exists() ? $theme->get( 'Name' ) : $stylesheet;
			$current = $theme->exists() ? $theme->get( 'Version' ) : '?';
			$items[] = sprintf( 'Theme %s: %s -> %s', $name, $current, $data['new_version'] ?? '?' );
		}
	}

	return array_map( 'wp_strip_all_tags', $items );
}

/**
 * Email the pending list, but only when it differs from the last email.
 *
 * @return bool True when an email was sent.
 */
function dpc_update_alert_run(): bool {
	$items = dpc_update_alert_pending();

	if ( ! $items ) {
		delete_option( 'dpc_update_alert_last' );
		return false;
	}

	$fingerprint = md5( implode( '|', $items ) );
	if ( get_option( 'dpc_update_alert_last' ) === $fingerprint ) {
		return false;
	}

	$recipients = (array) apply_filters( 'dpc_update_alert_recipients', array( get_option( 'admin_email' ) ) );
	$recipients = array_filter( array_map( 'sanitize_email', $recipients ), 'is_email' );
	if ( ! $recipients ) {
		return false;
	}

	$site    = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
	$subject = sprintf( '[%s] %d update(s) waiting', $site, count( $items ) );
	$body    = sprintf(
		'Pending updates on %1$s:%2$s%2$s- %3$s%2$s%2$sReview and install them: %4$s',
		home_url( '/' ),
		PHP_EOL,
		implode( PHP_EOL . '- ', $items ),
		admin_url( 'update-core.php' )
	);

	$sent = wp_mail( $recipients, $subject, $body );
	if ( $sent ) {
		update_option( 'dpc_update_alert_last', $fingerprint, false );
	}

	return $sent;
}

How it works

  • WordPress already checks WordPress.org for updates about twice a day. It stores the results in the update_core, update_plugins and update_themes site transients. The snippet only reads those transients, so it makes no extra HTTP requests.
  • dpc_update_alert_schedule() registers a twicedaily WP-Cron event, the same interval as the core check.
  • dpc_update_alert_pending() turns the transients into one line per item, such as Plugin Contact Form: 5.9.1 -> 5.9.2. It gets names and installed versions from get_plugins() and wp_get_theme(), and strips tags in case a header contains markup.
  • dpc_update_alert_run() hashes the list and saves the hash in a non-autoloaded option. It sends an email only when the hash changes. When everything is up to date, it deletes the option, so the next update triggers a new email.

Customise it

To send the alert to an agency inbox instead of, or as well as, the admin email, add this filter:

add_filter( 'dpc_update_alert_recipients', function ( array $to ): array {
	$to[] = 'alerts@example.com';
	return $to;
} );
  • For faster alerts, change twicedaily to hourly. The data only changes when WordPress runs its own update check.
  • To send a daily reminder until updates are installed, remove the fingerprint check.

Gotchas

  • WP-Cron only runs when someone visits the site. On low-traffic sites, set DISABLE_WP_CRON and call wp-cron.php from a real server cron job.
  • The update data doesn’t say which releases are security fixes. For official announcements, follow the Security category on WordPress.org News, which has its own RSS feed.
  • If a vulnerable plugin has no fix yet, deactivating it is usually safer than waiting. This alert tells you the fix has shipped.
  • If you used Disable WordPress core, plugins and themes updates to block update checks, the transients stay empty and this snippet never sends anything. If you only hid the admin notices, it still works.
  • In a normal plugin, clear the event on deactivation with wp_clear_scheduled_hook( 'dpc_update_alert_check' ).
  • For another admin email built the same way, see Notify Admin When New Account Is Created.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

Click to Copy