CiviMail - Implement List-Unsubscibe=One-Click (with overloaded routing)
authorTim Otten <totten@civicrm.org>
Tue, 9 Jan 2024 04:09:32 +0000 (20:09 -0800)
committerTim Otten <totten@civicrm.org>
Wed, 24 Jan 2024 08:47:36 +0000 (00:47 -0800)
CRM/Admin/Form/Setting/Mail.php
CRM/Mailing/Page/Unsubscribe.php
CRM/Mailing/Service/ListUnsubscribe.php [new file with mode: 0644]
ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php
settings/Mailing.setting.php
tests/phpunit/CRM/Mailing/BaseMailingSystemTest.php

index 4b49514c34a43201376142b8a0db94361dc67f8a..358c87f81fcf9acf70dcb7bcb9006154e3c44648 100644 (file)
@@ -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,
   ];
 
   /**
index 3db0708a20a12c0c2aecca61dbd8374379f13044..218fe4c82873994164173f7d9ce67a5799b5e1d4 100644 (file)
@@ -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 (file)
index 0000000..5439047
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+/**
+ * Apply a full range of `List-Unsubscribe` header options.
+ *
+ * @service civi.mailing.listUnsubscribe
+ * @link https://datatracker.ietf.org/doc/html/rfc8058
+ */
+class CRM_Mailing_Service_ListUnsubscribe extends \Civi\Core\Service\AutoService implements \Symfony\Component\EventDispatcher\EventSubscriberInterface {
+
+  public static function getMethods(): array {
+    return [
+      'mailto' => 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: <mailto:....>`. 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(';^<mailto:u\.(\d+)\.(\d+)\.(\w*)@(.*)>$;', $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']);
+  }
+
+}
index d91e6ec490cc4beaa9c7d20c7e5bf5ac97787e32..679b4b6426a294307652fb5ce7ac283196b7fdc3 100644 (file)
@@ -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,
index d000dc47fda603680beeee9f0a5385fcedc54b50..32e6a92ef602b766d8e9cd64a069ee8ef219611a 100644 (file)
@@ -18,6 +18,8 @@
  * Settings metadata file
  */
 
+$unsubLearnMore = '<br/>' . ts('(<a %1">Learn more</a>)', [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',
index 06b3a751e9fbbc79cde0c42d9f43624298f0272b..b401711d6971f085c6030d28da4613ee65dbe48e 100644 (file)
@@ -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(';^\<mailto:u\.[\d\.a-z]+@chaos.org\>$;', $message->headers['List-Unsubscribe'][0]);
+      $this->assertMatchesRegularExpression(';^\<mailto:u\.[\d\.a-z]+@chaos.org\>, \<http.*unsubscribe.*\>$;', $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).
    */