Added outbound link tagging setting (#16146)

fixes https://github.com/TryGhost/Team/issues/2432
    
Adds outbound_link_tagging setting (enabled by default and behind
feature flag). If the feature flag is enabled, and the setting is
disabled, we won't add ?ref to links in emails.
    
This includes new E2E tests for email click tracking, which were also
extended to check outbound link tagging (for both MEGA and the new email
stability flow).

Also fixes a test fixture for the comments_enabled setting.
This commit is contained in:
Simon Backx 2023-01-20 13:41:36 +01:00 committed by GitHub
parent 8a95c62ff1
commit e879406659
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 473 additions and 55 deletions

View File

@ -57,7 +57,7 @@
<div>
<h4 class="gh-expandable-title">Member sources</h4>
<p class="gh-expandable-description">
Track which sources and posts are driving the most member growth
Track the traffic sources and posts that drive the most member growth
</p>
</div>
<div class="for-switch">
@ -74,5 +74,29 @@
</div>
</div>
</div>
{{#if (feature 'outboundLinkTagging')}}
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Outbound link tagging</h4>
<p class="gh-expandable-description">
Make it easier for other sites to track the traffic you send them in their analytics
</p>
</div>
<div class="for-switch">
<label class="switch" for="outbound-link-tagging" data-test-label="outbound-link-tagging">
<input
id="outbound-link-tagging"
type="checkbox"
checked={{this.settings.outboundLinkTagging}}
data-test-checkbox="outbound-link-tagging"
{{on "change" this.toggleOutboundLinkTagging}}
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
</div>
{{/if}}
</section>
</div>
</div>

View File

@ -29,4 +29,12 @@ export default class Analytics extends Component {
}
this.settings.membersTrackSources = !this.settings.membersTrackSources;
}
@action
toggleOutboundLinkTagging(event) {
if (event) {
event.preventDefault();
}
this.settings.outboundLinkTagging = !this.settings.outboundLinkTagging;
}
}

View File

@ -40,8 +40,6 @@ export default Model.extend(ValidationEngine, {
mailgunApiKey: attr('string'),
mailgunDomain: attr('string'),
mailgunBaseUrl: attr('string'),
emailTrackOpens: attr('boolean'),
emailTrackClicks: attr('boolean'),
portalButton: attr('boolean'),
portalName: attr('boolean'),
portalPlans: attr('json-string'),
@ -50,6 +48,13 @@ export default Model.extend(ValidationEngine, {
portalButtonIcon: attr('string'),
portalButtonSignupText: attr('string'),
sharedViews: attr('string'),
/**
* Analytics settings
*/
emailTrackOpens: attr('boolean'),
emailTrackClicks: attr('boolean'),
outboundLinkTagging: attr('boolean'),
membersTrackSources: attr('boolean'),
/**
* Members settings
*/
@ -59,7 +64,6 @@ export default Model.extend(ValidationEngine, {
membersSupportAddress: attr('string'),
membersMonthlyPriceId: attr('string'),
membersYearlyPriceId: attr('string'),
membersTrackSources: attr('boolean'),
stripeSecretKey: attr('string'),
stripePublishableKey: attr('string'),
stripePlans: attr('json-string'),

View File

@ -65,7 +65,7 @@ export default class FeatureService extends Service {
@feature('suppressionList') suppressionList;
@feature('emailStability') emailStability;
@feature('webmentions') webmentions;
@feature('externalAttribution') externalAttribution;
@feature('outboundLinkTagging') outboundLinkTagging;
_user = null;

View File

@ -248,7 +248,7 @@
</p>
</div>
<div class="for-switch">
<GhFeatureFlag @flag="externalAttribution" />
<GhFeatureFlag @flag="outboundLinkTagging" />
</div>
</div>
</div>

View File

@ -90,6 +90,9 @@ export default [
setting('email', 'email_track_clicks', 'true'),
setting('email', 'email_verification_required', 'false'),
// ANALYTICS
setting('email', 'outbound_link_tagging', 'true'),
// AMP
setting('amp', 'amp', 'false'),
setting('amp', 'amp_gtag_id', null),

View File

@ -1,5 +1,6 @@
import {authenticateSession} from 'ember-simple-auth/test-support';
import {click, find} from '@ember/test-helpers';
import {enableLabsFlag} from '../../helpers/labs-flag';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
@ -62,4 +63,20 @@ describe('Acceptance: Settings - Analytics', function () {
expect(this.server.db.settings.findBy({key: 'members_track_sources'}).value).to.equal(false);
});
it('can manage outbound link tagging', async function () {
enableLabsFlag(this.server, 'outboundLinkTagging');
this.server.db.settings.update({key: 'outbound_link_tagging'}, {value: 'true'});
await visit('/settings/analytics');
expect(find('[data-test-checkbox="outbound-link-tagging"]')).to.be.checked;
await click('[data-test-label="outbound-link-tagging"]');
expect(find('[data-test-checkbox="outbound-link-tagging"]')).to.not.be.checked;
await click('[data-test-button="save-analytics-settings"]');
expect(this.server.db.settings.findBy({key: 'outbound_link_tagging'}).value).to.equal(false);
});
});

View File

@ -59,7 +59,8 @@ const EDITABLE_SETTINGS = [
'editor_default_email_recipients',
'editor_default_email_recipients_filter',
'labs',
'comments_enabled'
'comments_enabled',
'outbound_link_tagging'
];
module.exports = {

View File

@ -0,0 +1,8 @@
const {addSetting} = require('../../utils');
module.exports = addSetting({
key: 'outbound_link_tagging',
value: 'true',
type: 'boolean',
group: 'analytics'
});

View File

@ -480,5 +480,15 @@
]]
}
}
},
"analytics": {
"outbound_link_tagging": {
"defaultValue": "true",
"validations": {
"isEmpty": false,
"isIn": [["true", "false"]]
},
"type": "boolean"
}
}
}

View File

@ -400,13 +400,13 @@ const PostEmailSerializer = {
if (isSite) {
// Add newsletter name as ref to the URL
url = memberAttribution.service.addEmailSourceAttributionTracking(url, newsletter);
url = memberAttribution.service.addOutboundLinkTagging(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);
url = memberAttribution.service.addOutboundLinkTagging(url);
}
// Add link click tracking

View File

@ -1,6 +1,7 @@
const urlService = require('../url');
const urlUtils = require('../../../shared/url-utils');
const settingsCache = require('../../../shared/settings-cache');
const labs = require('../../../shared/labs');
class MemberAttributionServiceWrapper {
init() {
@ -41,6 +42,7 @@ class MemberAttributionServiceWrapper {
},
attributionBuilder: this.attributionBuilder,
getTrackingEnabled: () => !!settingsCache.get('members_track_sources'),
getOutboundLinkTaggingEnabled: () => !labs.isSet('outboundLinkTagging') || !!settingsCache.get('outbound_link_tagging'),
getSiteTitle: () => settingsCache.get('title')
});
}

View File

@ -34,7 +34,7 @@ const ALPHA_FEATURES = [
'beforeAfterCard',
'lexicalEditor',
'webmentions',
'externalAttribution'
'outboundLinkTagging'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View File

@ -264,6 +264,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,
@ -614,6 +622,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,
@ -638,7 +654,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3522",
"content-length": "3608",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -912,6 +928,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,
@ -1209,6 +1233,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,
@ -1511,6 +1543,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,
@ -1808,6 +1848,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,
@ -2170,6 +2218,14 @@ Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "members_enabled",
"value": true,

View File

@ -7,7 +7,7 @@ const {stringMatching, anyEtag, anyUuid, anyContentLength, anyContentVersion} =
const models = require('../../../core/server/models');
const {anyErrorId} = matchers;
const CURRENT_SETTINGS_COUNT = 69;
const CURRENT_SETTINGS_COUNT = 71;
const settingsMatcher = {};

View File

@ -7,7 +7,7 @@ Object {
"accent_color": "#FF1A75",
"codeinjection_foot": null,
"codeinjection_head": null,
"comments_enabled": null,
"comments_enabled": "off",
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"facebook": "ghost",

View File

@ -941,7 +941,7 @@ Object {
"accent_color": "#FF1A75",
"codeinjection_foot": null,
"codeinjection_head": null,
"comments_enabled": null,
"comments_enabled": "off",
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"facebook": "ghost",
@ -1035,7 +1035,7 @@ Object {
"accent_color": "#FF1A75",
"codeinjection_foot": null,
"codeinjection_head": null,
"comments_enabled": null,
"comments_enabled": "off",
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"facebook": "ghost",

View File

@ -6,12 +6,16 @@ const sinon = require('sinon');
const assert = require('assert');
const MailgunClient = require('@tryghost/mailgun-client/lib/mailgun-client');
const jobManager = require('../../../../core/server/services/jobs/job-service');
let agent;
const _ = require('lodash');
const {MailgunEmailProvider} = require('@tryghost/email-service');
const mobileDocWithPaywall = '{"version":"0.3.1","markups":[],"atoms":[],"cards":[["paywall",{}]],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members content"]]]]}';
const configUtils = require('../../../utils/configUtils');
const {settingsCache} = require('../../../../core/server/services/settings-helpers');
const DomainEvents = require('@tryghost/domain-events');
let agent;
let stubbedSend;
let frontendAgent;
function sortBatches(a, b) {
const aId = a.get('provider_id');
@ -62,20 +66,68 @@ async function createPublishedPostEmail(settings = {}, email_recipient_filter) {
return emailModel;
}
async function sendEmail(settings, email_recipient_filter) {
// Prepare a post and email model
const completedPromise = jobManager.awaitCompletion('batch-sending-service-job');
const emailModel = await createPublishedPostEmail(settings, email_recipient_filter);
// Await sending job
await completedPromise;
await emailModel.refresh();
assert.equal(emailModel.get('status'), 'submitted');
// Get the email that was sent
return {emailModel, ...(await getLastEmail())};
}
async function retryEmail(emailId) {
await agent.put(`emails/${emailId}/retry`)
.expectStatus(200);
}
/**
* Returns the last email that was sent via the stub, with all recipient variables replaced
*/
async function getLastEmail() {
// Get the email body
sinon.assert.calledOnce(stubbedSend);
const messageData = stubbedSend.lastArg;
let html = messageData.html;
let plaintext = messageData.text;
const recipientVariables = JSON.parse(messageData['recipient-variables']);
const recipientData = recipientVariables[Object.keys(recipientVariables)[0]];
for (const [key, value] of Object.entries(recipientData)) {
html = html.replace(new RegExp(`%recipient.${key}%`, 'g'), value);
plaintext = plaintext.replace(new RegExp(`%recipient.${key}%`, 'g'), value);
}
return {
...messageData,
html,
plaintext,
recipientData
};
}
describe('Batch sending tests', function () {
let stubbedSend;
let linkRedirectService, linkRedirectRepository, linkTrackingService, linkClickRepository;
let ghostServer;
beforeEach(function () {
stubbedSend = async function () {
return {
id: 'stubbed-email-id'
};
};
MailgunEmailProvider.BATCH_SIZE = 100;
stubbedSend = sinon.fake.resolves({
id: 'stubbed-email-id'
});
});
afterEach(async function () {
configUtils.restore();
await models.Settings.edit([{
key: 'email_verification_required',
value: false
}], {context: {internal: true}});
});
before(async function () {
@ -88,19 +140,30 @@ describe('Batch sending tests', function () {
sinon.stub(MailgunClient.prototype, 'getInstance').returns({
// @ts-ignore
messages: {
create: async () => {
return await stubbedSend();
create: async function () {
return await stubbedSend.call(this, ...arguments);
}
}
});
agent = await agentProvider.getAdminAPIAgent();
const agents = await agentProvider.getAgentsWithFrontend();
agent = agents.adminAgent;
frontendAgent = agents.frontendAgent;
ghostServer = agents.ghostServer;
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
linkRedirectService = require('../../../../core/server/services/link-redirection');
linkRedirectRepository = linkRedirectService.linkRedirectRepository;
linkTrackingService = require('../../../../core/server/services/link-tracking');
linkClickRepository = linkTrackingService.linkClickRepository;
});
after(function () {
after(async function () {
mockManager.restore();
await ghostServer.stop();
});
it('Can send a scheduled post email', async function () {
@ -528,6 +591,92 @@ describe('Batch sending tests', function () {
configUtils.restore();
});
// TODO: Link tracking
describe('Analytics', function () {
it('Adds link tracking to all links in a post', async function () {
const {emailModel, html, plaintext, recipientData} = await sendEmail();
const memberUuid = recipientData.uuid;
const member = await models.Member.findOne({uuid: memberUuid});
// Test if all links are replaced and contain the member id
const cheerio = require('cheerio');
const $ = cheerio.load(html);
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const el of $('a').toArray()) {
const href = $(el).attr('href');
if (href.includes('/unsubscribe/?uuid')) {
assert(href.includes('?uuid=' + memberUuid), 'Subscribe link need to contain uuid, got ' + href);
continue;
}
// Check if the link is a tracked link
assert(href.includes('?m=' + memberUuid), href + ' is not tracked');
// Check if this link is also present in the plaintext version (with the right replacements)
assert(plaintext.includes(href), href + ' is not present in the plaintext version');
// Check stored in the database
const u = new URL(href);
const link = links.find(l => l.from.pathname === u.pathname);
assert(link, 'Link model not created for ' + href);
// Mimic a click on a link
const path = u.pathname + u.search;
await frontendAgent.get(path)
.expect('Location', link.to.href)
.expect(302);
// Wait for the link clicks to be processed
await DomainEvents.allSettled();
const clickEvent = await linkClickRepository.getAll({member_id: member.id, link_id: link.link_id.toHexString()});
assert(clickEvent.length, 'Click event was not tracked for ' + link.from.href);
}
for (const link of links) {
// Check ref added to all replaced links
assert.match(link.to.search, /ref=/);
}
});
it('Does not add outbound refs if disabled', async function () {
mockManager.mockSetting('outbound_link_tagging', false);
const {emailModel, html} = await sendEmail();
assert.match(html, /\m=/);
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const link of links) {
// Check ref not added to all replaced links
assert.doesNotMatch(link.to.search, /ref=/);
}
});
// Remove this test once outboundLinkTagging goes GA
it('Does add outbound refs if disabled but flag is disabled', async function () {
mockManager.mockLabsDisabled('outboundLinkTagging');
mockManager.mockSetting('outbound_link_tagging', false);
const {emailModel, html} = await sendEmail();
assert.match(html, /\m=/);
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const link of links) {
// Check ref not added to all replaced links
assert.match(link.to.search, /ref=/);
}
});
it('Does not add link tracking if disabled', async function () {
mockManager.mockSetting('email_track_clicks', false);
const {emailModel, html} = await sendEmail();
assert.doesNotMatch(html, /\m=/);
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
assert.equal(links.length, 0);
});
});
// TODO: Replacement fallbacks
});

View File

@ -132,10 +132,13 @@ describe('MEGA', function () {
* + it tests if all the pieces glue together correctly
*/
describe('Link click tracking', function () {
let ghostServer;
before(async function () {
const agents = await agentProvider.getAgentsWithFrontend();
agent = agents.adminAgent;
frontendAgent = agents.frontendAgent;
ghostServer = agents.ghostServer;
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
@ -143,6 +146,10 @@ describe('MEGA', function () {
_mailgunClient = require('../../../core/server/services/bulk-email')._mailgunClient;
});
after(async function () {
await ghostServer.stop();
});
it('Tracks all the links in an email', async function () {
const linkRedirectService = require('../../../core/server/services/link-redirection');
const linkRedirectRepository = linkRedirectService.linkRedirectRepository;
@ -218,10 +225,15 @@ describe('MEGA', function () {
}
}
const links = await linkRedirectRepository.getAll({post_id: emailModel.get('post_id')});
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
const link = links.find(l => l.from.pathname === firstLink.pathname);
assert(link, 'Link model not created');
for (const l of links) {
// Check ref added
assert.match(l.to.search, /ref=/);
}
// Mimic a click on a link
const path = firstLink.pathname + firstLink.search;
await frontendAgent.get(path)
@ -236,5 +248,66 @@ describe('MEGA', function () {
const clickEvent = await linkClickRepository.getAll({member_id: member.id, link_id: link.link_id.toHexString()});
assert(clickEvent.length === 1, 'Click event was not tracked');
});
it('Does not add outbound refs if disabled', async function () {
mockManager.mockSetting('outbound_link_tagging', false);
const linkRedirectService = require('../../../core/server/services/link-redirection');
const linkRedirectRepository = linkRedirectService.linkRedirectRepository;
sinon.stub(_mailgunClient, 'getInstance').returns({});
const sendStub = sinon.stub(_mailgunClient, 'send');
sendStub.callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('submitted');
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const link of links) {
// Check ref is not added
assert.doesNotMatch(link.to.search, /ref=/);
}
});
// Remove this test once outboundLinkTagging goes GA
it('Does add outbound refs if disabled but flag is disabled', async function () {
mockManager.mockLabsDisabled('outboundLinkTagging');
mockManager.mockSetting('outbound_link_tagging', false);
const linkRedirectService = require('../../../core/server/services/link-redirection');
const linkRedirectRepository = linkRedirectService.linkRedirectRepository;
sinon.stub(_mailgunClient, 'getInstance').returns({});
const sendStub = sinon.stub(_mailgunClient, 'send');
sendStub.callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('submitted');
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const link of links) {
assert.match(link.to.search, /ref=/);
}
});
});
});

View File

@ -5,7 +5,7 @@ const db = require('../../../core/server/data/db');
// Stuff we are testing
const models = require('../../../core/server/models');
const SETTINGS_LENGTH = 79;
const SETTINGS_LENGTH = 81;
describe('Settings Model', function () {
before(models.init);

View File

@ -234,7 +234,7 @@ describe('Exporter', function () {
// NOTE: if default settings changed either modify the settings keys blocklist or increase allowedKeysLength
// This is a reminder to think about the importer/exporter scenarios ;)
const allowedKeysLength = 72;
const allowedKeysLength = 73;
totalKeysLength.should.eql(SETTING_KEYS_BLOCKLIST.length + allowedKeysLength);
});
});

View File

@ -37,7 +37,7 @@ describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '8eab51dd80562c92215283df89b0200b';
const currentFixturesHash = 'f0ccdb0c7eccbc3311e38b5d145ed1db';
const currentSettingsHash = '9acce72858e75420b831297718595bbd';
const currentSettingsHash = 'b0c8359b7482e39112e7c5739d43f11b';
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,

View File

@ -293,6 +293,7 @@ const getAgentsForMembers = async () => {
};
/**
* WARNING: when using this, you should stop the returned ghostServer after the tests.
* @NOTE: for now method returns a supertest agent for Frontend instead of test agent with snapshot support.
* frontendAgent should be returning an instance of TestAgent (related: https://github.com/TryGhost/Toolbox/issues/471)
* @returns {Promise<{adminAgent: InstanceType<AdminAPITestAgent>, membersAgent: InstanceType<MembersAPITestAgent>, frontendAgent: InstanceType<supertest.SuperAgentTest>, contentAPIAgent: InstanceType<ContentAPITestAgent>, ghostServer: Express.Application}>} agents

View File

@ -480,5 +480,15 @@
]]
}
}
},
"analytics": {
"outbound_link_tagging": {
"defaultValue": "true",
"validations": {
"isEmpty": false,
"isIn": [["true", "false"]]
},
"type": "boolean"
}
}
}

View File

@ -474,5 +474,29 @@
"defaultValue": "all",
"type": "string"
}
},
"comments": {
"comments_enabled": {
"type": "string",
"defaultValue": "off",
"validations": {
"isEmpty": false,
"isIn": [[
"off",
"all",
"paid"
]]
}
}
},
"analytics": {
"outbound_link_tagging": {
"defaultValue": "true",
"validations": {
"isEmpty": false,
"isIn": [["true", "false"]]
},
"type": "boolean"
}
}
}

View File

@ -238,13 +238,13 @@ class EmailRenderer {
if (isSite) {
// Add newsletter name as ref to the URL
url = this.#memberAttributionService.addEmailSourceAttributionTracking(url, newsletter);
url = this.#memberAttributionService.addOutboundLinkTagging(url, newsletter);
// Only add post attribution to our own site (because external sites could/should not process this information)
url = this.#memberAttributionService.addPostAttributionTracking(url, post);
} else {
// Add email source attribution without the newsletter name
url = this.#memberAttributionService.addEmailSourceAttributionTracking(url);
url = this.#memberAttributionService.addOutboundLinkTagging(url);
}
// Add link click tracking

View File

@ -386,7 +386,7 @@ describe('Email renderer', function () {
},
linkReplacer,
memberAttributionService: {
addEmailSourceAttributionTracking: (u, newsletter) => {
addOutboundLinkTagging: (u, newsletter) => {
u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site');
return u;
},

View File

@ -16,12 +16,14 @@ class MemberAttributionService {
* @param {Object} deps.models.MemberCreatedEvent
* @param {Object} deps.models.SubscriptionCreatedEvent
* @param {() => boolean} deps.getTrackingEnabled
* @param {() => boolean} deps.getOutboundLinkTaggingEnabled
* @param {() => string} deps.getSiteTitle
*/
constructor({attributionBuilder, models, getTrackingEnabled, getSiteTitle}) {
constructor({attributionBuilder, models, getTrackingEnabled, getOutboundLinkTaggingEnabled, getSiteTitle}) {
this.models = models;
this.attributionBuilder = attributionBuilder;
this._getTrackingEnabled = getTrackingEnabled;
this._getOutboundLinkTaggingEnabled = getOutboundLinkTaggingEnabled;
this._getSiteTitle = getSiteTitle;
}
@ -29,6 +31,10 @@ class MemberAttributionService {
return this._getTrackingEnabled();
}
get isOutboundLinkTaggingEnabled() {
return this._getOutboundLinkTaggingEnabled();
}
get siteTitle() {
return this._getSiteTitle();
}
@ -95,16 +101,20 @@ class MemberAttributionService {
}
/**
* Add some parameters to a URL so that the frontend script can detect this and add the required records
* in the URLHistory.
* Add some parameters to a URL that points to a site, so that site can detect that the traffic is coming from a Ghost site or newsletter.
* Note that this is disabled if outboundLinkTagging setting is disabled.
* @param {URL} url instance that will get updated
* @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source
* @returns {URL}
*/
addEmailSourceAttributionTracking(url, useNewsletter) {
addOutboundLinkTagging(url, useNewsletter) {
// Create a deep copy
url = new URL(url);
if (!this.isOutboundLinkTaggingEnabled) {
return url;
}
if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) {
// Don't overwrite + keep existing source attribution
return url;
@ -118,7 +128,7 @@ class MemberAttributionService {
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);

View File

@ -10,20 +10,33 @@ describe('MemberAttributionService', function () {
});
});
describe('addEmailSourceAttributionTracking', function () {
describe('addOutboundLinkTagging', function () {
it('uses sluggified sitename for external urls', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/');
const updatedUrl = await service.addEmailSourceAttributionTracking(url);
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/?ref=hello-world');
});
it('does not add if disabled', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => false
});
const url = new URL('https://example.com/');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/');
});
it('uses sluggified newsletter name for internal urls', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/');
const newsletterName = 'used newsletter name';
@ -35,14 +48,15 @@ describe('MemberAttributionService', function () {
}
};
const updatedUrl = await service.addEmailSourceAttributionTracking(url, newsletter);
const updatedUrl = await service.addOutboundLinkTagging(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'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/');
const newsletterName = 'Weekly newsletter';
@ -53,45 +67,49 @@ describe('MemberAttributionService', function () {
}
}
};
const updatedUrl = await service.addEmailSourceAttributionTracking(url, newsletter);
const updatedUrl = await service.addOutboundLinkTagging(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'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://facebook.com/');
const updatedUrl = await service.addEmailSourceAttributionTracking(url);
const updatedUrl = await service.addOutboundLinkTagging(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'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/?utm_source=hello');
const updatedUrl = await service.addEmailSourceAttributionTracking(url);
const updatedUrl = await service.addOutboundLinkTagging(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'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/?ref=hello');
const updatedUrl = await service.addEmailSourceAttributionTracking(url);
const updatedUrl = await service.addOutboundLinkTagging(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'
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/?source=hello');
const updatedUrl = await service.addEmailSourceAttributionTracking(url);
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/?source=hello');
});
});