Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Secure email click analytics (#113)**
- Rewrites eligible campaign and one-time email links through HMAC-signed, recipient-specific redirect URLs
- Records privacy-safe first/last click timestamps, repeat counts, inferred opens, unique clickers, CTR/CTOR, and per-link performance
- Excludes unsubscribe/confirmation links, unsupported schemes, and `data-mskd-no-track` anchors
- Prevents BCC attribution by disabling open and click tracking on any message copy carrying BCC
- Adds schema version 1.8.0, uninstall/truncate cleanup, public redirect validation, and automated security/send-path coverage
- **Per-recipient email open analytics (#111)**
- Adds an unpredictable tracking token and invisible 1×1 pixel to newly queued campaign and one-time emails
- Records the first open timestamp and total pixel load count without storing IP addresses or user-agent data
- Shows unique opens, open rates, and per-recipient sent/open timestamps in the Queue overview and campaign detail screens
- Includes a database upgrade to schema version 1.7.0, unit coverage, and an in-product caveat explaining image blocking, privacy proxy, and prefetch limitations

### Fixed
- **Bulk actions Apply button not showing** — on the Subscribers page, if a browser restored the bulk actions dropdown's previous value on page load/refresh without firing a `change` event, the Apply button stayed hidden even though a bulk action was selected and subscribers were checked. The button's visibility is now synced on page load, not only on `change`.
- **Opt-in confirmation email wrappers** — confirmation emails now apply the configured email header and footer and replace wrapper template variables (`{first_name}`, `{last_name}`, `{email}`, `{unsubscribe_link}`, `{unsubscribe_url}`) before sending.
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The plugin ships without a required build step and works out of the box without
| New campaign | Compose a campaign from a template, select target lists, and queue or publish immediately. |
| One-time email | Send a single ad-hoc email to a specific subscriber without creating a campaign. |
| Sending queue | Emails are queued and dispatched by WP-Cron at a configurable rate (default: 10/minute). |
| Email analytics | Review sent totals, unique opens, unique clickers, CTR/CTOR, repeat activity, and per-link performance. |
| SMTP | Configure an external SMTP server with SSL or TLS for reliable delivery. |
| Import / Export | Bulk import and export subscribers and lists in CSV or JSON format. |
| Subscription shortcode | `[mskd_subscribe_form]` renders a signup form on any page or post. |
Expand All @@ -50,7 +51,7 @@ The plugin ships without a required build step and works out of the box without
| Templates | `mskd-templates` | Save and manage reusable email templates. |
| New campaign | `mskd-compose` | Compose and queue a newsletter campaign. |
| One-time email | `mskd-one-time-email` | Send a single email to one subscriber. |
| Queue | `mskd-queue` | Inspect pending and sent queue entries. |
| Queue | `mskd-queue` | Inspect delivery status and per-campaign open/click analytics. |
| Settings | `mskd-settings` | Configure SMTP, sending rate, and plugin options. |
| Import / Export | `mskd-import-export` | Bulk import or export subscribers and lists (CSV or JSON). |
| Shortcodes | `mskd-shortcodes` | Reference for available shortcodes and parameters. |
Expand Down Expand Up @@ -147,9 +148,22 @@ The plugin creates custom tables using the active WordPress table prefix.
| `mskd_lists` | Mailing list definitions. |
| `mskd_subscriber_list` | Many-to-many subscriber-to-list relationships. |
| `mskd_queue` | Queued email jobs and delivery status. |
| `mskd_clicks` | Per-recipient, per-link click aggregates with privacy-safe display URLs. |

The plugin stores settings in `mskd_settings` and database versioning in `mskd_db_version`.

### Engagement Analytics

Every newly queued recipient receives a unique, non-identifying tracking URL. When the recipient's email client loads the invisible image, the queue row records its first-open timestamp and increments its pixel-load count. The Queue screen reports unique opens and calculates open rate against successfully sent emails.

Open data is approximate. Email clients that block remote images can cause missed opens, while privacy proxies and image prefetching can load the pixel before a recipient reads the message. The plugin does not store IP addresses or user-agent strings for these events.

Eligible `http://` and `https://` links are routed through a recipient-specific, HMAC-signed redirect URL. A valid click records first/last timestamps and a repeat-click count, then redirects to the original destination. Clicks also infer an open when the tracking pixel was blocked. The Queue screen reports unique clickers, total clicks, CTR, CTOR, per-recipient activity, and per-link performance. Stored reporting labels retain only the destination origin (scheme, host, and port), and no IP address, user-agent, device, or location data is retained.

Click aggregates follow the queue's lifecycle: clearing all campaigns clears their click rows, and uninstalling the plugin drops the click analytics table.

Unsubscribe and confirmation links, non-web schemes, and anchors carrying `data-mskd-no-track` are never rewritten. Messages sent with BCC are intentionally left untracked because the To and BCC recipients share one message body; this prevents BCC activity from being attributed to the primary recipient. Click counts remain approximate because security scanners and email clients may prefetch tracked links before a person clicks them.

## Development

Install development dependencies:
Expand Down
187 changes: 180 additions & 7 deletions admin/partials/queue-detail.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@
$where .= $wpdb->prepare( ' AND q.status = %s', $status_filter );
}

// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only analytics filter.
$engagement_filter = isset( $_GET['engagement'] ) ? sanitize_text_field( wp_unslash( $_GET['engagement'] ) ) : '';
$opened_filter = 'opened' === $engagement_filter;
$clicked_filter = 'clicked' === $engagement_filter;
if ( $opened_filter ) {
$where .= ' AND q.opened_at IS NOT NULL';
}
if ( $clicked_filter ) {
$where .= " AND EXISTS (SELECT 1 FROM {$wpdb->prefix}mskd_clicks clicked_filter WHERE clicked_filter.queue_id = q.id)";
}

// Get queue stats for this campaign.
$queue_stats = $wpdb->get_row(
$wpdb->prepare(
Expand All @@ -48,8 +59,10 @@
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) as sent,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened,
SUM(open_count) as open_count
FROM {$wpdb->prefix}mskd_queue
WHERE campaign_id = %d",
$campaign_id
Expand All @@ -62,6 +75,24 @@
$sent_count = $queue_stats->sent ?? 0;
$failed_count = $queue_stats->failed ?? 0;
$cancelled_count = $queue_stats->cancelled ?? 0;
$opened_count = $queue_stats->opened ?? 0;
$open_count = $queue_stats->open_count ?? 0;
$open_rate = $sent_count > 0 ? round( ( $opened_count / $sent_count ) * 100, 1 ) : 0;

// Get click totals separately so multiple clicked links cannot inflate recipient counts.
$click_stats = $wpdb->get_row(
$wpdb->prepare(
"SELECT COUNT(DISTINCT queue_id) as unique_clickers, COALESCE(SUM(click_count), 0) as total_clicks
FROM {$wpdb->prefix}mskd_clicks
WHERE campaign_id = %d",
$campaign_id
)
);

$unique_clickers = $click_stats->unique_clickers ?? 0;
$total_clicks = $click_stats->total_clicks ?? 0;
$click_rate = $sent_count > 0 ? round( ( $unique_clickers / $sent_count ) * 100, 1 ) : 0;
$click_to_open = $opened_count > 0 ? round( ( $unique_clickers / $opened_count ) * 100, 1 ) : 0;

// Get total count for current filter.
$total_items = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mskd_queue q" . $where );
Expand All @@ -70,16 +101,37 @@
// Get queue items for this campaign.
$queue_items = $wpdb->get_results(
$wpdb->prepare(
"SELECT q.*, s.email, s.first_name, s.last_name
"SELECT q.*, s.email, s.first_name, s.last_name,
COALESCE(clicks.total_clicks, 0) as total_clicks,
clicks.first_clicked_at,
clicks.last_clicked_at
FROM {$wpdb->prefix}mskd_queue q
LEFT JOIN {$wpdb->prefix}mskd_subscribers s ON q.subscriber_id = s.id"
LEFT JOIN {$wpdb->prefix}mskd_subscribers s ON q.subscriber_id = s.id
LEFT JOIN (
SELECT queue_id, SUM(click_count) as total_clicks, MIN(first_clicked_at) as first_clicked_at, MAX(last_clicked_at) as last_clicked_at
FROM {$wpdb->prefix}mskd_clicks
GROUP BY queue_id
) clicks ON clicks.queue_id = q.id"
. $where .
' ORDER BY q.id ASC LIMIT %d OFFSET %d',
$per_page,
$offset
)
);

// Aggregate each stable link position across campaign recipients.
$link_stats = $wpdb->get_results(
$wpdb->prepare(
"SELECT link_index, MAX(display_url) as display_url, COUNT(DISTINCT queue_id) as unique_clickers,
SUM(click_count) as total_clicks, MIN(first_clicked_at) as first_clicked_at, MAX(last_clicked_at) as last_clicked_at
FROM {$wpdb->prefix}mskd_clicks
WHERE campaign_id = %d
GROUP BY link_index
ORDER BY link_index ASC",
$campaign_id
)
);

// Calculate progress.
$completed = $sent_count + $failed_count + $cancelled_count;
$progress_percent = $total_count > 0 ? round( ( $completed / $total_count ) * 100 ) : 0;
Expand Down Expand Up @@ -172,6 +224,35 @@
<span class="mskd-stat-sent">
✓ <strong><?php echo esc_html( $sent_count ); ?></strong> <?php _e( 'sent', 'mail-system' ); ?>
</span>
<span class="mskd-stat-opened">
◉ <strong><?php echo esc_html( $opened_count ); ?></strong> <?php _e( 'opened', 'mail-system' ); ?>
<small>
(<?php echo esc_html( $open_rate ); ?>%;
<?php
printf(
/* translators: %d: tracking pixel load count */
esc_html__( 'Loads: %d', 'mail-system' ),
$open_count
);
?>
)
</small>
</span>
<span class="mskd-stat-clicked">
↗ <strong><?php echo esc_html( $unique_clickers ); ?></strong> <?php _e( 'clicked', 'mail-system' ); ?>
<small>
(<?php echo esc_html( $click_rate ); ?>% CTR;
<?php echo esc_html( $click_to_open ); ?>% CTOR;
<?php
printf(
/* translators: %d: total tracked link clicks */
esc_html__( 'Total: %d', 'mail-system' ),
$total_clicks
);
?>
)
</small>
</span>
<?php if ( $failed_count > 0 ) : ?>
<span class="mskd-stat-failed">
✗ <strong><?php echo esc_html( $failed_count ); ?></strong> <?php _e( 'failed', 'mail-system' ); ?>
Expand All @@ -190,6 +271,10 @@
</div>
</div>

<p class="description">
<?php esc_html_e( 'Engagement analytics are approximate. Image privacy proxies can create opens, while security scanners and link prefetching can create clicks before a person interacts with the email. Messages carrying BCC are intentionally untracked to prevent incorrect recipient attribution.', 'mail-system' ); ?>
</p>

<?php if ( $can_cancel ) : ?>
<div class="mskd-campaign-actions">
<a href="
Expand Down Expand Up @@ -238,7 +323,7 @@ class="button button-secondary mskd-cancel-btn"
<ul class="subsubsub">
<li>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id ) ); ?>"
class="<?php echo empty( $status_filter ) ? 'current' : ''; ?>">
class="<?php echo empty( $status_filter ) && ! $opened_filter && ! $clicked_filter ? 'current' : ''; ?>">
<?php _e( 'All', 'mail-system' ); ?>
<span class="count">(<?php echo esc_html( $total_count ); ?>)</span>
</a> |
Expand All @@ -252,11 +337,25 @@ class="<?php echo $status_filter === 'pending' ? 'current' : ''; ?>">
</li>
<li>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&status=sent' ) ); ?>"
class="<?php echo $status_filter === 'sent' ? 'current' : ''; ?>">
class="<?php echo 'sent' === $status_filter && ! $opened_filter && ! $clicked_filter ? 'current' : ''; ?>">
<?php _e( 'Sent', 'mail-system' ); ?>
<span class="count">(<?php echo esc_html( $sent_count ); ?>)</span>
</a> |
</li>
<li>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&engagement=opened' ) ); ?>"
class="<?php echo $opened_filter ? 'current' : ''; ?>">
<?php _e( 'Opened', 'mail-system' ); ?>
<span class="count">(<?php echo esc_html( $opened_count ); ?>)</span>
</a> |
</li>
<li>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&engagement=clicked' ) ); ?>"
class="<?php echo $clicked_filter ? 'current' : ''; ?>">
<?php _e( 'Clicked', 'mail-system' ); ?>
<span class="count">(<?php echo esc_html( $unique_clickers ); ?>)</span>
</a> |
</li>
<li>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&status=failed' ) ); ?>"
class="<?php echo $status_filter === 'failed' ? 'current' : ''; ?>">
Expand All @@ -273,6 +372,39 @@ class="<?php echo $status_filter === 'cancelled' ? 'current' : ''; ?>">
</li>
</ul>

