Skip to content

Conversation

@betschki
Copy link
Contributor

@betschki betschki commented Nov 10, 2025

Allows filtering members by their email click through rate (CTR), calculated as the percentage of tracked emails that resulted in at least one click. Follows the same pattern as email open rate.

Got some code for us? Awesome 🎊!

Please take a minute to explain the change you're making:

Why are you making it?
Ghost currently provides an email open rate filter to identify highly engaged members, but lacks a comparable metric for click engagement. Click-through rate (CTR) is a critical engagement metric that shows which members actively interact with email content by clicking links, not just opening emails. This completes Ghost's email engagement analytics by adding the CTR metric alongside the existing open rate.

What does it do?
This adds a "Click rate (all time)" filter to the members list in Ghost Admin. The CTR is calculated as:

(emails clicked / emails sent with click tracking enabled) × 100

The calculation requires a minimum of 5 tracked emails before displaying a rate (same threshold as open rate), and updates automatically as new email analytics are processed.

Why is this something Ghost users or developers need?
This gives Ghost publishers better options to segment their audience and improve their reporting. Example use case: https://forum.ghost.org/t/memberlist-cleaning/60818/

Please check your PR against these items:

  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

We appreciate your contribution! 🙏


Note

Adds email_click_rate to members with DB/storage, analytics computation, API exposure, admin filter, and list ordering, plus tests and migrations.

  • Admin (UI):
    • Add new filter EMAIL_CLICK_RATE_FILTER (“Click rate (all time)”) in ghost/admin/app/components/members/filter.js and export via filters/index.js.
    • New filter definition in components/members/filters/email-click-rate.js (gated by settings.emailTrackClicks).
  • Backend (API/Model):
    • Expose email_click_rate in member serializer core/server/api/.../members.js.
    • Support ordering by email_click_rate in core/server/models/member.js (orderRawQuery).
    • Compute and persist click rate in core/server/services/email-analytics/lib/queries.js using click events and tracked-click emails.
  • Data/Schema:
    • Add email_click_rate column and index to members (migrations in core/server/data/migrations/versions/6.7/...) and schema in core/server/data/schema/schema.js.
  • Tests:
    • Update snapshots to include email_click_rate and content-length changes.
    • Add E2E tests for ordering by email_click_rate in core/test/e2e-api/admin/members.test.js.

Written by Cursor Bugbot for commit cd057d9. This will update automatically on new commits. Configure here.

Allows filtering members by their email click through rate (CTR), calculated as the percentage of tracked emails that resulted in at least one click. Follows the same pattern as email open rate.
@github-actions github-actions bot added the migration [pull request] Includes migration for review label Nov 10, 2025
@github-actions
Copy link
Contributor

It looks like this PR contains a migration 👀
Here's the checklist for reviewing migrations:

General requirements

  • ⚠️ Tested performance on staging database servers, as performance on local machines is not comparable to a production environment
  • Satisfies idempotency requirement (both up() and down())
  • Does not reference models
  • Filename is in the correct format (and correctly ordered)
  • Targets the next minor version
  • All code paths have appropriate log messages
  • Uses the correct utils
  • Contains a minimal changeset
  • Does not mix DDL/DML operations
  • Tested in MySQL and SQLite

Schema changes

  • Both schema change and related migration have been implemented
  • For index changes: has been performance tested for large tables
  • For new tables/columns: fields use the appropriate predefined field lengths
  • For new tables/columns: field names follow the appropriate conventions
  • Does not drop a non-alpha table outside of a major version

