Skip to content

Commit

Permalink
Merge pull request #19386 from totten/master-track-token
Browse files Browse the repository at this point in the history
(dev/mail#81) Flexmailer - Track click-throughs for URLs with tokens
  • Loading branch information
mattwire authored Jan 21, 2021
2 parents dca7262 + 6ad859f commit 7049cd0
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 8 deletions.
68 changes: 68 additions & 0 deletions CRM/Mailing/BAO/TrackableURL.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ public function __construct() {
* The redirect/tracking url
*/
public static function getTrackerURL($url, $mailing_id, $queue_id) {
if (strpos($url, '{') !== FALSE) {
return self::getTrackerURLForUrlWithTokens($url, $mailing_id, $queue_id);
}
else {
return self::getBasicTrackerURL($url, $mailing_id, $queue_id);
}
}

private static function getBasicTrackerURL($url, $mailing_id, $queue_id) {
static $urlCache = [];

if (array_key_exists($mailing_id . $url, $urlCache)) {
Expand Down Expand Up @@ -87,6 +95,66 @@ public static function getTrackerURL($url, $mailing_id, $queue_id) {
return $returnUrl;
}

/**
* Create a trackable URL for a URL with tokens.
*
* @param string $url
* @param int $mailing_id
* @param int|string $queue_id
*
* @return string
*/
private static function getTrackerURLForUrlWithTokens($url, $mailing_id, $queue_id) {

// Parse the URL.
// (not using parse_url because it's messy to reassemble)
if (!preg_match('/^([^?#]+)([?][^#]*)?(#.*)?$/', $url, $parsed)) {
// Failed to parse it, give up and don't track it.
return $url;
}

// If we have a token in the URL + path section, we can't tokenise.
if (strpos($parsed[1], '{') !== FALSE) {
return $url;
}

$trackable_url = $parsed[1];

// Process the query parameters, if there are any.
$tokenised_params = [];
$static_params = [];
if (!empty($parsed[2])) {
$query_key_value_pairs = explode('&', substr($parsed[2], 1));

// Separate the tokenised from the static parts.
foreach ($query_key_value_pairs as $_) {
if (strpos($_, '{') === FALSE) {
$static_params[] = $_;
}
else {
$tokenised_params[] = $_;
}
}
// Add the static params to the trackable part.
if ($static_params) {
$trackable_url .= '?' . implode('&', $static_params);
}
}

// Get trackable URL.
$data = self::getBasicTrackerURL($trackable_url, $mailing_id, $queue_id);

// Append the tokenised bits and the fragment.
if ($tokenised_params) {
// We know the URL will already have the '?'
$data .= '&' . implode('&', $tokenised_params);
}
if (!empty($parsed[3])) {
$data .= $parsed[3];
}
return $data;
}

/**
* @param $url
* @param $mailing_id
Expand Down
5 changes: 1 addition & 4 deletions ext/flexmailer/src/ClickTracker/HtmlClickTracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,8 @@ class HtmlClickTracker implements ClickTrackerInterface {
public function filterContent($msg, $mailing_id, $queue_id) {
return self::replaceHrefUrls($msg,
function ($url) use ($mailing_id, $queue_id) {
if (strpos($url, '{') !== FALSE) {
return $url;
}
$data = \CRM_Mailing_BAO_TrackableURL::getTrackerURL(
$url, $mailing_id, $queue_id);
html_entity_decode($url), $mailing_id, $queue_id);
$data = htmlentities($data, ENT_NOQUOTES);
return $data;
}
Expand Down
3 changes: 0 additions & 3 deletions ext/flexmailer/src/ClickTracker/TextClickTracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ class TextClickTracker implements ClickTrackerInterface {
public function filterContent($msg, $mailing_id, $queue_id) {
return self::replaceTextUrls($msg,
function ($url) use ($mailing_id, $queue_id) {
if (strpos($url, '{') !== FALSE) {
return $url;
}
return \CRM_Mailing_BAO_TrackableURL::getTrackerURL($url, $mailing_id,
$queue_id);
}
Expand Down
140 changes: 140 additions & 0 deletions ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTrackerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
namespace Civi\FlexMailer;

use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;

use Civi\FlexMailer\ClickTracker\TextClickTracker;
use Civi\FlexMailer\ClickTracker\HtmlClickTracker;

/**
* Tests that URLs are converted to tracked ones if at all possible.
*
* @group headless
*/
class ClickTrackerTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {

protected $mailing_id;

public function setUpHeadless() {
// Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
// See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
return \Civi\Test::headless()
->installMe(__DIR__)
->apply();
}

public function setUp() {
// Mock the getTrackerURL call; we don't need to test creating a row in a table.
// If you want this to work without runkit, then either (a) make the dummy rows or (b) switch this to a hook/event that is runtime-configurable.
require_once 'CRM/Mailing/BAO/TrackableURL.php';
runkit7_method_rename('\CRM_Mailing_BAO_TrackableURL', 'getBasicTrackerURL', 'orig_getBasicTrackerURL');
runkit7_method_add('\CRM_Mailing_BAO_TrackableURL', 'getBasicTrackerURL', '$a, $b, $c', 'return \'http://example.com/extern?u=1&qid=1\';', RUNKIT7_ACC_STATIC | RUNKIT7_ACC_PRIVATE);
parent::setUp();
}

public function tearDown() {
// Reset the class.
runkit7_method_remove('\CRM_Mailing_BAO_TrackableURL', 'getBasicTrackerURL');
runkit7_method_rename('\CRM_Mailing_BAO_TrackableURL', 'orig_getBasicTrackerURL', 'getBasicTrackerURL');
parent::tearDown();
}

/**
* Example: Test that a link without any tokens works.
*/
public function testLinkWithoutTokens() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/foo/bar?a=b&c=d#frag';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: http://example.com/extern?u=1&qid=1', $result);
}

/**
* Example: Test that a link with tokens in the query works.
*/
public function testLinkWithTokensInQueryWithStaticParams() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/foo/bar?a=b&cid={contact.id}';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: http://example.com/extern?u=1&qid=1&cid={contact.id}', $result);
}

/**
* Example: Test that a link with tokens in the query works.
*/
public function testLinkWithTokensInQueryWithMultipleStaticParams() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/foo/bar?cs={contact.checksum}&a=b&cid={contact.id}';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: http://example.com/extern?u=1&qid=1&cs={contact.checksum}&cid={contact.id}', $result);
}