<?php if ( ! empty( $link_stats ) ) : ?>
<h2><?php esc_html_e( 'Link performance', 'mail-system' ); ?></h2>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th scope="col" style="width: 70px;"><?php esc_html_e( 'Link', 'mail-system' ); ?></th>
<th scope="col"><?php esc_html_e( 'Destination', 'mail-system' ); ?></th>
<th scope="col" style="width: 130px;"><?php esc_html_e( 'Unique clickers', 'mail-system' ); ?></th>
<th scope="col" style="width: 110px;"><?php esc_html_e( 'Total clicks', 'mail-system' ); ?></th>
<th scope="col" style="width: 100px;"><?php esc_html_e( 'Click rate', 'mail-system' ); ?></th>
<th scope="col" style="width: 150px;"><?php esc_html_e( 'First click', 'mail-system' ); ?></th>
<th scope="col" style="width: 150px;"><?php esc_html_e( 'Last click', 'mail-system' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $link_stats as $link ) : ?>
<?php $link_rate = $sent_count > 0 ? round( ( $link->unique_clickers / $sent_count ) * 100, 1 ) : 0; ?>
<tr>
<td>#<?php echo esc_html( (int) $link->link_index ); ?></td>
<td><code><?php echo esc_html( $link->display_url ); ?></code></td>
<td><?php echo esc_html( (int) $link->unique_clickers ); ?></td>
<td><?php echo esc_html( (int) $link->total_clicks ); ?></td>
<td><?php echo esc_html( $link_rate ); ?>%</td>
<td><?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $link->first_clicked_at ) ) ); ?></td>
<td><?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $link->last_clicked_at ) ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>