Data changes

  • Mass updates/inserts are batched appropriately
  • Does not loop over large tables/datasets
  • Defends against missing or invalid data
  • For settings updates: follows the appropriate guidelines

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 10, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds member email click rate support across the stack: new nullable unsigned integer column email_click_rate (with index) and migrations; analytics query changes to compute and conditionally update email_click_rate; serialization of email_click_rate into API member output; ordering support for email_click_rate in member model queries; admin UI additions (new filter component, exports, and inclusion in FILTER_GROUPS); and end-to-end tests verifying ordering by email_click_rate.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Areas requiring extra attention:

  • ghost/core/core/server/services/email-analytics/lib/queries.js — correctness of SQL queries, joins, counts, grouping, and conditional update logic for email_click_rate.
  • ghost/core/core/server/data/migrations/versions/6.7/* — migration correctness, nullable/index semantics, and rollback behavior.
  • ghost/core/core/server/data/schema/schema.js — schema entry for email_click_rate matches migration (type, unsigned, nullable, index).
  • ghost/core/core/server/models/member.js — ordering SQL generation for email_click_rate, NULL handling, and parity with email_open_rate.
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js — typedef and serialization inclusion of email_click_rate.
  • ghost/admin/app/components/members/* — new filter component, exports, and FILTER_GROUPS integration.
  • ghost/core/test/e2e-api/admin/members.test.js — test expectations and snapshot validity for ordering by email_click_rate.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding an email click rate filter for members in Ghost Admin.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the feature's purpose, implementation, calculation method, and user value.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f95a5a5 and cd057d9.

📒 Files selected for processing (1)
  • ghost/core/core/server/services/email-analytics/lib/queries.js (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
ghost/core/core/server/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend core logic should reside under ghost/core/core/server/

Files:

  • ghost/core/core/server/services/email-analytics/lib/queries.js
ghost/core/core/server/services/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend services should be implemented under ghost/core/core/server/services/

Files:

  • ghost/core/core/server/services/email-analytics/lib/queries.js
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Acceptance tests (Node 22.13.1, mysql8)
  • GitHub Check: Acceptance tests (Node 22.13.1, sqlite3)
  • GitHub Check: Legacy tests (Node 22.13.1, sqlite3)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Legacy tests (Node 22.13.1, mysql8)
  • GitHub Check: Lint
  • GitHub Check: Admin tests - Chrome
  • GitHub Check: Build & Push
🔇 Additional comments (3)
ghost/core/core/server/services/email-analytics/lib/queries.js (3)

189-194: LGTM! Denominator query follows established pattern.

The query for trackedEmailCountForClicks correctly mirrors the structure of the existing trackedEmailCount query, ensuring consistency in how tracked email counts are calculated across different metrics.


199-210: Click count query correctly scopes to received emails.

The query properly addresses previous concerns by joining through email_recipients to ensure clicks are only counted for emails the member actually received, and uses DISTINCT email_id to avoid double-counting.

The theoretical edge case (multiple email sends for the same post) has been acknowledged by the author as extremely rare in practice. The implementation is correct for the standard scenario and follows the schema's existing constraints where redirects are post-scoped rather than email-scoped.


221-223: LGTM! Click rate calculation handles edge cases correctly.

The calculation properly uses (emailClickedCount || 0) to prevent NaN when the count is undefined, and correctly applies the minimum threshold check before computing the rate. The implementation mirrors the existing email_open_rate pattern.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is being reviewed by Cursor Bugbot

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/admin/members.test.js (1)

769-825: Strengthen ordering assertions for email_click_rate (mirror open_rate tests)

Currently only checks field presence. Add ordering checks to validate sort behavior.

             .expect(({body}) => {
                 const {members} = body;
-                // Verify email_click_rate field exists in response
-                members.forEach((member) => {
-                    assert.ok(member.hasOwnProperty('email_click_rate'), 'Member should have email_click_rate field');
-                });
+                members.forEach((m) => assert.ok(m.hasOwnProperty('email_click_rate'), 'email_click_rate missing'));
+                assert.equal(members[0].email_click_rate > members[1].email_click_rate, true, 'Expected first to have greater click rate than second.');
             });
@@
             .expect(({body}) => {
                 const {members} = body;
-                // Verify email_click_rate field exists in response
-                members.forEach((member) => {
-                    assert.ok(member.hasOwnProperty('email_click_rate'), 'Member should have email_click_rate field');
-                });
+                members.forEach((m) => assert.ok(m.hasOwnProperty('email_click_rate'), 'email_click_rate missing'));
+                assert.equal(members[0].email_click_rate < members[1].email_click_rate, true, 'Expected first to have smaller click rate than second.');
             });

If fixtures don’t guarantee differing click rates, consider adjusting or seeding data for deterministic assertions (similar to open_rate test setup).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b337c4 and 8639b48.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (9)
  • ghost/admin/app/components/members/filter.js (2 hunks)
  • ghost/admin/app/components/members/filters/email-click-rate.js (1 hunks)
  • ghost/admin/app/components/members/filters/index.js (1 hunks)
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js (2 hunks)
  • ghost/core/core/server/data/migrations/versions/6.7/2025-11-10-16-16-16-add-members-email-click-rate.js (1 hunks)
  • ghost/core/core/server/data/schema/schema.js (1 hunks)
  • ghost/core/core/server/models/member.js (1 hunks)
  • ghost/core/core/server/services/email-analytics/lib/queries.js (2 hunks)
  • ghost/core/test/e2e-api/admin/members.test.js (1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
ghost/core/core/server/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend core logic should reside under ghost/core/core/server/

Files:

  • ghost/core/core/server/models/member.js
  • ghost/core/core/server/data/migrations/versions/6.7/2025-11-10-16-16-16-add-members-email-click-rate.js
  • ghost/core/core/server/services/email-analytics/lib/queries.js
  • ghost/core/core/server/data/schema/schema.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js
ghost/core/core/server/models/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend models should be implemented under ghost/core/core/server/models/

Files:

  • ghost/core/core/server/models/member.js
ghost/core/core/server/services/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend services should be implemented under ghost/core/core/server/services/

Files:

  • ghost/core/core/server/services/email-analytics/lib/queries.js
ghost/core/core/server/data/schema/**

📄 CodeRabbit inference engine (AGENTS.md)

Database schema changes must be placed under ghost/core/core/server/data/schema/

Files:

  • ghost/core/core/server/data/schema/schema.js
ghost/core/core/server/api/**

📄 CodeRabbit inference engine (AGENTS.md)

Backend API route handlers should be placed under ghost/core/core/server/api/

Files:

  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js
🧠 Learnings (4)
📚 Learning: 2025-10-15T07:53:49.814Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: apps/shade/AGENTS.md:0-0
Timestamp: 2025-10-15T07:53:49.814Z
Learning: Applies to apps/shade/src/index.ts : Export new UI components from src/index.ts

Applied to files:

  • ghost/admin/app/components/members/filters/index.js
📚 Learning: 2025-06-10T11:07:10.800Z
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 23770
File: apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModalLabs.tsx:435-450
Timestamp: 2025-06-10T11:07:10.800Z
Learning: In Ghost's newsletter customization features, when promoting a feature from alpha to beta status, the feature flag guards are updated to make the feature available under both the `emailCustomization` (beta) and `emailCustomizationAlpha` (alpha) flags. This is done by either removing conditional guards entirely when the component is already behind both flags, or by updating conditionals to check for either flag.

Applied to files:

  • ghost/admin/app/components/members/filter.js
📚 Learning: 2025-10-09T17:25:12.439Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-10-09T17:25:12.439Z
Learning: Applies to ghost/core/core/server/data/schema/** : Database schema changes must be placed under ghost/core/core/server/data/schema/

Applied to files:

  • ghost/core/core/server/data/schema/schema.js
📚 Learning: 2025-10-08T14:20:28.632Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-10-08T14:20:28.632Z
Learning: Applies to e2e/tests/**/*.ts : Test suite titles should follow: 'Ghost Admin - Feature' or 'Ghost Public - Feature'

