Skip to content

Commit 4f5c8e3

Browse files
author
Kilo
committed
Merge remote-tracking branch 'origin/main' into feature/issue-114-local-email-guard
# Conflicts: # languages/mail-system-bg_BG.mo # languages/mail-system-bg_BG.po # languages/mail-system-de_DE.mo # languages/mail-system-de_DE.po # languages/mail-system.pot
2 parents a8586c2 + e3fa7b9 commit 4f5c8e3

32 files changed

Lines changed: 1670 additions & 82 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Local email delivery guard (#114)** — outgoing email, SMTP connection tests, and queue processing are blocked when WordPress reports a `local` environment or the site uses a common local-development hostname. Administrators see a persistent warning on Mail System pages, and detection remains filterable for project-specific setups.
12+
- **Secure email click analytics (#113)**
13+
- Rewrites eligible campaign and one-time email links through HMAC-signed, recipient-specific redirect URLs
14+
- Records privacy-safe first/last click timestamps, repeat counts, inferred opens, unique clickers, CTR/CTOR, and per-link performance
15+
- Excludes unsubscribe/confirmation links, unsupported schemes, and `data-mskd-no-track` anchors
16+
- Prevents BCC attribution by disabling open and click tracking on any message copy carrying BCC
17+
- Adds schema version 1.8.0, uninstall/truncate cleanup, public redirect validation, and automated security/send-path coverage
18+
- **Per-recipient email open analytics (#111)**
19+
- Adds an unpredictable tracking token and invisible 1×1 pixel to newly queued campaign and one-time emails
20+
- Records the first open timestamp and total pixel load count without storing IP addresses or user-agent data
21+
- Shows unique opens, open rates, and per-recipient sent/open timestamps in the Queue overview and campaign detail screens
22+
- 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
23+
1024
### Fixed
1125
- **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`.
1226
- **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.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The plugin ships without a required build step and works out of the box without
2727
| New campaign | Compose a campaign from a template, select target lists, and queue or publish immediately. |
2828
| One-time email | Send a single ad-hoc email to a specific subscriber without creating a campaign. |
2929
| Sending queue | Emails are queued and dispatched by WP-Cron at a configurable rate (default: 10/minute). |
30+
| Email analytics | Review sent totals, unique opens, unique clickers, CTR/CTOR, repeat activity, and per-link performance. |
3031
| SMTP | Configure an external SMTP server with SSL or TLS for reliable delivery. |
3132
| Import / Export | Bulk import and export subscribers and lists in CSV or JSON format. |
3233
| Subscription shortcode | `[mskd_subscribe_form]` renders a signup form on any page or post. |
@@ -50,7 +51,7 @@ The plugin ships without a required build step and works out of the box without
5051
| Templates | `mskd-templates` | Save and manage reusable email templates. |
5152
| New campaign | `mskd-compose` | Compose and queue a newsletter campaign. |
5253
| One-time email | `mskd-one-time-email` | Send a single email to one subscriber. |
53-
| Queue | `mskd-queue` | Inspect pending and sent queue entries. |
54+
| Queue | `mskd-queue` | Inspect delivery status and per-campaign open/click analytics. |
5455
| Settings | `mskd-settings` | Configure SMTP, sending rate, and plugin options. |
5556
| Import / Export | `mskd-import-export` | Bulk import or export subscribers and lists (CSV or JSON). |
5657
| Shortcodes | `mskd-shortcodes` | Reference for available shortcodes and parameters. |
@@ -147,9 +148,22 @@ The plugin creates custom tables using the active WordPress table prefix.
147148
| `mskd_lists` | Mailing list definitions. |
148149
| `mskd_subscriber_list` | Many-to-many subscriber-to-list relationships. |
149150
| `mskd_queue` | Queued email jobs and delivery status. |
151+
| `mskd_clicks` | Per-recipient, per-link click aggregates with privacy-safe display URLs. |
150152

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

155+
### Engagement Analytics
156+
157+
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.
158+
159+
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.
160+
161+
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.
162+
163+
Click aggregates follow the queue's lifecycle: clearing all campaigns clears their click rows, and uninstalling the plugin drops the click analytics table.
164+
165+
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.
166+
153167
## Development
154168

155169
Install development dependencies:

admin/partials/queue-detail.php

Lines changed: 180 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@
4040
$where .= $wpdb->prepare( ' AND q.status = %s', $status_filter );
4141
}
4242

43+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only analytics filter.
44+
$engagement_filter = isset( $_GET['engagement'] ) ? sanitize_text_field( wp_unslash( $_GET['engagement'] ) ) : '';
45+
$opened_filter = 'opened' === $engagement_filter;
46+
$clicked_filter = 'clicked' === $engagement_filter;
47+
if ( $opened_filter ) {
48+
$where .= ' AND q.opened_at IS NOT NULL';
49+
}
50+
if ( $clicked_filter ) {
51+
$where .= " AND EXISTS (SELECT 1 FROM {$wpdb->prefix}mskd_clicks clicked_filter WHERE clicked_filter.queue_id = q.id)";
52+
}
53+
4354
// Get queue stats for this campaign.
4455
$queue_stats = $wpdb->get_row(
4556
$wpdb->prepare(
@@ -48,8 +59,10 @@
4859
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
4960
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
5061
SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) as sent,
51-
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
52-
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled
62+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
63+
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
64+
SUM(CASE WHEN opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened,
65+
SUM(open_count) as open_count
5366
FROM {$wpdb->prefix}mskd_queue
5467
WHERE campaign_id = %d",
5568
$campaign_id
@@ -62,6 +75,24 @@
6275
$sent_count = $queue_stats->sent ?? 0;
6376
$failed_count = $queue_stats->failed ?? 0;
6477
$cancelled_count = $queue_stats->cancelled ?? 0;
78+
$opened_count = $queue_stats->opened ?? 0;
79+
$open_count = $queue_stats->open_count ?? 0;
80+
$open_rate = $sent_count > 0 ? round( ( $opened_count / $sent_count ) * 100, 1 ) : 0;
81+
82+
// Get click totals separately so multiple clicked links cannot inflate recipient counts.
83+
$click_stats = $wpdb->get_row(
84+
$wpdb->prepare(
85+
"SELECT COUNT(DISTINCT queue_id) as unique_clickers, COALESCE(SUM(click_count), 0) as total_clicks
86+
FROM {$wpdb->prefix}mskd_clicks
87+
WHERE campaign_id = %d",
88+
$campaign_id
89+
)
90+
);
91+
92+
$unique_clickers = $click_stats->unique_clickers ?? 0;
93+
$total_clicks = $click_stats->total_clicks ?? 0;
94+
$click_rate = $sent_count > 0 ? round( ( $unique_clickers / $sent_count ) * 100, 1 ) : 0;
95+
$click_to_open = $opened_count > 0 ? round( ( $unique_clickers / $opened_count ) * 100, 1 ) : 0;
6596

6697
// Get total count for current filter.
6798
$total_items = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mskd_queue q" . $where );
@@ -70,16 +101,37 @@
70101
// Get queue items for this campaign.
71102
$queue_items = $wpdb->get_results(
72103
$wpdb->prepare(
73-
"SELECT q.*, s.email, s.first_name, s.last_name
104+
"SELECT q.*, s.email, s.first_name, s.last_name,
105+
COALESCE(clicks.total_clicks, 0) as total_clicks,
106+
clicks.first_clicked_at,
107+
clicks.last_clicked_at
74108
FROM {$wpdb->prefix}mskd_queue q
75-
LEFT JOIN {$wpdb->prefix}mskd_subscribers s ON q.subscriber_id = s.id"
109+
LEFT JOIN {$wpdb->prefix}mskd_subscribers s ON q.subscriber_id = s.id
110+
LEFT JOIN (
111+
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
112+
FROM {$wpdb->prefix}mskd_clicks
113+
GROUP BY queue_id
114+
) clicks ON clicks.queue_id = q.id"
76115
. $where .
77116
' ORDER BY q.id ASC LIMIT %d OFFSET %d',
78117
$per_page,
79118
$offset
80119
)
81120
);
82121

122+
// Aggregate each stable link position across campaign recipients.
123+
$link_stats = $wpdb->get_results(
124+
$wpdb->prepare(
125+
"SELECT link_index, MAX(display_url) as display_url, COUNT(DISTINCT queue_id) as unique_clickers,
126+
SUM(click_count) as total_clicks, MIN(first_clicked_at) as first_clicked_at, MAX(last_clicked_at) as last_clicked_at
127+
FROM {$wpdb->prefix}mskd_clicks
128+
WHERE campaign_id = %d
129+
GROUP BY link_index
130+
ORDER BY link_index ASC",
131+
$campaign_id
132+
)
133+
);
134+
83135
// Calculate progress.
84136
$completed = $sent_count + $failed_count + $cancelled_count;
85137
$progress_percent = $total_count > 0 ? round( ( $completed / $total_count ) * 100 ) : 0;
@@ -172,6 +224,35 @@
172224
<span class="mskd-stat-sent">
173225
✓ <strong><?php echo esc_html( $sent_count ); ?></strong> <?php _e( 'sent', 'mail-system' ); ?>
174226
</span>
227+
<span class="mskd-stat-opened">
228+
◉ <strong><?php echo esc_html( $opened_count ); ?></strong> <?php _e( 'opened', 'mail-system' ); ?>
229+
<small>
230+
(<?php echo esc_html( $open_rate ); ?>%;
231+
<?php
232+
printf(
233+
/* translators: %d: tracking pixel load count */
234+
esc_html__( 'Loads: %d', 'mail-system' ),
235+
$open_count
236+
);
237+
?>
238+
)
239+
</small>
240+
</span>
241+
<span class="mskd-stat-clicked">
242+
↗ <strong><?php echo esc_html( $unique_clickers ); ?></strong> <?php _e( 'clicked', 'mail-system' ); ?>
243+
<small>
244+
(<?php echo esc_html( $click_rate ); ?>% CTR;
245+
<?php echo esc_html( $click_to_open ); ?>% CTOR;
246+
<?php
247+
printf(
248+
/* translators: %d: total tracked link clicks */
249+
esc_html__( 'Total: %d', 'mail-system' ),
250+
$total_clicks
251+
);
252+
?>
253+
)
254+
</small>
255+
</span>
175256
<?php if ( $failed_count > 0 ) : ?>
176257
<span class="mskd-stat-failed">
177258
✗ <strong><?php echo esc_html( $failed_count ); ?></strong> <?php _e( 'failed', 'mail-system' ); ?>
@@ -190,6 +271,10 @@
190271
</div>
191272
</div>
192273

274+
<p class="description">
275+
<?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' ); ?>
276+
</p>
277+
193278
<?php if ( $can_cancel ) : ?>
194279
<div class="mskd-campaign-actions">
195280
<a href="
@@ -238,7 +323,7 @@ class="button button-secondary mskd-cancel-btn"
238323
<ul class="subsubsub">
239324
<li>
240325
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id ) ); ?>"
241-
class="<?php echo empty( $status_filter ) ? 'current' : ''; ?>">
326+
class="<?php echo empty( $status_filter ) && ! $opened_filter && ! $clicked_filter ? 'current' : ''; ?>">
242327
<?php _e( 'All', 'mail-system' ); ?>
243328
<span class="count">(<?php echo esc_html( $total_count ); ?>)</span>
244329
</a> |
@@ -252,11 +337,25 @@ class="<?php echo $status_filter === 'pending' ? 'current' : ''; ?>">
252337
</li>
253338
<li>
254339
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&status=sent' ) ); ?>"
255-
class="<?php echo $status_filter === 'sent' ? 'current' : ''; ?>">
340+
class="<?php echo 'sent' === $status_filter && ! $opened_filter && ! $clicked_filter ? 'current' : ''; ?>">
256341
<?php _e( 'Sent', 'mail-system' ); ?>
257342
<span class="count">(<?php echo esc_html( $sent_count ); ?>)</span>
258343
</a> |
259344
</li>
345+
<li>
346+
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&engagement=opened' ) ); ?>"
347+
class="<?php echo $opened_filter ? 'current' : ''; ?>">
348+
<?php _e( 'Opened', 'mail-system' ); ?>
349+
<span class="count">(<?php echo esc_html( $opened_count ); ?>)</span>
350+
</a> |
351+
</li>
352+
<li>
353+
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&engagement=clicked' ) ); ?>"
354+
class="<?php echo $clicked_filter ? 'current' : ''; ?>">
355+
<?php _e( 'Clicked', 'mail-system' ); ?>
356+
<span class="count">(<?php echo esc_html( $unique_clickers ); ?>)</span>
357+
</a> |
358+
</li>
260359
<li>
261360
<a href="<?php echo esc_url( admin_url( 'admin.php?page=mskd-queue&action=view&campaign_id=' . $campaign_id . '&status=failed' ) ); ?>"
262361
class="<?php echo $status_filter === 'failed' ? 'current' : ''; ?>">
@@ -273,6 +372,39 @@ class="<?php echo $status_filter === 'cancelled' ? 'current' : ''; ?>">
273372
</li>
274373
</ul>
275374

375+
<?php if ( ! empty( $link_stats ) ) : ?>
376+
<h2><?php esc_html_e( 'Link performance', 'mail-system' ); ?></h2>
377+
<table class="wp-list-table widefat fixed striped">
378+
<thead>
379+
<tr>
380+
<th scope="col" style="width: 70px;"><?php esc_html_e( 'Link', 'mail-system' ); ?></th>
381+
<th scope="col"><?php esc_html_e( 'Destination', 'mail-system' ); ?></th>
382+
<th scope="col" style="width: 130px;"><?php esc_html_e( 'Unique clickers', 'mail-system' ); ?></th>
383+
<th scope="col" style="width: 110px;"><?php esc_html_e( 'Total clicks', 'mail-system' ); ?></th>
384+
<th scope="col" style="width: 100px;"><?php esc_html_e( 'Click rate', 'mail-system' ); ?></th>
385+
<th scope="col" style="width: 150px;"><?php esc_html_e( 'First click', 'mail-system' ); ?></th>
386+
<th scope="col" style="width: 150px;"><?php esc_html_e( 'Last click', 'mail-system' ); ?></th>
387+
</tr>
388+
</thead>
389+
<tbody>
390+
<?php foreach ( $link_stats as $link ) : ?>
391+
<?php $link_rate = $sent_count > 0 ? round( ( $link->unique_clickers / $sent_count ) * 100, 1 ) : 0; ?>
392+
<tr>
393+
<td>#<?php echo esc_html( (int) $link->link_index ); ?></td>
394+
<td><code><?php echo esc_html( $link->display_url ); ?></code></td>
395+
<td><?php echo esc_html( (int) $link->unique_clickers ); ?></td>
396+
<td><?php echo esc_html( (int) $link->total_clicks ); ?></td>
397+
<td><?php echo esc_html( $link_rate ); ?>%</td>
398+
<td><?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $link->first_clicked_at ) ) ); ?></td>
399+
<td><?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $link->last_clicked_at ) ) ); ?></td>
400+
</tr>
401+
<?php endforeach; ?>
402+
</tbody>
403+
</table>
404+
<?php endif; ?>
405+
406+
<h2><?php esc_html_e( 'Recipients', 'mail-system' ); ?></h2>
407+
276408
<table class="wp-list-table widefat fixed striped">
277409
<thead>
278410
<tr>
@@ -281,6 +413,8 @@ class="<?php echo $status_filter === 'cancelled' ? 'current' : ''; ?>">
281413
<th scope="col" style="width: 100px;"><?php _e( 'Status', 'mail-system' ); ?></th>
282414
<th scope="col" style="width: 80px;"><?php _e( 'Attempts', 'mail-system' ); ?></th>
283415
<th scope="col" style="width: 140px;"><?php _e( 'Sent', 'mail-system' ); ?></th>
416+
<th scope="col" style="width: 160px;"><?php _e( 'Opened', 'mail-system' ); ?></th>
417+
<th scope="col" style="width: 180px;"><?php _e( 'Clicks', 'mail-system' ); ?></th>
284418
<th scope="col"><?php _e( 'Error', 'mail-system' ); ?></th>
285419
<th scope="col" style="width: 80px;"><?php _e( 'Actions', 'mail-system' ); ?></th>
286420
</tr>
@@ -335,6 +469,39 @@ class="<?php echo $status_filter === 'cancelled' ? 'current' : ''; ?>">
335469
<td>
336470
<?php echo $item->sent_at ? esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->sent_at ) ) ) : ''; ?>
337471
</td>
472+
<td>
473+
<?php if ( $item->opened_at ) : ?>
474+
<?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->opened_at ) ) ); ?>
475+
<br><small>
476+
<?php
477+
printf(
478+
/* translators: %d: tracking pixel load count */
479+
esc_html__( 'Loads: %d', 'mail-system' ),
480+
(int) $item->open_count
481+
);
482+
?>
483+
</small>
484+
<?php else : ?>
485+
486+
<?php endif; ?>
487+
</td>
488+
<td>
489+
<?php if ( $item->first_clicked_at ) : ?>
490+
<?php echo esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->first_clicked_at ) ) ); ?>
491+
<br><small>
492+
<?php
493+
printf(
494+
/* translators: 1: total clicks, 2: last click date */
495+
esc_html__( '%1$d total; last %2$s', 'mail-system' ),
496+
(int) $item->total_clicks,
497+
esc_html( date_i18n( 'd.m.Y H:i', strtotime( $item->last_clicked_at ) ) )
498+
);
499+
?>
500+
</small>
501+
<?php else : ?>
502+
503+
<?php endif; ?>
504+
</td>
338505
<td>
339506
<?php if ( $item->error_message ) : ?>
340507
<small class="mskd-error-msg"><?php echo esc_html( $item->error_message ); ?></small>
@@ -364,7 +531,7 @@ class="mskd-delete-link mskd-cancel-link"
364531
<?php endforeach; ?>
365532
<?php else : ?>
366533
<tr>
367-
<td colspan="7"><?php _e( 'No emails in this campaign.', 'mail-system' ); ?></td>
534+
<td colspan="9"><?php _e( 'No emails in this campaign.', 'mail-system' ); ?></td>
368535
</tr>
369536
<?php endif; ?>
370537
</tbody>
@@ -379,6 +546,12 @@ class="mskd-delete-link mskd-cancel-link"
379546
if ( $status_filter ) {
380547
$base_url .= '&status=' . $status_filter;
381548
}
549+
if ( $opened_filter ) {
550+
$base_url .= '&engagement=opened';
551+
}
552+
if ( $clicked_filter ) {
553+
$base_url .= '&engagement=clicked';
554+
}
382555
echo paginate_links(
383556
array(
384557
'base' => add_query_arg( 'paged', '%#%', $base_url ),

0 commit comments

Comments
 (0)