<h2><?php esc_html_e( 'Recipients', 'mail-system' ); ?></h2>

<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
Expand All @@ -281,6 +413,8 @@ class="<?php echo $status_filter === 'cancelled' ? 'current' : ''; ?>">
<th scope="col" style="width: 100px;"><?php _e( 'Status', 'mail-system' ); ?></th>
<th scope="col" style="width: 80px;"><?php _e( 'Attempts', 'mail-system' ); ?></th>
<th scope="col" style="width: 140px;"><?php _e( 'Sent', 'mail-system' ); ?></th>
<th scope="col" style="width: 160px;"><?php _e( 'Opened', 'mail-system' ); ?></th>
<th scope="col" style="width: 180px;"><?php _e( 'Clicks', 'mail-system' ); ?></th>
<th scope="col"><?php _e( 'Error', 'mail-system' ); ?></th>
<th scope="col" style="width: 80px;"><?php _e( 'Actions', 'mail-system' ); ?></th>
</tr>
Expand Down Expand Up @@ -335,6 +469,39 @@ class="<?php echo $status_filter === 'cancelled' ? 'current' : ''; ?>">
<td>
<?php echo $item->sent_at ? esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->sent_at ) ) ) : '—'; ?>
</td>
<td>
<?php if ( $item->opened_at ) : ?>
<?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->opened_at ) ) ); ?>
<br><small>
<?php
printf(
/* translators: %d: tracking pixel load count */
esc_html__( 'Loads: %d', 'mail-system' ),
(int) $item->open_count
);
?>
</small>
<?php else : ?>
<?php endif; ?>
</td>
<td>
<?php if ( $item->first_clicked_at ) : ?>
<?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->first_clicked_at ) ) ); ?>
<br><small>
<?php
printf(
/* translators: 1: total clicks, 2: last click date */
esc_html__( '%1$d total; last %2$s', 'mail-system' ),
(int) $item->total_clicks,
esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->last_clicked_at ) ) )
);
?>
</small>
<?php else : ?>
<?php endif; ?>
</td>
<td>
<?php if ( $item->error_message ) : ?>
<small class="mskd-error-msg"><?php echo esc_html( $item->error_message ); ?></small>
Expand Down Expand Up @@ -364,7 +531,7 @@ class="mskd-delete-link mskd-cancel-link"
<?php endforeach; ?>
<?php else : ?>
<tr>
<td colspan="7"><?php _e( 'No emails in this campaign.', 'mail-system' ); ?></td>
<td colspan="9"><?php _e( 'No emails in this campaign.', 'mail-system' ); ?></td>
</tr>
<?php endif; ?>
</tbody>
Expand All @@ -379,6 +546,12 @@ class="mskd-delete-link mskd-cancel-link"
if ( $status_filter ) {
$base_url .= '&status=' . $status_filter;
}
if ( $opened_filter ) {
$base_url .= '&engagement=opened';
}
if ( $clicked_filter ) {
$base_url .= '&engagement=clicked';
}
echo paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%', $base_url ),
Expand Down
Loading
Loading