Skip to content

Commit 56a3ec2

Browse files
authored
🐛 Fixed attribution on links in email-only posts (#26899)
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
1 parent 0c65075 commit 56a3ec2

File tree

5 files changed

+93
-4
lines changed

5 files changed

+93
-4
lines changed

ghost/admin/app/components/member/subscription-detail-box.hbs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
Source&nbsp;&mdash;&nbsp;<span>{{@sub.attribution.referrerSource}}</span>
1515
</p>
1616
{{/if}}
17-
{{#if (and @sub.attribution @sub.attribution.url @sub.attribution.title)}}
17+
{{#if (and @sub.attribution @sub.attribution.title)}}
1818
<p>
19+
{{#if (and @sub.attribution.id (eq @sub.attribution.type "post"))}}
20+
Page&nbsp;&mdash;&nbsp;<a href="/ghost/#/posts/analytics/{{@sub.attribution.id}}">{{ @sub.attribution.title }}</a>
21+
{{else if @sub.attribution.url}}
1922
Page&nbsp;&mdash;&nbsp;<a href="{{@sub.attribution.url}}" target="_blank" rel="noopener noreferrer">{{ @sub.attribution.title }}</a>
23+
{{else}}
24+
Page&nbsp;&mdash;&nbsp;{{ @sub.attribution.title }}
25+
{{/if}}
2026
</p>
2127
{{/if}}
2228
</div>

ghost/core/core/server/services/member-attribution/attribution-builder.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class Attribution {
9696
};
9797
}
9898

99-
const updatedUrl = this.#urlTranslator.getUrlByResourceId(this.id, {absolute: true});
99+
const updatedUrl = this.#urlTranslator.getResourceUrl(this.id, this.type, model, {absolute: true});
100100

101101
return {
102102
id: model.id,

ghost/core/core/server/services/member-attribution/url-translator.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class UrlTranslator {
6464
return {
6565
type: item.type,
6666
id: item.id,
67-
url: this.getUrlByResourceId(item.id, {absolute: false})
67+
url: this.getResourceUrl(item.id, item.type, resource, {absolute: false})
6868
};
6969
}
7070

@@ -128,11 +128,24 @@ class UrlTranslator {
128128
return this.urlService.getUrlByResourceId(id, options);
129129
}
130130

131+
/**
132+
* Get the URL for a resource, handling email-only posts which have no
133+
* public URL (the URL service returns /404/ for them).
134+
*/
135+
getResourceUrl(id, type, model, {absolute = true} = {}) {
136+
const isEmailOnly = type === 'post' && model.get('status') === 'sent';
137+
if (isEmailOnly) {
138+
const emailPath = `/email/${model.get('uuid')}/`;
139+
return absolute ? this.relativeToAbsolute(emailPath) : emailPath;
140+
}
141+
return this.getUrlByResourceId(id, {absolute});
142+
}
143+
131144
async getResourceById(id, type) {
132145
switch (type) {
133146
case 'post':
134147
case 'page': {
135-
const post = await this.models.Post.findOne({id}, {require: false});
148+
const post = await this.models.Post.findOne({id}, {require: false, filter: 'type:[post,page]+status:[published,sent]'});
136149
if (!post) {
137150
return null;
138151
}

ghost/core/test/e2e-server/services/member-attribution.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,73 @@ describe('Member Attribution Service', function () {
185185
title: author.get('name')
186186
});
187187
});
188+
189+
it('resolves email-only posts via id and type', async function () {
190+
// Simulates the offer link flow: member-attribution.js extracts
191+
// attribution_id and attribution_type from the URL search params
192+
// and stores them as {id, type} entries in the URL history
193+
const id = fixtureManager.get('posts', 0).id;
194+
const post = await models.Post.where('id', id).fetch({require: true});
195+
196+
// Make the post email-only (must set email_only flag first,
197+
// otherwise the model rejects the 'sent' status)
198+
await models.Post.edit({posts_meta: {email_only: true}, status: 'published'}, {id});
199+
await models.Post.edit({status: 'sent'}, {id});
200+
201+
try {
202+
const attribution = await memberAttributionService.service.getAttribution([
203+
{
204+
id: post.id,
205+
type: 'post',
206+
time: Date.now()
207+
}
208+
]);
209+
210+
// Should resolve to the post, not fall through to homepage
211+
assertObjectMatches(attribution, {
212+
id: post.id,
213+
url: `/email/${post.get('uuid')}/`,
214+
type: 'post'
215+
});
216+
217+
const resource = await attribution.fetchResource();
218+
const expectedUrl = urlUtils.createUrl(`/email/${post.get('uuid')}/`, true);
219+
220+
assertObjectMatches(resource, {
221+
id: post.id,
222+
url: expectedUrl,
223+
type: 'post',
224+
title: post.get('title')
225+
});
226+
} finally {
227+
await models.Post.edit({posts_meta: {email_only: false}, status: 'published'}, {id});
228+
}
229+
});
230+
231+
it('does not resolve draft posts via id and type', async function () {
232+
const id = fixtureManager.get('posts', 0).id;
233+
const post = await models.Post.where('id', id).fetch({require: true});
234+
235+
await models.Post.edit({status: 'draft'}, {id});
236+
237+
try {
238+
const attribution = await memberAttributionService.service.getAttribution([
239+
{
240+
id: post.id,
241+
type: 'post',
242+
time: Date.now()
243+
}
244+
]);
245+
246+
// Draft posts should not resolve — falls through to null attribution
247+
assertObjectMatches(attribution, {
248+
id: null,
249+
type: null
250+
});
251+
} finally {
252+
await models.Post.edit({status: 'published'}, {id});
253+
}
254+
});
188255
});
189256

190257
describe('with subdirectory', function () {

ghost/core/test/unit/server/services/member-attribution/attribution.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ describe('AttributionBuilder', function () {
7171
getUrlByResourceId() {
7272
return 'https://absolute/dir/path';
7373
},
74+
getResourceUrl() {
75+
return 'https://absolute/dir/path';
76+
},
7477
relativeToAbsolute(path) {
7578
return 'https://absolute/dir' + path;
7679
},

0 commit comments

Comments
 (0)