Applied to files:

  • ghost/core/test/e2e-api/admin/members.test.js
🧬 Code graph analysis (2)
ghost/admin/app/components/members/filter.js (1)
ghost/admin/app/components/members/filters/email-click-rate.js (2)
  • EMAIL_CLICK_RATE_FILTER (3-9)
  • EMAIL_CLICK_RATE_FILTER (3-9)
ghost/core/test/e2e-api/admin/members.test.js (2)
ghost/core/test/e2e-api/admin/members-exporter.test.js (8)
  • agent (21-21)
  • member (11-16)
  • member (110-113)
  • member (127-130)
  • member (142-150)
  • member (165-169)
  • member (182-188)
  • member (201-204)
ghost/core/core/server/api/endpoints/members.js (4)
  • member (87-87)
  • member (124-124)
  • member (147-147)
  • member (172-172)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Legacy tests (Node 22.13.1, sqlite3)
  • GitHub Check: Legacy tests (Node 22.13.1, mysql8)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Acceptance tests (Node 22.13.1, sqlite3)
  • GitHub Check: Acceptance tests (Node 22.13.1, mysql8)
  • GitHub Check: Admin tests - Chrome
  • GitHub Check: Lint
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Build & Push
🔇 Additional comments (5)
ghost/core/core/server/models/member.js (1)

391-395: Ordering by email_click_rate mirrors open_rate correctly

Nulls last via IS NOT NULL DESC then value by direction. Looks good.

Please confirm direction is validated upstream to only ASC|DESC to avoid injection through orderByRaw. If not, normalize before usage:

- orderByRaw: `members.email_click_rate IS NOT NULL DESC, members.email_click_rate ${direction}`
+ const dir = String(direction).toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
+ orderByRaw: `members.email_click_rate IS NOT NULL DESC, members.email_click_rate ${dir}`
ghost/core/core/server/api/endpoints/utils/serializers/output/members.js (1)

178-179: Expose email_click_rate in API payload

Field addition and typedef look consistent with the data model.

Also applies to: 266-266

ghost/admin/app/components/members/filters/email-click-rate.js (1)

1-9: Setting key verified—no issues found

The 'emailTrackClicks' setting is correctly defined and consistently used throughout the codebase with perfect parity to 'emailTrackOpens'. Both filter components follow the same pattern, and the setting is already registered in the admin settings model.

ghost/admin/app/components/members/filter.js (2)

4-4: LGTM! Import follows existing pattern.

The EMAIL_CLICK_RATE_FILTER import is correctly added in alphabetical order and follows the established pattern for filter imports.


52-52: CODE CHANGES VERIFIED.

The emailTrackClicks setting name is correct and consistently used throughout the codebase. The EMAIL_CLICK_RATE_FILTER placement alongside EMAIL_OPEN_RATE_FILTER is logical and properly integrated—it will be correctly excluded when email tracking or the click tracking setting is disabled.

- Scoped CTR calculation to only count clicks from emails member received
- Added separate index migration for email_click_rate column
- Removed duplicate export in filters
- Updated test snapshots
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

migration [pull request] Includes migration for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant