Skip to content

🐛 Fixed attribution on links in email-only posts#26899

Merged
cmraible merged 8 commits intomainfrom
chris-onc-1569-post-attribution-on-an-offer-redemption-link-resolves-to-the
Mar 24, 2026
Merged

🐛 Fixed attribution on links in email-only posts#26899
cmraible merged 8 commits intomainfrom
chris-onc-1569-post-attribution-on-an-offer-redemption-link-resolves-to-the

Conversation

@cmraible
Copy link
Copy Markdown
Collaborator

@cmraible cmraible commented Mar 19, 2026

refs https://linear.app/ghost/issue/ONC-1569/post-attribution-for-links-in-emails-seems-to-be-broken-on-pro
closes https://linear.app/ghost/issue/ONC-1568/email-only-post-attribution-incorrectly-resolves-to-url-type-instead

Problem

Attribution for links in email-only posts does not correctly resolve to the post itself. For example, if a free member clicks a link in an email-only post they received, then upgrade to a paid membership within the same browser session, the newly created subscription should be attributed to the email only post. Currently though, the subscription would be attributed as a url and, depending on the link, the URL might be the /email/:uuid route of the post, or the Homepage.

Root cause

In /ghost/core/core/server/services/member-attribution/url-translator.js, Ghost had previously been filtering out email only posts, because the Post.findOne() query implicitly filters to only published posts, while an email-only post will have a status of sent.

Fix

Updated the query in the url-translator to filter for status:[published,sent] and type:[post,page]. With this change, the attribution correctly resolves to the post itself, rather than the generic url.

There is also a change to the attribution builder to support this: since email only post's don't have a URL registered in the URL service, the attribution_url was being set to null. This adds a conditional to check if the post is email only, and if so, it sets the attribution_url to /email/:uuid for the post.

Finally, it also updated the link on the members page to the post to point to the Post Analytics, rather than the post on the frontend. This change is made for regular published posts in addition to the sent posts for consistency.

Files changed

  • url-translator.js — Added fallback findOne query with status:sent for email-only posts; added /email/:uuid/ URL for sent posts
  • attribution-builder.js — Use /email/:uuid/ URL when resolving attribution for email-only posts at read time

Test plan

  • Send an email-only post containing an offer link
  • Redeem the offer via the email link
  • Verify the subscription attribution shows the post title, not "Homepage"
  • Verify the attribution URL is /email/:uuid/ (not /404/)
  • Repeat with a published+sent post and verify attribution works correctly
  • Verify page attribution still resolves correctly (not broken by the query change)
  • Verify draft post attribution still degrades gracefully to URL type

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR changes member attribution URL handling for sent posts: attribution-builder now computes updatedUrl using an absolute /email/:uuid/ path when this.type === 'post' and model.get('status') === 'sent'. The URL translator returns the same fixed email URL for sent posts and adds context: { internal: true } to models.Post.findOne used by getResourceById. A test was adjusted to expect unresolved-post resolution to remain returning a post model (id and title) in the “with subdirectory” variant. No exported API signatures were changed.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title mentions 'attribution on links in email-only posts' which directly aligns with the PR's core objective of fixing post attribution for email-only posts. However, it uses an emoji (🐛) and is somewhat informal, though it remains clear and specific about the main change.
Description check ✅ Passed The pull request description is directly related to the changeset, explaining the problem with email-only post attribution, the root cause, and the specific fixes applied to url-translator.js and attribution-builder.js.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chris-onc-1569-post-attribution-on-an-offer-redemption-link-resolves-to-the

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.

