// 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,
];
/**
* @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')
+ );
+ }
+
}
--- /dev/null
+<?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']);
+ }
+
+}
* @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,
* 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',
'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',
* @copyright CiviCRM LLC https://civicrm.org/licensing
*/
+use GuzzleHttp\Psr7\Request;
+
/**
* Class CRM_Mailing_MailingSystemTest
* @group headless
$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
$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).
*/