From f928a0b0df809420de307c8428aa0fddeae2feb9 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 30 Oct 2020 16:01:42 -0700 Subject: [PATCH] dev/core#2141 - Add hook_civicrm_alterMailStore Overview -------- The CiviCRM "MailStore" layer is used for importing email messages from various sources (IMAP, POP3, Maildir, etc). There is a built-in list with a handful of drivers (`CRM_Mailing_MailStore_Imap`, etc). This patch adds a hook for manipulating those drivers. Before ------ It's not possible for an extension to add a driver, modify a driver, etc. After ----- It is now possible to add/modify/replace drivers. Here are two examples. (1) To supplement the IMAP authentication with a dynamic token for XOAuth2: ```php function hook_civicrm_alterMailStore(&$mailSettings) { if (...$mailSettings requires oauth...) { $mailSettings['auth'] = 'XOAuth2'; $mailSettings['password'] = $myOauthSystem->getToken(...); } } ``` (2) To add a new protocol `FIZZBUZZ`, you would: 1. Register a value in the OptionGroup `mail_protocol`. 2. Create a driver class (eg `CRM_Mailing_MailStore_FizzBuzz` extends `CRM_Mailing_MailStore`) 3. Use the hook to activate the class: ```php function hook_civicrm_alterMailStore(&$mailSettings) { if ($mailSettings['protocol'] === 'FIZZBUZZ') { $mailSettings['factory'] = function ($mailSettings) { return new CRM_Mailing_MailStore_FizzBuzz(...); }; } } ``` Technical Details ----------------- This adds a unit-test with examples of basic/non-hook behavior and hooked behavior. In reading the diff for `getStore()`, note that it previously had a long 'switch()' to handle instantiation. I tried to make the change in a way that you could see some continuity - e.g. it's still the same basic `switch()`. The change is to basically wrap the bits inside a function: * Before: `case 'FOO': return new Foo(...)` * After: `case 'FOO': return ['factory' => function(...) { return new Foo(...); }]` --- CRM/Mailing/MailStore.php | 69 +++++++++++++--- CRM/Utils/Hook.php | 27 ++++++ tests/phpunit/CRM/Mailing/MailStoreTest.php | 91 +++++++++++++++++++++ 3 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 tests/phpunit/CRM/Mailing/MailStoreTest.php diff --git a/CRM/Mailing/MailStore.php b/CRM/Mailing/MailStore.php index 90db173cdc..f963a1b825 100644 --- a/CRM/Mailing/MailStore.php +++ b/CRM/Mailing/MailStore.php @@ -40,34 +40,79 @@ class CRM_Mailing_MailStore { } $protocols = CRM_Core_PseudoConstant::get('CRM_Core_DAO_MailSettings', 'protocol', [], 'validate'); - if (empty($protocols[$dao->protocol])) { - throw new Exception("Empty mail protocol"); + + // Prepare normalized/hookable representation of the mail settings. + $mailSettings = $dao->toArray(); + $mailSettings['protocol'] = $protocols[$mailSettings['protocol']] ?? NULL; + $protocolDefaults = self::getProtocolDefaults($mailSettings['protocol']); + $mailSettings = array_merge($protocolDefaults, $mailSettings); + + CRM_Utils_Hook::alterMailStore($mailSettings); + + if (!empty($mailSettings['factory'])) { + return call_user_func($mailSettings['factory'], $mailSettings); } + else { + throw new Exception("Unknown protocol {$mailSettings['protocol']}"); + } + } - switch ($protocols[$dao->protocol]) { + /** + * @param string $protocol + * Ex: 'IMAP', 'Maildir' + * @return array + * List of properties to merge into the $mailSettings. + * The most important property is 'factory' with signature: + * + * function($mailSettings): CRM_Mailing_MailStore + */ + private static function getProtocolDefaults($protocol) { + switch ($protocol) { case 'IMAP': - return new CRM_Mailing_MailStore_Imap($dao->server, $dao->username, $dao->password, (bool) $dao->is_ssl, $dao->source); - case 'IMAP_XOAUTH2': - return new CRM_Mailing_MailStore_Imap($dao->server, $dao->username, $dao->password, (bool) $dao->is_ssl, $dao->source, TRUE); + return [ + // For backward compat with pre-release XOAuth2 configurations + 'auth' => $protocol === 'IMAP_XOAUTH2' ? 'XOAuth2' : 'Password', + // In a simpler world: + // 'auth' => 'Password', + 'factory' => function($mailSettings) { + $useXOAuth2 = ($mailSettings['auth'] === 'XOAuth2'); + return new CRM_Mailing_MailStore_Imap($mailSettings['server'], $mailSettings['username'], $mailSettings['password'], (bool) $mailSettings['is_ssl'], $mailSettings['source'], $useXOAuth2); + }, + ]; case 'POP3': - return new CRM_Mailing_MailStore_Pop3($dao->server, $dao->username, $dao->password, (bool) $dao->is_ssl); + return [ + 'factory' => function ($mailSettings) { + return new CRM_Mailing_MailStore_Pop3($mailSettings['server'], $mailSettings['username'], $mailSettings['password'], (bool) $mailSettings['is_ssl']); + }, + ]; case 'Maildir': - return new CRM_Mailing_MailStore_Maildir($dao->source); + return [ + 'factory' => function ($mailSettings) { + return new CRM_Mailing_MailStore_Maildir($mailSettings['source']); + }, + ]; case 'Localdir': - return new CRM_Mailing_MailStore_Localdir($dao->source); + return [ + 'factory' => function ($mailSettings) { + return new CRM_Mailing_MailStore_Localdir($mailSettings['source']); + }, + ]; // DO NOT USE the mbox transport for anything other than testing // in particular, it does not clear the mbox afterwards - case 'mbox': - return new CRM_Mailing_MailStore_Mbox($dao->source); + return [ + 'factory' => function ($mailSettings) { + return new CRM_Mailing_MailStore_Mbox($mailSettings['source']); + }, + ]; default: - throw new Exception("Unknown protocol {$dao->protocol}"); + return []; } } diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index 06888c960c..bc905d21ba 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -1219,6 +1219,33 @@ abstract class CRM_Utils_Hook { ); } + /** + * This hook is called when loading a mail-store (e.g. IMAP, POP3, or Maildir). + * + * @param array $params + * Most fields correspond to data in the MailSettings entity: + * - id: int + * - server: string + * - username: string + * - password: string + * - is_ssl: bool + * - source: string + * - local_part: string + * + * With a few supplements + * - protocol: string, symbolic protocol name (e.g. "IMAP") + * - factory: callable, the function which instantiates the driver class + * - auth: string, (for some drivers) specify the authentication method (eg "Password" or "XOAuth2") + * + * @return mixed + */ + public static function alterMailStore(&$params) { + return self::singleton()->invoke(['params'], $params, $context, + self::$_nullObject, self::$_nullObject, self::$_nullObject, self::$_nullObject, + 'civicrm_alterMailStore' + ); + } + /** * This hook is called when membership status is being calculated. * diff --git a/tests/phpunit/CRM/Mailing/MailStoreTest.php b/tests/phpunit/CRM/Mailing/MailStoreTest.php new file mode 100644 index 0000000000..ba982b9d35 --- /dev/null +++ b/tests/phpunit/CRM/Mailing/MailStoreTest.php @@ -0,0 +1,91 @@ +useTransaction(TRUE); + parent::setUp(); + $this->workDir = tempnam(sys_get_temp_dir(), 'mailstoretest'); + @unlink($this->workDir); + } + + public function tearDown() { + parent::tearDown(); + if (is_dir($this->workDir)) { + CRM_Utils_File::cleanDir($this->workDir); + } + } + + /** + * Create an example store (maildir) using default behaviors (no hooks). + */ + public function testMaildirBasic() { + $this->createMaildirSettings([ + 'name' => __FUNCTION__, + ]); + $store = CRM_Mailing_MailStore::getStore(__FUNCTION__); + $this->assertTrue($store instanceof CRM_Mailing_MailStore_Maildir); + } + + /** + * Create an example store (maildir) and change the driver via hook. + */ + public function testMaildirHook() { + // This hook swaps out the implementation used for 'Maildir' stores. + Civi::dispatcher() + ->addListener('hook_civicrm_alterMailStore', function ($e) { + if ($e->params['protocol'] === 'Maildir') { + $e->params['factory'] = function ($mailSettings) { + $this->assertEquals('testMaildirHook', $mailSettings['name']); + // Make a fake object that technically meets the contract of 'MailStore' + return new class extends CRM_Mailing_MailStore { + + public function frobnicate() { + return 'totally'; + } + + }; + }; + } + }); + + $this->createMaildirSettings([ + 'name' => __FUNCTION__, + ]); + $store = CRM_Mailing_MailStore::getStore(__FUNCTION__); + + // The hook gave us an unusual instance of MailStore. + $this->assertTrue($store instanceof CRM_Mailing_MailStore); + $this->assertFalse($store instanceof CRM_Mailing_MailStore_Maildir); + $this->assertEquals('totally', $store->frobnicate()); + } + + /** + * Create a "MailSettings" record for maildir store. + * @param array $values + * Some values to set + * @return array + */ + private function createMaildirSettings($values = []):array { + mkdir($this->workDir); + $defaults = [ + 'protocol:name' => 'Maildir', + 'name' => NULL, + 'source' => $this->workDir, + 'domain' => 'maildir.example.com', + 'username' => 'pass-my-name', + 'password' => 'pass-my-pass', + ]; + $mailSettings = \Civi\Api4\MailSettings::create(0) + ->setValues(array_merge($defaults, $values)) + ->execute() + ->single(); + return $mailSettings; + } + +} -- 2.25.1