From 02a47bd1511dc414397f9563129ebba1971d1578 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 14 Jul 2021 01:48:16 -0700 Subject: [PATCH] AddressingTrait - Add a trait to handle setTo(), setFrom(), etc --- .../GenericWorkflowMessage.php | 4 + .../Traits/AddressingTrait.php | 336 ++++++++++++++++++ .../Traits/AddressingTraitTest.php | 215 +++++++++++ 3 files changed, 555 insertions(+) create mode 100644 Civi/WorkflowMessage/Traits/AddressingTrait.php create mode 100644 tests/phpunit/Civi/WorkflowMessage/Traits/AddressingTraitTest.php diff --git a/Civi/WorkflowMessage/GenericWorkflowMessage.php b/Civi/WorkflowMessage/GenericWorkflowMessage.php index 0423dca179..862c3a8d42 100644 --- a/Civi/WorkflowMessage/GenericWorkflowMessage.php +++ b/Civi/WorkflowMessage/GenericWorkflowMessage.php @@ -13,6 +13,7 @@ namespace Civi\WorkflowMessage; use Civi\Schema\Traits\MagicGetterSetterTrait; +use Civi\WorkflowMessage\Traits\AddressingTrait; use Civi\WorkflowMessage\Traits\FinalHelperTrait; use Civi\WorkflowMessage\Traits\ReflectiveWorkflowTrait; @@ -36,6 +37,9 @@ class GenericWorkflowMessage implements WorkflowMessageInterface { // Implement assertValid(), renderTemplate(), sendTemplate() - Sugary stub methods that delegate to real APIs. use FinalHelperTrait; + // Implement setTo(), setReplyTo(), etc + use AddressingTrait; + /** * WorkflowMessage constructor. * diff --git a/Civi/WorkflowMessage/Traits/AddressingTrait.php b/Civi/WorkflowMessage/Traits/AddressingTrait.php new file mode 100644 index 0000000000..ac60155bd5 --- /dev/null +++ b/Civi/WorkflowMessage/Traits/AddressingTrait.php @@ -0,0 +1,336 @@ +' + * - record (array): Pair of name+email, e.g. ['name' => 'Full Name', 'email' => 'user@example.com'] + * - records: (array) List of records, keyed sequentially. + */ +trait AddressingTrait { + + /** + * The primary email recipient (single address). + * + * @var string|null + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * + * The "To:" address is mapped to the "envelope" scope. The existing + * envelope format treats this as a pair of fields [toName,toEmail]. + * Consequently, we only support one "To:" address, and it uses a + * special import/export method. + */ + protected $to; + + /** + * The email sender (single address). + * + * @var string|null + * Ex: '"Foo Bar" ' + * @scope envelope + */ + protected $from; + + /** + * The email sender's Reply-To (single address). + * + * @var string|null + * Ex: '"Foo Bar" ' + * @scope envelope + */ + protected $replyTo; + + /** + * Additional recipients (multiple addresses). + * + * @var string|null + * Ex: '"Foo Bar" , "Whiz Bang" ' + * Ex: [['name' => 'Foo Bar', 'email' => 'foo.bar@example.com'], ['name' => 'Whiz Bang', 'email' => 'whiz.bang@example.com']] + * @scope envelope + */ + protected $cc; + + /** + * Additional recipients (multiple addresses). + * + * @var string|null + * Ex: '"Foo Bar" , "Whiz Bang" ' + * Ex: [['name' => 'Foo Bar', 'email' => 'foo.bar@example.com'], ['name' => 'Whiz Bang', 'email' => 'whiz.bang@example.com']] + * @scope envelope + */ + protected $bcc; + + /** + * Get the list of "To:" addresses. + * + * Note: This returns only + * + * @param string $format + * Ex: 'rfc822', 'records', 'record' + * @return array|string + * Ex: '"Foo Bar" ' + * Ex: ['name' => 'Foo Bar', 'email' => 'foo.bar@example.com'] + */ + public function getTo($format = ADDRESS_EXPORT_FMT) { + return $this->formatAddress($format, $this->to); + } + + /** + * Get the "From:" address. + * + * @param string $format + * Ex: 'rfc822', 'records', 'record' + * @return array|string + * The "From" address. If none set, this will be empty ([]). + * Ex: '"Foo Bar" ' + * Ex: ['name' => 'Foo Bar', 'email' => 'foo.bar@example.com'] + */ + public function getFrom($format = ADDRESS_EXPORT_FMT) { + return $this->formatAddress($format, $this->from); + } + + /** + * Get the "Reply-To:" address. + * + * @param string $format + * Ex: 'rfc822', 'records', 'record' + * @return array|string + * The "From" address. If none set, this will be empty ([]). + * Ex: '"Foo Bar" ' + * Ex: ['name' => 'Foo Bar', 'email' => 'foo.bar@example.com'] + */ + public function getReplyTo($format = ADDRESS_EXPORT_FMT) { + return $this->formatAddress($format, $this->replyTo); + } + + /** + * Get the list of "Cc:" addresses. + * + * @param string $format + * Ex: 'rfc822', 'records', 'record' + * @return array|string + * List of addresses. + * Ex: 'First , second@example.com' + * Ex: [['name' => 'First', 'email' => 'first@example.com'], ['email' => 'second@example.com']] + */ + public function getCc($format = ADDRESS_EXPORT_FMT) { + return $this->formatAddress($format, $this->cc); + } + + /** + * Get the list of "Bcc:" addresses. + * + * @param string $format + * Ex: 'rfc822', 'records', 'record' + * @return array|string + * List of addresses. + * Ex: 'First , second@example.com' + * Ex: [['name' => 'First', 'email' => 'first@example.com'], ['email' => 'second@example.com']] + */ + public function getBcc($format = ADDRESS_EXPORT_FMT) { + return $this->formatAddress($format, $this->bcc); + } + + /** + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * @return $this + */ + public function setFrom($address) { + $this->from = $this->formatAddress(ADDRESS_STORAGE_FMT, $address); + return $this; + } + + /** + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * @return $this + */ + public function setTo($address) { + $this->to = $this->formatAddress(ADDRESS_STORAGE_FMT, $address); + return $this; + } + + /** + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * @return $this + */ + public function setReplyTo($address) { + $this->replyTo = $this->formatAddress(ADDRESS_STORAGE_FMT, $address); + return $this; + } + + /** + * Set the "CC:" list. + * + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * Ex: [['email' => 'first@example.com'], ['email' => 'second@example.com']] + * @return $this + */ + public function setCc($address) { + $this->cc = $this->formatAddress(ADDRESS_STORAGE_FMT, $address); + return $this; + } + + /** + * Set the "BCC:" list. + * + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * Ex: [['email' => 'first@example.com'], ['email' => 'second@example.com']] + * @return $this + */ + public function setBcc($address) { + $this->bcc = $this->formatAddress(ADDRESS_STORAGE_FMT, $address); + return $this; + } + + /** + * Add another "CC:" address. + * + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * Ex: [['email' => 'first@example.com'], ['email' => 'second@example.com']] + * @return $this + */ + public function addCc($address) { + return $this->setCc(array_merge( + $this->getCc('records'), + $this->formatAddress('records', $address) + )); + } + + /** + * Add another "BCC:" address. + * + * @param string|array $address + * Ex: '"Foo Bar" ' + * Ex: ['name' => "Foo Bar", "email" => "foo.bar@example.com"] + * Ex: [['email' => 'first@example.com'], ['email' => 'second@example.com']] + * @return $this + */ + public function addBcc($address) { + return $this->setBcc(array_merge( + $this->getBcc('records'), + $this->formatAddress('records', $address) + )); + } + + /** + * Plugin to `WorkflowMessageInterface::import()` and handle toEmail/toName. + * + * @param array $values + * @see \Civi\WorkflowMessage\Traits\ReflectiveWorkflowTrait::import + */ + protected function importExtraEnvelope_toAddress(array &$values): void { + if (array_key_exists('toEmail', $values) || array_key_exists('toName', $values)) { + $this->setTo(['name' => $values['toName'] ?? NULL, 'email' => $values['toEmail'] ?? NULL]); + unset($values['toName']); + unset($values['toEmail']); + } + } + + /** + * Plugin to `WorkflowMessageInterface::export()` and handle toEmail/toName. + * + * @param array $values + * @see \Civi\WorkflowMessage\Traits\ReflectiveWorkflowTrait::export + */ + protected function exportExtraEnvelope_toAddress(array &$values): void { + $addr = $this->getTo('record'); + $values['toName'] = $addr['name'] ?? NULL; + $values['toEmail'] = $addr['email'] ?? NULL; + } + + /** + * Convert an address to the desired format. + * + * @param string $newFormat + * Ex: 'rfc822', 'records', 'record' + * @param array|string $mixed + * @return array|string|null + */ + private function formatAddress($newFormat, $mixed) { + if ($mixed === NULL) { + return NULL; + } + + $oldFormat = is_string($mixed) ? 'rfc822' : (array_key_exists('email', $mixed) ? 'record' : 'records'); + if ($oldFormat === $newFormat) { + return $mixed; + } + + $recordToObj = function (?array $record) { + return new \ezcMailAddress($record['email'], $record['name'] ?? ''); + }; + $objToRecord = function (?\ezcMailAddress $addr) { + return is_null($addr) ? NULL : ['email' => $addr->email, 'name' => $addr->name]; + }; + + // Convert $mixed to intermediate format (ezcMailAddress[] $objects) and then to final format. + + /** @var \ezcMailAddress[] $objects */ + + switch ($oldFormat) { + case 'rfc822': + $objects = \ezcMailTools::parseEmailAddresses($mixed); + break; + + case 'record': + $objects = [$recordToObj($mixed)]; + break; + + case 'records': + $objects = array_map($recordToObj, $mixed); + break; + + default: + throw new \RuntimeException("Unrecognized source format: $oldFormat"); + } + + switch ($newFormat) { + case 'rfc822': + // We use `implode(map(composeEmailAddress))` instead of `composeEmailAddresses` because the latter has header-line-wrapping. + return implode(', ', array_map(['ezcMailTools', 'composeEmailAddress'], $objects)); + + case 'record': + if (count($objects) > 1) { + throw new \RuntimeException("Cannot convert email addresses to record format. Too many addresses."); + } + return $objToRecord($objects[0] ?? NULL); + + case 'records': + return array_map($objToRecord, $objects); + + default: + throw new \RuntimeException("Unrecognized output format: $newFormat"); + } + } + +} diff --git a/tests/phpunit/Civi/WorkflowMessage/Traits/AddressingTraitTest.php b/tests/phpunit/Civi/WorkflowMessage/Traits/AddressingTraitTest.php new file mode 100644 index 0000000000..6ad3cfb79c --- /dev/null +++ b/tests/phpunit/Civi/WorkflowMessage/Traits/AddressingTraitTest.php @@ -0,0 +1,215 @@ +useTransaction(); + parent::setUp(); + } + + /** + * @return \Civi\WorkflowMessage\GenericWorkflowMessage + */ + protected static function createExample() { + return new class() extends GenericWorkflowMessage { + + const WORKFLOW = 'ex_address_wf'; + + const GROUP = 'ex_address_grp'; + }; + } + + /** + * Set email addresses using fluent methods (setTo(), setCc(), etc). + */ + public function testFluentSetup() { + $wfm = $this->createExample() + // Setters support array or string inputs. All address fields support the same formats. + ->setTo(['name' => 'Foo', 'email' => 'foo@example.com']) + ->setReplyTo('Nobody ') + ->setCc([['email' => 'cc1@example.com'], ['name' => 'Bob', 'email' => 'cc2@example.com']]); + + $this->assertEquals(['email' => 'nobody@example.com', 'name' => 'Nobody'], $wfm->getReplyTo('record')); + + $export = $wfm->export('envelope'); + + $this->assertEquals('Foo', $export['toName']); + $this->assertEquals('foo@example.com', $export['toEmail']); + $this->assertEquals('cc1@example.com, Bob ', $export['cc']); + $this->assertEquals('Nobody ', $export['replyTo']); + } + + /** + * Set email addresses using fluent methods (setTo(), setCc(), etc). + */ + public function testFluentAdder() { + $wfm = $this->createExample() + // Setters support array or string inputs. All address fields support the same formats. + ->setCc([['email' => 'cc1@example.com'], ['name' => 'Bob', 'email' => 'cc2@example.com']]) + ->addCc('"Third" ') + ->addCc(['email' => 'cc4@example.com', 'name' => 'Fourth\'s']); + + $this->assertEquals('cc1@example.com, Bob , Third , "Fourth\'s" ', $wfm->getCc('rfc822')); + } + + /** + * Set email addresses using model-properties. + */ + public function testModelPropsSetup() { + $wfm = $this->createExample() + ->import('modelProps', [ + // modelProps support array or string inputs. All address fields support the same formats. + 'to' => ['name' => 'Foo', 'email' => 'foo@example.com'], + 'replyTo' => 'Nobody ', + 'cc' => [['email' => 'cc1@example.com'], ['name' => 'Bob', 'email' => 'cc2@example.com']], + ]); + + $this->assertEquals(['email' => 'nobody@example.com', 'name' => 'Nobody'], $wfm->getReplyTo('record')); + + $export = $wfm->export('envelope'); + + $this->assertEquals('Foo', $export['toName']); + $this->assertEquals('foo@example.com', $export['toEmail']); + $this->assertEquals('cc1@example.com, Bob ', $export['cc']); + $this->assertEquals('Nobody ', $export['replyTo']); + } + + /** + * Set email addresses using sendTemplate()'s envelope format. + */ + public function testEnvelopeSetup() { + $ex = $this->createExample(); + + $envelopeArray = [ + 'toName' => "It's Me", + 'toEmail' => 'me@example.com', + 'from' => '', + 'replyTo' => '"Reply To Me" ', + 'cc' => 'cc1@example.com, , "Third" ', + ]; + + $ex->import('envelope', $envelopeArray); + $this->assertEquals(['name' => "It's Me", 'email' => 'me@example.com'], $ex->getTo('record')); + $this->assertEquals(['name' => NULL, 'email' => 'from@example.com'], $ex->getFrom('record')); + $this->assertEquals(['name' => 'Reply To Me', 'email' => 'replyto@example.com'], $ex->getReplyTo('record')); + $this->assertEquals([ + ['name' => NULL, 'email' => 'cc1@example.com'], + ['name' => NULL, 'email' => 'cc2@example.com'], + ['name' => 'Third', 'email' => 'cc3@example.com'], + ], $ex->getCc('records')); + + $actualExport = $ex->export('envelope'); + foreach ($envelopeArray as $key => $value) { + $this->assertEquals($value, $actualExport[$key], "Key '$key' should match"); + } + } + + public function testSingularValues() { + $ex = $this->createExample(); + + $singularFields = ['to', 'from', 'replyTo', 'cc', 'bcc']; + + $singularExamples = []; + $singularExamples[] = [ + 'rfc822' => 'Foo Bar ', + 'record' => ['name' => 'Foo Bar', 'email' => 'foo@example.com'], + 'records' => [['name' => 'Foo Bar', 'email' => 'foo@example.com']], + ]; + $singularExamples[] = [ + 'rfc822' => 'foo@example.com', + 'record' => ['name' => NULL, 'email' => 'foo@example.com'], + 'records' => [['name' => NULL, 'email' => 'foo@example.com']], + ]; + + $this->assertNotEmpty($singularFields); + foreach ($singularFields as $field) { + $setter = 'set' . ucfirst($field); + $getter = 'get' . ucfirst($field); + + $this->assertNotEmpty($singularExamples); + foreach ($singularExamples as $equivalenceSet) { + $this->assertNotEmpty($equivalenceSet); + foreach (array_keys($equivalenceSet) as $inFormat) { + foreach (array_keys($equivalenceSet) as $outFormat) { + $ex->{$setter}(NULL); + $this->assertEquals(NULL, $ex->{$getter}(), "Field ($field) should start at empty"); + + $ex->{$setter}($equivalenceSet[$inFormat]); + $this->assertEquals($equivalenceSet[$outFormat], $ex->{$getter}($outFormat), "Field ($field) should return equivalent result (method=setter, in=$inFormat, out=$outFormat)"); + + if ($field !== 'to') { + $export = $ex->export('envelope'); + $this->assertEquals($equivalenceSet['rfc822'], $export[$field], 'export() always produces header format'); + + $ex->{$setter}(NULL); + $this->assertEquals(NULL, $ex->{$getter}(), "Field ($field) should start at empty"); + + $ex->import('envelope', [$field => $equivalenceSet[$inFormat]]); + $this->assertEquals($equivalenceSet[$outFormat], $ex->{$getter}($outFormat), "Field ($field) should return equivalent result (method=import, in=$inFormat, out=$outFormat)"); + } + } + } + } + } + } + + public function testPluralValues() { + $ex = $this->createExample(); + + $pluralFields = ['cc', 'bcc']; + + $pluralExamples = []; + $pluralExamples[] = [ + 'rfc822' => 'First , "Second\'s Name" , third@example.com', + 'records' => [ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => "Second's Name", 'email' => 'second@example.com'], + ['name' => NULL, 'email' => 'third@example.com'], + ], + ]; + $pluralExamples[] = [ + 'rfc822' => '', + 'records' => [], + ]; + + $this->assertNotEmpty($pluralFields); + foreach ($pluralFields as $field) { + $setter = 'set' . ucfirst($field); + $getter = 'get' . ucfirst($field); + + $this->assertNotEmpty($pluralExamples); + foreach ($pluralExamples as $equivalenceSet) { + $this->assertNotEmpty($equivalenceSet); + foreach (array_keys($equivalenceSet) as $inFormat) { + foreach (array_keys($equivalenceSet) as $outFormat) { + $ex->{$setter}(NULL); + $this->assertEquals(NULL, $ex->{$getter}(), "Field ($field) should start at empty"); + + $ex->{$setter}($equivalenceSet[$inFormat]); + $this->assertEquals($equivalenceSet[$outFormat], $ex->{$getter}($outFormat), "Field ($field) should return equivalent result (method=setter, in=$inFormat, out=$outFormat)"); + + $ex->{$setter}(NULL); + $this->assertEquals(NULL, $ex->{$getter}(), "Field ($field) should start at empty"); + + $ex->import('envelope', [$field => $equivalenceSet[$inFormat]]); + $this->assertEquals($equivalenceSet[$outFormat], $ex->{$getter}($outFormat), "Field ($field) should return equivalent result (method=import, in=$inFormat, out=$outFormat)"); + } + } + } + } + } + +} -- 2.25.1