diff --git a/ghost/core/core/server/services/mega/post-email-serializer.js b/ghost/core/core/server/services/mega/post-email-serializer.js index 0740ba775f..2f6259d475 100644 --- a/ghost/core/core/server/services/mega/post-email-serializer.js +++ b/ghost/core/core/server/services/mega/post-email-serializer.js @@ -392,12 +392,17 @@ const PostEmailSerializer = { if (!options.isBrowserPreview && !options.isTestEmail && settingsCache.get('email_track_clicks')) { result.html = await linkReplacer.replace(result.html, async (url) => { // Add newsletter source attribution - url = memberAttribution.service.addEmailSourceAttributionTracking(url, newsletter); const isSite = urlUtils.isSiteUrl(url); if (isSite) { + // Add newsletter name as ref to the URL + url = memberAttribution.service.addEmailSourceAttributionTracking(url, newsletter); + // Only add post attribution to our own site (because external sites could/should not process this information) url = memberAttribution.service.addPostAttributionTracking(url, post); + } else { + // Add email source attribution without the newsletter name + url = memberAttribution.service.addEmailSourceAttributionTracking(url); } // Add link click tracking diff --git a/ghost/core/core/server/services/member-attribution/index.js b/ghost/core/core/server/services/member-attribution/index.js index 84c17e7fec..8a632132a1 100644 --- a/ghost/core/core/server/services/member-attribution/index.js +++ b/ghost/core/core/server/services/member-attribution/index.js @@ -40,7 +40,8 @@ class MemberAttributionServiceWrapper { Integration: models.Integration }, attributionBuilder: this.attributionBuilder, - getTrackingEnabled: () => !!settingsCache.get('members_track_sources') + getTrackingEnabled: () => !!settingsCache.get('members_track_sources'), + getSiteTitle: () => settingsCache.get('title') }); } } diff --git a/ghost/member-attribution/lib/service.js b/ghost/member-attribution/lib/service.js index 5306e84e16..fe5e1b4f05 100644 --- a/ghost/member-attribution/lib/service.js +++ b/ghost/member-attribution/lib/service.js @@ -1,4 +1,11 @@ const UrlHistory = require('./history'); +const {slugify} = require('@tryghost/string'); + +const blacklistedReferrerDomains = [ + // Facebook has some restrictions on the 'ref' attribute (max 15 chars + restricted character set) that breaks links if we add ?ref=longer-string + 'facebook.com', + 'www.facebook.com' +]; class MemberAttributionService { /** @@ -9,17 +16,23 @@ class MemberAttributionService { * @param {Object} deps.models.MemberCreatedEvent * @param {Object} deps.models.SubscriptionCreatedEvent * @param {() => boolean} deps.getTrackingEnabled + * @param {() => string} deps.getSiteTitle */ - constructor({attributionBuilder, models, getTrackingEnabled}) { + constructor({attributionBuilder, models, getTrackingEnabled, getSiteTitle}) { this.models = models; this.attributionBuilder = attributionBuilder; this._getTrackingEnabled = getTrackingEnabled; + this._getSiteTitle = getSiteTitle; } get isTrackingEnabled() { return this._getTrackingEnabled(); } + get siteTitle() { + return this._getSiteTitle(); + } + /** * * @param {Object} context instance of ghost framework context object @@ -85,14 +98,33 @@ class MemberAttributionService { * Add some parameters to a URL so that the frontend script can detect this and add the required records * in the URLHistory. * @param {URL} url instance that will get updated - * @param {Object} newsletter The newsletter from which a link was clicked + * @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source * @returns {URL} */ - addEmailSourceAttributionTracking(url, newsletter) { + addEmailSourceAttributionTracking(url, useNewsletter) { // Create a deep copy url = new URL(url); - // We slugify the name here so that we don't use the default slugs in fixtures - url.searchParams.append('ref', newsletter.get('name') + '-newsletter'); + + if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) { + // Don't overwrite + keep existing source attribution + return url; + } + + // Check blacklist domains + const referrerDomain = url.hostname; + if (blacklistedReferrerDomains.includes(referrerDomain)) { + return url; + } + + if (useNewsletter) { + const name = slugify(useNewsletter.get('name')); + + // If newsletter name ends with newsletter, don't add it again + const ref = name.endsWith('newsletter') ? name : `${name}-newsletter`; + url.searchParams.append('ref', ref); + } else { + url.searchParams.append('ref', slugify(this.siteTitle)); + } return url; } @@ -107,6 +139,11 @@ class MemberAttributionService { // Create a deep copy url = new URL(url); + if (url.searchParams.has('attribution_id') || url.searchParams.has('attribution_type')) { + // Don't overwrite + return url; + } + // Post attribution url.searchParams.append('attribution_id', post.id); url.searchParams.append('attribution_type', 'post'); diff --git a/ghost/member-attribution/package.json b/ghost/member-attribution/package.json index 67cd146cee..56f05053c2 100644 --- a/ghost/member-attribution/package.json +++ b/ghost/member-attribution/package.json @@ -22,6 +22,7 @@ "dependencies": { "@tryghost/domain-events": "0.0.0", "@tryghost/member-events": "0.0.0", - "@tryghost/referrers": "0.0.0" + "@tryghost/referrers": "0.0.0", + "@tryghost/string": "0.2.1" } } diff --git a/ghost/member-attribution/test/service.test.js b/ghost/member-attribution/test/service.test.js index 34ac0d8a83..158d078e48 100644 --- a/ghost/member-attribution/test/service.test.js +++ b/ghost/member-attribution/test/service.test.js @@ -10,6 +10,92 @@ describe('MemberAttributionService', function () { }); }); + describe('addEmailSourceAttributionTracking', function () { + it('uses sluggified sitename for external urls', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://example.com/'); + const updatedUrl = await service.addEmailSourceAttributionTracking(url); + + should(updatedUrl.toString()).equal('https://example.com/?ref=hello-world'); + }); + + it('uses sluggified newsletter name for internal urls', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://example.com/'); + const newsletterName = 'used newsletter name'; + const newsletter = { + get: (t) => { + if (t === 'name') { + return newsletterName; + } + } + }; + + const updatedUrl = await service.addEmailSourceAttributionTracking(url, newsletter); + + should(updatedUrl.toString()).equal('https://example.com/?ref=used-newsletter-name-newsletter'); + }); + + it('does not repeat newsletter at the end of the newsletter name', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://example.com/'); + const newsletterName = 'Weekly newsletter'; + const newsletter = { + get: (t) => { + if (t === 'name') { + return newsletterName; + } + } + }; + const updatedUrl = await service.addEmailSourceAttributionTracking(url, newsletter); + + should(updatedUrl.toString()).equal('https://example.com/?ref=weekly-newsletter'); + }); + + it('does not add ref to blacklisted domains', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://facebook.com/'); + const updatedUrl = await service.addEmailSourceAttributionTracking(url); + + should(updatedUrl.toString()).equal('https://facebook.com/'); + }); + + it('does not add ref if utm_source is present', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://example.com/?utm_source=hello'); + const updatedUrl = await service.addEmailSourceAttributionTracking(url); + should(updatedUrl.toString()).equal('https://example.com/?utm_source=hello'); + }); + + it('does not add ref if ref is present', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://example.com/?ref=hello'); + const updatedUrl = await service.addEmailSourceAttributionTracking(url); + should(updatedUrl.toString()).equal('https://example.com/?ref=hello'); + }); + + it('does not add ref if source is present', async function () { + const service = new MemberAttributionService({ + getSiteTitle: () => 'Hello world' + }); + const url = new URL('https://example.com/?source=hello'); + const updatedUrl = await service.addEmailSourceAttributionTracking(url); + should(updatedUrl.toString()).equal('https://example.com/?source=hello'); + }); + }); + describe('getAttributionFromContext', function () { it('returns null if no context is provided', async function () { const service = new MemberAttributionService({