/**
* Example: Test that a link with tokens in the query works.
*/
public function testLinkWithTokensInQueryWithMultipleStaticParamsHtml() {
$filter = new HtmlClickTracker();
$msg = '<a href="https://example.com/foo/bar?cs={contact.checksum}&amp;a=b&amp;cid={contact.id}">See this</a>';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('<a href="http://example.com/extern?u=1&amp;qid=1&amp;cs={contact.checksum}&amp;cid={contact.id}" rel=\'nofollow\'>See this</a>', $result);
}

/**
* Example: Test that a link with tokens in the query works.
*/
public function testLinkWithTokensInQueryWithoutStaticParams() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/foo/bar?cid={contact.id}';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: http://example.com/extern?u=1&qid=1&cid={contact.id}', $result);
}

/**
* Example: Test that a link with tokens in the fragment works.
*
* Seems browsers maintain the fragment when they receive a redirect, so a
* token here might still work.
*/
public function testLinkWithTokensInFragment() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/foo/bar?a=b#cid={contact.id}';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: http://example.com/extern?u=1&qid=1#cid={contact.id}', $result);
}

/**
* Example: Test that a link with tokens in the fragment works.
*
* Seems browsers maintain the fragment when they receive a redirect, so a
* token here might still work.
*/
public function testLinkWithTokensInQueryAndFragment() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/foo/bar?a=b&cid={contact.id}#cid={contact.id}';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: http://example.com/extern?u=1&qid=1&cid={contact.id}#cid={contact.id}', $result);
}

/**
* We can't handle tokens in the domain so it should not be tracked.
*/
public function testLinkWithTokensInDomainFails() {
$filter = new TextClickTracker();
$msg = 'See this: https://{some.domain}.com/foo/bar';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: https://{some.domain}.com/foo/bar', $result);
}

/**
* We can't handle tokens in the path so it should not be tracked.
*/
public function testLinkWithTokensInPathFails() {
$filter = new TextClickTracker();
$msg = 'See this: https://example.com/{some.path}';
$result = $filter->filterContent($msg, 1, 1);
$this->assertEquals('See this: https://example.com/{some.path}', $result);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ public function testUrlTracking(
parent::testUrlTracking($inputHtml, $htmlUrlRegex, $textUrlRegex, $params);
}

/**
*
* This takes CiviMail's own ones, but removes one that tested for a
* non-feature (i.e. that tokenised links are not handled).
*
* @return array
*/
public function urlTrackingExamples() {
$cases = parent::urlTrackingExamples();

// When it comes to URLs with embedded tokens, support diverges - Flexmailer
// can track them, but BAO mailer cannot.
$cases[6] = [
'<p><a href="http://example.net/?id={contact.contact_id}">Foo</a></p>',
';<p><a href=[\'"].*(extern/url.php|civicrm/mailing/url)(\?|&amp\\;)u=\d+.*&amp\\;id=\d+.*[\'"]>Foo</a></p>;',
';\\[1\\] .*(extern/url.php|civicrm/mailing/url)[\?&]u=\d+.*&id=\d+.*;',
['url_tracking' => 1],
];

return $cases;
}

public function testBasicHeaders() {
parent::testBasicHeaders();
}
Expand Down
3 changes: 2 additions & 1 deletion tests/phpunit/CRM/Mailing/BaseMailingSystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,6 @@ public function urlTrackingExamples() {
* @throws \CRM_Core_Exception
*/
public function testUrlTracking($inputHtml, $htmlUrlRegex, $textUrlRegex, $params) {
$caseName = print_r(['inputHtml' => $inputHtml, 'params' => $params], 1);

$allMessages = $this->runMailingSuccess($params + [
'subject' => 'Example Subject',
Expand All @@ -341,11 +340,13 @@ public function testUrlTracking($inputHtml, $htmlUrlRegex, $textUrlRegex, $param
list($textPart, $htmlPart) = $message->body->getParts();

if ($htmlUrlRegex) {
$caseName = print_r(['inputHtml' => $inputHtml, 'params' => $params, 'htmlUrlRegex' => $htmlUrlRegex, 'htmlPart' => $htmlPart->text], 1);
$this->assertEquals('html', $htmlPart->subType, "Should have HTML part in case: $caseName");
$this->assertRegExp($htmlUrlRegex, $htmlPart->text, "Should have correct HTML in case: $caseName");
}

if ($textUrlRegex) {
$caseName = print_r(['inputHtml' => $inputHtml, 'params' => $params, 'textUrlRegex' => $textUrlRegex, 'textPart' => $textPart->text], 1);
$this->assertEquals('plain', $textPart->subType, "Should have text part in case: $caseName");
$this->assertRegExp($textUrlRegex, $textPart->text, "Should have correct text in case: $caseName");
}
Expand Down

0 comments on commit 7049cd0

Please sign in to comment.