From b334ae091ac0100452624dc61599cbb4bf13589a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 8 Jan 2024 20:09:32 -0800 Subject: [PATCH] CiviMail - Implement List-Unsubscibe=One-Click (with overloaded routing) --- CRM/Admin/Form/Setting/Mail.php | 1 + CRM/Mailing/Page/Unsubscribe.php | 36 ++++++++++ CRM/Mailing/Service/ListUnsubscribe.php | 69 +++++++++++++++++++ .../Civi/FlexMailer/FlexMailerSystemTest.php | 6 +- settings/Mailing.setting.php | 24 +++++++ .../CRM/Mailing/BaseMailingSystemTest.php | 51 +++++++++++++- 6 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 CRM/Mailing/Service/ListUnsubscribe.php diff --git a/CRM/Admin/Form/Setting/Mail.php b/CRM/Admin/Form/Setting/Mail.php index 4b49514c34..358c87f81f 100644 --- a/CRM/Admin/Form/Setting/Mail.php +++ b/CRM/Admin/Form/Setting/Mail.php @@ -29,6 +29,7 @@ class CRM_Admin_Form_Setting_Mail extends CRM_Admin_Form_Setting { // dev/core#1768 Make this interval configurable. 'civimail_sync_interval' => CRM_Core_BAO_Setting::MAILING_PREFERENCES_NAME, 'replyTo' => CRM_Core_BAO_Setting::MAILING_PREFERENCES_NAME, + 'civimail_unsubscribe_methods' => CRM_Core_BAO_Setting::MAILING_PREFERENCES_NAME, ]; /** diff --git a/CRM/Mailing/Page/Unsubscribe.php b/CRM/Mailing/Page/Unsubscribe.php index 3db0708a20..218fe4c828 100644 --- a/CRM/Mailing/Page/Unsubscribe.php +++ b/CRM/Mailing/Page/Unsubscribe.php @@ -25,8 +25,44 @@ class CRM_Mailing_Page_Unsubscribe extends CRM_Core_Page { * @throws Exception */ public function run() { + $isOneClick = ($_SERVER['REQUEST_METHOD'] === 'POST' && CRM_Utils_Request::retrieve('List-Unsubscribe', 'String') === 'One-Click'); + if ($isOneClick) { + $this->handleOneClick(); + return NULL; + } + $wrapper = new CRM_Utils_Wrapper(); return $wrapper->run('CRM_Mailing_Form_Unsubscribe', $this->_title); } + /** + * + * Pre-condition: Validated the _job_id, _queue_id, _hash. + * Post-condition: Unsubscribed + * + * @link https://datatracker.ietf.org/doc/html/rfc8058 + * @return void + */ + public function handleOneClick(): void { + $jobId = CRM_Utils_Request::retrieve('jid', 'Integer'); + $queueId = CRM_Utils_Request::retrieve('qid', 'Integer'); + $hash = CRM_Utils_Request::retrieve('h', 'String'); + + $q = CRM_Mailing_Event_BAO_MailingEventQueue::verify(NULL, $queueId, $hash); + if (!$q) { + CRM_Utils_System::sendResponse( + new \GuzzleHttp\Psr7\Response(400, [], ts("Invalid request: bad parameters")) + ); + } + + $groups = CRM_Mailing_Event_BAO_MailingEventUnsubscribe::unsub_from_mailing($jobId, $queueId, $hash); + if (!empty($groups)) { + CRM_Mailing_Event_BAO_MailingEventUnsubscribe::send_unsub_response($queueId, $groups, FALSE, $jobId); + } + + CRM_Utils_System::sendResponse( + new \GuzzleHttp\Psr7\Response(200, [], 'OK') + ); + } + } diff --git a/CRM/Mailing/Service/ListUnsubscribe.php b/CRM/Mailing/Service/ListUnsubscribe.php new file mode 100644 index 0000000000..5439047895 --- /dev/null +++ b/CRM/Mailing/Service/ListUnsubscribe.php @@ -0,0 +1,69 @@ + ts('Mailto'), + 'http' => ts('HTTP(S) Web-Form'), + 'oneclick' => ts('HTTP(S) One-Click'), + ]; + } + + public static function getSubscribedEvents() { + return [ + '&hook_civicrm_alterMailParams' => ['alterMailParams', 1000], + ]; + } + + /** + * @see \CRM_Utils_Hook::alterMailParams() + */ + public function alterMailParams(&$params, $context = NULL): void { + // FIXME: Flexmailer (BasicHeaders) and BAO (getVerpAndUrlsAndHeaders) separately define + // `List-Unsubscribe: `. And they have separate invocations of alterMailParams. + // + // This code is a little ugly because it anticipates serving both code-paths. + // But the BAO path should be properly killed. Doing so will allow you cleanup this code more. + + if (!in_array($context, ['civimail', 'flexmailer'])) { + return; + } + + $methods = Civi::settings()->get('civimail_unsubscribe_methods'); + if ($methods === ['mailto']) { + return; + } + + if (!preg_match(';^$;', $params['List-Unsubscribe'], $m)) { + \Civi::log()->warning('Failed to set final value of List-Unsubscribe'); + return; + } + + $listUnsubscribe = []; + if (in_array('mailto', $methods)) { + $listUnsubscribe[] = $params['List-Unsubscribe']; + } + if (array_intersect(['http', 'oneclick'], $methods)) { + $listUnsubscribe[] = '<' . Civi::url('civicrm/mailing/unsubscribe')->addQuery([ + 'reset' => 1, + 'jid' => $m[1], + 'qid' => $m[2], + 'h' => $m[3], + ]) . '>'; + } + + if (in_array('oneclick', $methods)) { + $params['headers']['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + } + $params['headers']['List-Unsubscribe'] = implode(', ', $listUnsubscribe); + unset($params['List-Unsubscribe']); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php index d91e6ec490..679b4b6426 100644 --- a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php @@ -75,13 +75,13 @@ class FlexMailerSystemTest extends \CRM_Mailing_BaseMailingSystemTest { * @see CRM_Utils_Hook::alterMailParams */ public function hook_alterMailParams(&$params, $context = NULL) { - $this->counts['hook_alterMailParams'] = 1; - $this->assertEquals('flexmailer', $context); + $this->counts["hook_alterMailParams::$context"] = 1; } public function tearDown(): void { parent::tearDown(); - $this->assertNotEmpty($this->counts['hook_alterMailParams']); + $this->assertNotEmpty($this->counts['hook_alterMailParams::flexmailer']); + $this->assertEmpty($this->counts['hook_alterMailParams::civimail'] ?? NULL); foreach (FlexMailer::getEventTypes() as $event => $class) { $this->assertTrue( $this->counts[$class] > 0, diff --git a/settings/Mailing.setting.php b/settings/Mailing.setting.php index d000dc47fd..32e6a92ef6 100644 --- a/settings/Mailing.setting.php +++ b/settings/Mailing.setting.php @@ -18,6 +18,8 @@ * Settings metadata file */ +$unsubLearnMore = '
' . ts('(Learn more)', [1 => 'href="https://civicrm.org/redirect/unsubscribe-one-click" target="_blank"']); + return [ 'profile_double_optin' => [ 'group_name' => 'Mailing Preferences', @@ -91,6 +93,28 @@ return [ 'is_contact' => 0, 'help_text' => NULL, ], + 'civimail_unsubscribe_methods' => [ + 'group_name' => 'Mailing Preferences', + 'group' => 'mailing', + 'name' => 'civimail_unsubscribe_methods', + 'type' => 'Array', + 'quick_form_type' => 'Select', + 'html_type' => 'Select', + 'html_attributes' => [ + 'multiple' => 1, + 'class' => 'crm-select2', + ], + 'default' => version_compare(CRM_Utils_System::version(), '5.72', '<=') ? ['mailto'] : ['mailto', 'http', 'oneclick'], + 'add' => '5.70', + 'title' => ts('Unsubscribe Methods'), + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => ts("These methods will be offered to email clients for semi-automated unsubscribes. Support for each depends on the recipient's email client.") . $unsubLearnMore, + 'help_text' => NULL, + 'pseudoconstant' => [ + 'callback' => 'CRM_Mailing_Service_ListUnsubscribe::getMethods', + ], + ], 'replyTo' => [ 'group_name' => 'Mailing Preferences', 'group' => 'mailing', diff --git a/tests/phpunit/CRM/Mailing/BaseMailingSystemTest.php b/tests/phpunit/CRM/Mailing/BaseMailingSystemTest.php index 06b3a751e9..b401711d69 100644 --- a/tests/phpunit/CRM/Mailing/BaseMailingSystemTest.php +++ b/tests/phpunit/CRM/Mailing/BaseMailingSystemTest.php @@ -18,6 +18,8 @@ * @copyright CiviCRM LLC https://civicrm.org/licensing */ +use GuzzleHttp\Psr7\Request; + /** * Class CRM_Mailing_MailingSystemTest * @group headless @@ -52,11 +54,13 @@ abstract class CRM_Mailing_BaseMailingSystemTest extends CiviUnitTestCase { $this->_mut = new CiviMailUtils($this, TRUE); $this->callAPISuccess('mail_settings', 'get', ['api.mail_settings.create' => ['domain' => 'chaos.org']]); + Civi::settings()->set('civimail_unsubscribe_methods', ['mailto', 'http', 'oneclick']); } /** */ public function tearDown(): void { + $this->revertSetting('civimail_unsubscribe_methods'); $this->_mut->stop(); CRM_Utils_Hook::singleton()->reset(); // DGW @@ -87,11 +91,56 @@ abstract class CRM_Mailing_BaseMailingSystemTest extends CiviUnitTestCase { $this->assertMatchesRegularExpression('#^text/plain; charset=utf-8#', $message->headers['Content-Type']); $this->assertMatchesRegularExpression(';^b\.[\d\.a-z]+@chaos.org$;', $message->headers['Return-Path']); $this->assertMatchesRegularExpression(';^b\.[\d\.a-z]+@chaos.org$;', $message->headers['X-CiviMail-Bounce'][0]); - $this->assertMatchesRegularExpression(';^\$;', $message->headers['List-Unsubscribe'][0]); + $this->assertMatchesRegularExpression(';^\, \$;', $message->headers['List-Unsubscribe'][0]); $this->assertEquals('bulk', $message->headers['Precedence'][0]); } } + public function testHttpUnsubscribe(): void { + $client = new Civi\Test\LocalHttpClient(['reboot' => TRUE, 'htmlHeader' => FALSE]); + + $allMessages = $this->runMailingSuccess([ + 'subject' => 'Yellow Unsubmarine', + 'body_text' => 'In the {domain.address} where I was born, lived a man who sailed to sea', + ]); + + $getMembers = fn($status) => Civi\Api4\GroupContact::get(FALSE) + ->addWhere('group_id', '=', $this->_groupID) + ->addWhere('status', '=', $status) + ->execute(); + + $this->assertEquals(2, $getMembers('Added')->count()); + $this->assertEquals(0, $getMembers('Removed')->count()); + + foreach ($allMessages as $k => $message) { + $this->assertNotEmpty($message->headers['List-Unsubscribe'][0]); + $this->assertEquals('List-Unsubscribe=One-Click', $message->headers['List-Unsubscribe-Post'][0]); + + $urls = array_map( + fn($s) => trim($s, '<>'), + preg_split('/[,\s]+/', $message->headers['List-Unsubscribe'][0]) + + ); + $url = CRM_Utils_Array::first(preg_grep('/^http/', $urls)); + $this->assertMatchesRegularExpression(';civicrm/mailing/unsubscribe;', $url); + + // Older clients (RFC 2369 only): Open a browser for the unsubscribe page + // FIXME: This works locally, but in CI complains about ckeditor4. Smells like compatibility issue between LocalHttpClient and $unclear. + $get = $client->sendRequest(new Request('GET', $url)); + $this->assertEquals(200, $get->getStatusCode()); + $this->assertMatchesRegularExpression(';You are requesting to unsubscribe;', (string) $get->getBody()); + + // Newer clients (RFC 8058): Send headless HTTP POST + $post = $client->sendRequest(new Request('POST', $url, [], $message->headers['List-Unsubscribe-Post'][0])); + $this->assertEquals(200, $post->getStatusCode()); + $this->assertEquals('OK', trim((string) $post->getBody())); + } + + // The HTTP POSTs removed the members. + $this->assertEquals(0, $getMembers('Added')->count()); + $this->assertEquals(2, $getMembers('Removed')->count()); + } + /** * Generate a fully-formatted mailing (with body_text content). */ -- 2.25.1