@cmraible cmraible changed the title 🐛 Fixed post attribution on offer redemption links 🐛 Fixed post attribution on links in email-only posts Mar 19, 2026
case 'post':
case 'page': {
const post = await this.models.Post.findOne({id}, {require: false});
const post = await this.models.Post.findOne({id}, {require: false, context: {internal: true}});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This fixes the missing attribution for the link on email-only posts. Without the context: {internal: true}, Post.findOne() includes a status: published filter by default, which excludes posts sent as email-only.

When resolving the attribution for a newly created subscription, it therefore does not find a post, and falls back to the Homepage.

Copy link
Copy Markdown
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.

🧹 Nitpick comments (1)
ghost/core/core/server/services/member-attribution/attribution-builder.js (1)

1-15: Consider updating the AttributionResource typedef.

The postStatus field is now returned by getResource() for post types, but it's not documented in the AttributionResource typedef. Adding it would improve documentation completeness and IDE support.

📝 Proposed typedef update
 /**
  * `@typedef` {object} AttributionResource
  * `@prop` {string|null} id
  * `@prop` {string|null} url (absolute URL)
  * `@prop` {'page'|'post'|'author'|'tag'|'url'|null} type
  * `@prop` {string|null} title
+ * `@prop` {string|undefined} postStatus
  * `@prop` {string|null} referrerSource
  * `@prop` {string|null} referrerMedium
  * `@prop` {string|null} referrerUrl
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/member-attribution/attribution-builder.js`
around lines 1 - 15, The AttributionResource typedef is missing the postStatus
property returned by getResource() for post types; update the typedef for
AttributionResource to include a postStatus field (string or a union of expected
statuses for posts, e.g., 'draft'|'published'|'scheduled'|null) so IDEs and docs
reflect the data returned by getResource() and to reference this new property in
any code handling post resources.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/member-attribution/attribution-builder.js`:
- Around line 1-15: The AttributionResource typedef is missing the postStatus
property returned by getResource() for post types; update the typedef for
AttributionResource to include a postStatus field (string or a union of expected
statuses for posts, e.g., 'draft'|'published'|'scheduled'|null) so IDEs and docs
reflect the data returned by getResource() and to reference this new property in
any code handling post resources.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0ad037c5-0858-48b1-8001-dc77846da471

📥 Commits

Reviewing files that changed from the base of the PR and between fd06558 and 760b2d6.

📒 Files selected for processing (4)
  • ghost/admin/app/components/member/subscription-detail-box.hbs
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js
  • ghost/core/core/server/services/member-attribution/attribution-builder.js
  • ghost/core/core/server/services/member-attribution/url-translator.js

Comment thread ghost/admin/app/components/member/subscription-detail-box.hbs Outdated
Comment thread ghost/core/core/server/services/member-attribution/attribution-builder.js Outdated
Comment thread ghost/core/core/server/api/endpoints/utils/serializers/output/members.js Outdated
@cmraible cmraible force-pushed the chris-onc-1569-post-attribution-on-an-offer-redemption-link-resolves-to-the branch 2 times, most recently from 58f5b3c to 50e5c2e Compare March 19, 2026 19:54
When a member clicked an offer link in an email, their subscription
attribution resolved to the Homepage instead of the originating post.
Two issues were fixed: Post.findOne in the URL translator used default
filters (type:post+status:published) which excluded email-only posts
(status:sent), now uses internal context. The URL service also returned
/404/ for email-only posts since they have no public route — attribution
now stores the /email/:uuid/ path instead, which is the correct
frontend route for viewing email-only posts.
@cmraible cmraible force-pushed the chris-onc-1569-post-attribution-on-an-offer-redemption-link-resolves-to-the branch from 50e5c2e to f5b0b8e Compare March 19, 2026 20:01
The test expected unpublished posts to not resolve during attribution
lookup. Since we now use internal context to find posts regardless of
status (to support email-only posts with status:sent), draft posts are
also found. This is correct — attribution should persist even when a
post is unpublished.
Copy link
Copy Markdown
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: 1

🧹 Nitpick comments (1)
ghost/core/test/e2e-server/services/member-attribution.test.js (1)

95-103: Guard fixture cleanup with try/finally.

If an assertion fails after Line 95, the restore at Line 103 is skipped and leaves shared fixture state mutated for later tests.

Suggested cleanup hardening
-                await models.Post.edit({status: 'draft'}, {id});
-
-                assertObjectMatches(await attribution.fetchResource(), {
-                    id: post.id,
-                    type: 'post',
-                    title: post.get('title')
-                });
-
-                await models.Post.edit({status: 'published'}, {id});
+                await models.Post.edit({status: 'draft'}, {id});
+                try {
+                    assertObjectMatches(await attribution.fetchResource(), {
+                        id: post.id,
+                        type: 'post',
+                        title: post.get('title')
+                    });
+                } finally {
+                    await models.Post.edit({status: 'published'}, {id});
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/e2e-server/services/member-attribution.test.js` around lines
95 - 103, Wrap the temporary status change and assertion in a try/finally so the
post is always restored: call models.Post.edit({status: 'draft'}, {id}) before
the try, perform the assertion using attribution.fetchResource() inside the try,
and in the finally call models.Post.edit({status: 'published'}, {id}) to
guarantee cleanup even if the assertion fails; reference the existing post/id
variables and the attribution.fetchResource() call when making this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/test/e2e-server/services/member-attribution.test.js`:
- Around line 93-100: The "with subdirectory" variant of the test has stale
expectations (expects URL fallback) but the non-subdirectory variant now asserts
that an internal draft post still resolves to a post resource; update the
subdirectory test's assertion to match the main case by using
attribution.fetchResource() expectation with id: post.id, type: 'post' and the
same title value (derived from post.get('title')) after calling
models.Post.edit({status: 'draft'}, {id}); ensure the assertion in the "with
subdirectory" block mirrors the main block's shape and values so both use the
internal post resolution path.

---

Nitpick comments:
In `@ghost/core/test/e2e-server/services/member-attribution.test.js`:
- Around line 95-103: Wrap the temporary status change and assertion in a
try/finally so the post is always restored: call models.Post.edit({status:
'draft'}, {id}) before the try, perform the assertion using
attribution.fetchResource() inside the try, and in the finally call
models.Post.edit({status: 'published'}, {id}) to guarantee cleanup even if the
assertion fails; reference the existing post/id variables and the
attribution.fetchResource() call when making this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 46420484-321c-4fb0-ac06-18a724dae412

📥 Commits

Reviewing files that changed from the base of the PR and between f5b0b8e and 7e92c2f.

📒 Files selected for processing (1)
  • ghost/core/test/e2e-server/services/member-attribution.test.js

Comment thread ghost/core/test/e2e-server/services/member-attribution.test.js Outdated
Instead of using context: {internal: true} which would also resolve
draft posts, use a specific filter status:[published,sent] to only
include published and email-only posts in attribution lookups. Reverted
test expectations back to original — draft posts should still not
resolve for attribution.
Using options.filter triggered NQL defaultFilters (type:post) which
filtered out pages. Instead, use the default findOne query (which finds
published posts and pages via simple WHERE clause) and fall back to a
second query with status:sent for email-only posts.
…us filter

Use type:[post,page]+status:[published,sent] to find published posts,
pages, and email-only posts in a single query, overriding the default
NQL filters for both type and status.
The attribution link in the member subscription detail now points to the
Post Analytics page for post-type attributions, which works for both
published and email-only posts.
@cmraible cmraible changed the title 🐛 Fixed post attribution on links in email-only posts 🐛 Fixed attribution on links in email-only posts Mar 19, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.15%. Comparing base (e8a9464) to head (38f71a5).
⚠️ Report is 12 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26899      +/-   ##
==========================================
+ Coverage   73.12%   73.15%   +0.02%     
==========================================
  Files        1537     1537              
  Lines      121397   121442      +45     
  Branches    14666    14681      +15     
==========================================
+ Hits        88775    88844      +69     
+ Misses      31593    31568      -25     
- Partials     1029     1030       +1     
Flag Coverage Δ
admin-tests 54.37% <ø> (+<0.01%) ⬆️
e2e-tests 73.15% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Tests the exact scenario from the offer link bug: email-only posts
(status:sent) are resolved via id/type history entries and use the
/email/:uuid/ URL. Draft posts are excluded from attribution.
@cmraible cmraible merged commit 56a3ec2 into main Mar 24, 2026
36 checks passed
@cmraible cmraible deleted the chris-onc-1569-post-attribution-on-an-offer-redemption-link-resolves-to-the branch March 24, 2026 02:00
cmraible added a commit that referenced this pull request Mar 25, 2026
refs
https://linear.app/ghost/issue/ONC-1569/post-attribution-for-links-in-emails-seems-to-be-broken-on-pro
closes
https://linear.app/ghost/issue/ONC-1568/email-only-post-attribution-incorrectly-resolves-to-url-type-instead

## Problem

Attribution for links in email-only posts does not correctly resolve to
the post itself. For example, if a free member clicks a link in an
email-only post they received, then upgrade to a paid membership within
the same browser session, the newly created subscription should be
attributed to the email only post. Currently though, the subscription
would be attributed as a `url` and, depending on the link, the URL might
be the `/email/:uuid` route of the post, or the Homepage.

## Root cause

In
`/ghost/core/core/server/services/member-attribution/url-translator.js`,
Ghost had previously been filtering out email only posts, because the
Post.findOne() query implicitly filters to only `published` posts, while
an email-only post will have a status of `sent`.

## Fix

Updated the query in the url-translator to filter for
`status:[published,sent]` and `type:[post,page]`. With this change, the
attribution correctly resolves to the `post` itself, rather than the
generic `url`.

There is also a change to the attribution builder to support this: since
email only post's don't have a URL registered in the URL service, the
`attribution_url` was being set to `null`. This adds a conditional to
check if the post is email only, and if so, it sets the
`attribution_url` to `/email/:uuid` for the post.

Finally, it also updated the link on the members page to the post to
point to the Post Analytics, rather than the post on the frontend. This
change is made for regular `published` posts in addition to the `sent`
posts for consistency.


### Files changed
- **`url-translator.js`** — Added fallback `findOne` query with
`status:sent` for email-only posts; added `/email/:uuid/` URL for sent
posts
- **`attribution-builder.js`** — Use `/email/:uuid/` URL when resolving
attribution for email-only posts at read time
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants