Monthly Archive:August, 2023

WordPress User Activity Email

wordpress user activity email

If you happen to be a WordPress admin you’d like to get up to date information about user activity on your website. There are a few options. You can use a plugin such as Activity Log, or plenty others available at the plugin repository. However, if you use the Activity Log plugin, for instance, you have to log in and scroll through the log to find out what has happened. The other option is to get a WordPress user activity email at your convenience.

I wanted a daily email sent to my admin email address, listing the activity since the day before. Instead of using a WordPress plugin I decided to write some PHP code and do this on my own. The site I developed this for is a “private site” where only registered and logged in users have access to contents. It made me feel better to have full control over this functionality.

Let’s Get User Activity Data

The first thing we need is a function to capture the last login time for the current user and store this time as user meta data. I also decided to capture the user IP during login. We get to that function later. The update_last_user_login($user_login, $user) function is called using add_action() with the wp_login hook. Now every time a user logs in the according meta data user_ip_login gets updated.

function update_last_user_login($user_login, $user)
{
    update_user_meta($user->ID, 'last_login', time());
    update_user_meta($user->ID, 'user_ip_login', get_user_ip());
}
add_action('wp_login', 'update_last_user_login', 10, 2);

Next we need a function to log user activity. Some users stay logged in and the user login hook will not fire when a logged in user returns to the site. Conveniently, we use the wp_footer hook. It fires every time a page is loaded. Sneakingly we log the user IP as well.

function update_last_user_activity()
{
    if (is_user_logged_in()) {
        update_user_meta(get_current_user_id(), 'last_active', time());
        update_user_meta(get_current_user_id(), 'user_ip_active', get_user_ip());
    }
}
add_action('wp_footer', 'update_last_user_activity');

With these two actions we saved all the information we need for our WordPress user activity email. Of course, using user meta to save this information will not provide us with a history of user activity. That would require an additional database table to keep. I may work on that in the future.

Where is the user coming from?

As mentioned before, we also retrieve the user’s IP address and save it in a separate user meta field. To get the IP address we use the PHP $_SERVER super global which holds information about headers, paths, etc. in array form. Detailed information about the $_SERVER variable can be found here. The get_user_ip() function checks several different indices of the $_SERVER array because the usage of $_SERVER is not always consistent. It is known that these addresses can be either empty or even forged, so be aware. However, it’s still better than a sharp stick in the eye.

function get_user_ip()
{
    if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
        $ip = $_SERVER['HTTP_CLIENT_IP'];
    } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
    } else {
        $ip = $_SERVER['REMOTE_ADDR'];
    }
    return $ip;
}

Since I’m kind of a sleuth, I also want to know the geolocation of the IP address. get_geolocation($ip) uses the freely available http://ip-api.com/json/ API to get detailed information about where the IP is located and returns it in JSON format.

For instance, if we call the API with http://ip-api.com/json/142.250.138.139, which is Google’s IP address, we’ll get the following information:

{
    "status": "success",
    "country": "United States",
    "countryCode": "US",
    "region": "CA",
    "regionName": "California",
    "city": "Mountain View",
    "zip": "94043",
    "lat": 37.422,
    "lon": -122.084,
    "timezone": "America/Los_Angeles",
    "isp": "Google LLC",
    "org": "Google LLC",
    "as": "AS15169 Google LLC",
    "query": "142.250.138.139"
}

The get_geolocation() function uses the IP address stored as user meta and formats the return as a string to be used by the email function. The API return is a raw JSON string. Here we use the json_decode() function to convert the geolocation data into an associative array.

function get_geolocation($ip)
{
    $url = 'http://ip-api.com/json/' . $ip;
    $geolocation = json_decode(file_get_contents($url), true);
    $email_content = '';
    if ($geolocation['status'] == 'success') {
        $email_content .=
            $geolocation['query'] . '</br>'
            . $geolocation['countryCode'] . ' '
            . $geolocation['city'] . ', '
            . $geolocation['region'] . ' '
            . $geolocation['zip'] . ' - '
            . $geolocation['isp'];
    } else {
        $email_content .= 'No geolocation available: ' . $geolocation['message'] . '!';
    }
    return $email_content;
}

