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:
parent
8a95c62ff1
commit
e879406659
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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'),
|
||||
|
@ -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;
|
||||
|
||||
|
@ -248,7 +248,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="for-switch">
|
||||
<GhFeatureFlag @flag="externalAttribution" />
|
||||
<GhFeatureFlag @flag="outboundLinkTagging" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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),
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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 = {
|
||||
|
@ -0,0 +1,8 @@
|
||||
const {addSetting} = require('../../utils');
|
||||
|
||||
module.exports = addSetting({
|
||||
key: 'outbound_link_tagging',
|
||||
value: 'true',
|
||||
type: 'boolean',
|
||||
group: 'analytics'
|
||||
});
|
@ -480,5 +480,15 @@
|
||||
]]
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"outbound_link_tagging": {
|
||||
"defaultValue": "true",
|
||||
"validations": {
|
||||
"isEmpty": false,
|
||||
"isIn": [["true", "false"]]
|
||||
},
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
});
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ const ALPHA_FEATURES = [
|
||||
'beforeAfterCard',
|
||||
'lexicalEditor',
|
||||
'webmentions',
|
||||
'externalAttribution'
|
||||
'outboundLinkTagging'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
@ -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,
|
||||
|
@ -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 = {};
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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=/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -480,5 +480,15 @@
|
||||
]]
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"outbound_link_tagging": {
|
||||
"defaultValue": "true",
|
||||
"validations": {
|
||||
"isEmpty": false,
|
||||
"isIn": [["true", "false"]]
|
||||
},
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user