Let’s make an Activity Table

Now we have all the data we need to put together the results in a nicely formatted HTML table. The function get_activity_list() iterates through all users, in our case users with a certain role, and creates a table row for each login and each activity. I decided that I only want to see the activity for the last day. The ternary operator containing ($last_active >= strtotime("-1 day") singles these out.

function get_activity_list()
{
    $blogusers = get_users(array('role__in' => array('author', 'administrator')));
    $email_content = '';
    foreach ($blogusers as $user) {
        $last_login = get_user_meta($user->ID, 'last_login', true);
        $last_active = get_user_meta($user->ID, 'last_active', true);
        $login = (!empty($last_login) && ($last_login >= strtotime("-1 day"))) ? true : false;
        $active = (!empty($last_active) && ($last_active >= strtotime("-1 day"))) ? true : false;
        if ($login || $active) {
            $email_content .= '<tr><td rowspan="2" class="name-row">' . $user->display_name . '</td>';
            if ($login) {
                $user_ip_login = get_user_meta($user->ID, 'user_ip_login', true);
                $email_content .= '<td>login</td><td>' . date('M j, Y, g:i a', $last_login) . '</td><td class="small-font">' . get_geolocation($user_ip_login) . '</td></tr>';
            } else if ($active) {
                $email_content .= '<td>no login</td><td></td><td></td></tr>';
            }
            if ($active) {
                $user_ip_active = get_user_meta($user->ID, 'user_ip_active', true);
                $email_content .= '<td>active</td><td>' . date('M j, Y, g:i a', $last_active) . '</td><td class="small-font">' . get_geolocation($user_ip_active) . '</td></tr>';
            } else if ($login) {
                $email_content .= '<td>no activity</td><td></td><td></td></tr>';
            }
        }
    }
    return $email_content;
}

The HTML User Activity Email

Last but not least we need to assemble an email and send it. As mentioned before we want to send an HTML email because we want the data formatted as an HTML table. The function send_user_activity_email() puts all this together. First, the email headers need to be set to ensure a smooth HTML transmission. If you’re interested, or if you want to ruin your day, details about email headers can be found in RFC 2045.

The email content is a HTML document. But unfortunately most email hosts and clients don’t understand all HTML tags. The safest way to format an HTML email is to use tables. We start with the HTML <head> containing the styling for our table. Then we begin the <table> including a table header. Those are the constant parts of the email.

For the dynamic part of the table we add the activity using our function get_activity_list(). Before we call the wp_mail() function we close the email contents with </table></body></html>.

function send_user_activity_email()
{
    $subject = 'Oakbrooke User Activity';
    $to = $from = get_bloginfo('admin_email');

    $headers = 'MIME-Version: 1.0' . "\r\n" .
    'Content-Type: text/html; charset=ISO-8859-1' . "\r\n" .
    'From: ' . $from . "\r\n" .
    'Reply-To: ' . $from . "\r\n" .
    'X-Mailer: PHP/' . phpversion() . "\r\n";

    $email_content = '
        <!DOCTYPE html>
        <html>
        <head>
        <style>
        table {
            font-family: arial, sans-serif;
            border-collapse: collapse;
            width: 100%;
        }

        h2 {
            font-family: arial, sans-serif;
        }

        td, th {
            border: 1px solid #dddddd;
            text-align: left;
            padding: 8px;
        }

        .small-font {
            font-size: small;
        }

        .name-row {
            background: #eee;
            border-top: 1px solid #666;
            border-bottom: 1px solid #666;
        }

        tr:nth-child(even) {
            background-color: #dddddd;
        }
        </style>
        </head>
        <body>
        <h2>Oakbrooke Activity Log</h2>
        <table>
        <tr>
            <th>Name</th>
            <th>Activity</th>
            <th>Date/Time</th>
            <th>IP Address/GeoLocation</th>
        </tr>';

    $email_content .= get_activity_list();

    $email_content .= '
        </table>
        </body>
        </html>';

    if (EMAIL_TO_FILE == true) {
        $dir = WP_CONTENT_DIR . '/email.html';
        $tresult = (string) file_put_contents($dir, $email_content);
        test_log('Email to file on TESTSERVER result: ' . $tresult);
    } else {
        $result = wp_mail($to, $subject, $email_content, $headers);
        $tresult = $result ? 'TRUE' : 'FALSE';
        test_log($headers);
        test_log('wp_mail() was used on TESTSERVER! Result: ' . $tresult);
    }
}

To Cron or not to Cron

The activity summary should only go out once a day at a specific time. That’s sounds like something to do for a cron job.

WordPress has its own cron system for scheduling tasks. WP-Cron handles all WordPress cron jobs. While the name is borrowed from UNIX, WordPress cron doesn’t work the same way. WP-Cron works by checking on every page load a list of scheduled tasks to see what needs to be run. Any tasks due to run will be called during that page load. In other words, WordPress cron is executed when a page loads. This functionality did not meet my requirements and I decided to run a “real” cron job on the server. To do so we need a PHP file containing code to be executed by the cron job. In our case this file is called cron-job.php and the following shows its contents.

<?php
/**
 * the cron job is set up to run every 5 minutes on the server
 */

date_default_timezone_set("America/Chicago");

// set up WordPress environment
if (!defined('ABSPATH')) {
    require_once __DIR__ . '/../../../../wp-load.php';
}

// tell WordPress we are doing a cron task
define('DOING_CRON', true);

// send user activity every day @ 5:00 PM
if ((int) date('H') == 17 && (int) date('i') < 5) {
    require_once 'user-activity-email.php';
    send_user_activity_email();
}

We need to call the PHP function date_default_timezone_set() first if we are executing time based tasks during a cron job. Otherwise our clock will be off. In my case I’m in the central time zone so we use “America/Chicago”.

There are a few additional intricacies we need to pay attention to when doing server based cron jobs on a WordPress site. Reason being is that when the cron job executes the WordPress core environment is not loaded. However, we need the WordPress core functions to do our job. This why we have to require wp-load.php which is responsible for bootstrapping the WordPress environment which in turn enables the cron job to use the WordPress core functions.

Next we let WordPress know that we are doing a cron job. We define('DOING_CRON', true), a WP constant otherwise defined in wp-cron.php when WP-Cron is executing.

Lastly we call our send_user_activity_email() function. Our cron job runs every 5 minutes on the server because there are other things to do which are not listed in this post. The following if statement

if ((int) date('H') == 17 && (int) date('i') < 5)

ensures that send_user_activity_email() is only called at 5 P.M. aka 17:00 hours and only if minutes are less than 5. This way the email only goes out once and not 12 times during the hour.

cPanel-ing the Cron Job

Most web hosting companies use cPanel to let customers manage their back-office. Most cPanels contain all functionality needed to set up a cron job. If you are not familiar with cron here’s a link to a cPanel blog. If your host uses a different control panel the following may not apply.

A cron job definition to run every 5 minutes looks like this: */5 * * * * crontabguru offers a nice online calculator to make our lives easier. You may not need this if your cPanel has a dropdown selection for the timing. Sometimes they also have a selection of commonly used timings to select from.

To finish this we need to let the cron job know which file to open and which PHP version to use. The following string show how this looks in my case. Again, some of this may vary depending on your hosting company.

/usr/bin/php -q /home/username/public_html/wp-content/plugins/plugin_name/cron-job.php

The first path /usr/bin/php lets the cron job know where to find the PHP version. Some hosts require to name the explicit PHP version e.g. /usr/bin/php74 or something similar. The -q option tells the cron job to execute quietly. No logs are created. The final path lets the cron job know where to find our script on our server. This path may look different based on your host.

If you are writing a lot of code and you want to do versioning and keep a history you may want to take a look at my post about running SVN on a Synology NAS.

That’s it! I hope this helps to create your own activity log functionality or something completely unrelated. Questions and suggestions are always appreciated. Nobody is perfect!