From 877d64493fcebd2ae4f2d885e7e147913edcd422 Mon Sep 17 00:00:00 2001 From: Jon Mahoney Date: Tue, 6 Sep 2016 23:14:20 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 + .travis.yml | 14 ++ composer.json | 37 ++++ phpunit.xml.dist | 14 ++ src/Client.php | 241 +++++++++++++++++++++++ src/Exception.php | 6 + src/Models/Address.php | 39 ++++ src/Models/Attachment.php | 55 ++++++ src/Models/Email.php | 180 +++++++++++++++++ src/Models/Image.php | 28 +++ src/Models/Link.php | 28 +++ tests/Mailosaur/ClientTest.php | 340 +++++++++++++++++++++++++++++++++ tests/bootstrap.php | 6 + tests/logo-m-circle-sm.png | Bin 0 -> 5260 bytes tests/logo-m.png | Bin 0 -> 4819 bytes 15 files changed, 992 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/Client.php create mode 100644 src/Exception.php create mode 100644 src/Models/Address.php create mode 100644 src/Models/Attachment.php create mode 100644 src/Models/Email.php create mode 100644 src/Models/Image.php create mode 100644 src/Models/Link.php create mode 100644 tests/Mailosaur/ClientTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/logo-m-circle-sm.png create mode 100644 tests/logo-m.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c56bea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.vendor/ +composer.lock +composer.phar \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0225652 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: php + +php: + - 5.3 + - 5.4 + - 5.5 + - 5.6 + - 7.0 + - 7.1 + +script: phpunit + +before_install: + wget http://getcomposer.org/composer.phar && php composer.phar install \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9b9b816 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "mailosaurapp/mailosaur-php", + "description": "Mailosaur API client library", + "minimum-stability": "dev", + "type": "library", + "autoload": { + "psr-4": { + "Mailosaur\\": "src" + } + }, + "require-dev": { + "phpunit/phpunit": "4.0.*@dev", + "fzaninotto/faker": "1.6.x-dev", + "nette/utils" : "v2.3", + "nette/mail" : "v2.3.5" + }, + "keywords": [ + "email", + "testing", + "mailosaur", + "automation" + ], + "require": { + "php": ">=5.3.3" + }, + "license": "MIT", + "authors": [ + { + "name": "Boris Yu", + "email": "admin@xdevel.info" + }, + { + "name": "Mailosaur Ltd", + "email": "support@mailosaur.com" + } + ] +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e40e663 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + + ./tests/Mailosaur + + + + + /vendor + + + \ No newline at end of file diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..2af4013 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,241 @@ +mailbox = $mailbox; + $this->key = $key; + + if ($apiUrl !== null) { + $this->apiUrl = $apiUrl; + } + } + + /** + * Get email by id + * + * @param string $id email id + * + * @return \Mailosaur\Models\Email + */ + public function getEmail($id) + { + $email = $this->request('/emails/' . urlencode($id)); + $email = json_decode($email); + + return Models\Email::fillFromResponse($email); + } + + /** + * List all email
+ * Returns a list of emails in this mailbox.
+ * The email returned is sorted by receipt date, with the most recent email appearing first. + * + * @return Models\Email[] + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#list-email List all email documentation + * @example https://mailosaur.com/docs/email#list-email + */ + public function getEmails() + { + $emails = $this->request('/mailboxes/' . $this->mailbox . '/emails'); + $emails = json_decode($emails); + + return $this->wrapEmails($emails); + } + + /** + * Get all emails for recipient
+ * You’ll usually know what address you send a mail to.
+ * Use can use this information to return matches of that address. + * + * @param string $recipient recipient email address + * + * @return Models\Email[] + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#list-email-recipient List email by recipient documentation + * @example https://mailosaur.com/docs/email#list-email-recipient + */ + public function getEmailsByRecipient($recipient) + { + $emails = $this->request('/mailboxes/' . $this->mailbox . '/emails?recipient=' . urlencode($recipient)); + $emails = json_decode($emails); + + return $this->wrapEmails($emails); + } + + /** + * List email by search pattern
+ * These examples show you how to fetch all email where the body or subject matches the search pattern provided. + * + * @param string $pattern search pattern + * + * @return Models\Email[] + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#list-email-search List email by search pattern documentation + * @example https://mailosaur.com/docs/email#list-email-search + */ + public function getEmailsBySearchPattern($pattern) + { + $emails = $this->request('/mailboxes/' . $this->mailbox . '/emails?search=' . urlencode($pattern)); + $emails = json_decode($emails); + + return $this->wrapEmails($emails); + } + + /** + * Downloads an attachment. + * + * @param string $attachmentId attachment id + * + * @return string + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#attachment Downloads an attachment documentation + * @example https://mailosaur.com/docs/email#attachment + */ + public function getAttachment($attachmentId) + { + return $this->request('/attachments/' . urlencode($attachmentId)); + } + + /** + * Download EML + * + * @param string $id email id + * + * @return string + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#eml Download EML documentation + * @example https://mailosaur.com/docs/email#eml + */ + public function getEML($id) + { + return $this->request('/raw/' . urlencode($id)); + } + + /** + * Delete email by id + * + * @param string $id email id + * + * @return void + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#delete Delete an email documentation + * @example https://mailosaur.com/docs/email#delete + */ + public function deleteEmail($id) + { + $this->request('/emails/' . urlencode($id), array(CURLOPT_CUSTOMREQUEST => 'DELETE')); + } + + /** + * Empty mailbox
+ * Permanently deletes all email in the specified mailbox.
+ * This cannot be undone. + * + * @return void + * @throws \Mailosaur\Exception + * @see https://mailosaur.com/docs/email#empty Empty mailbox documentation + * @example https://mailosaur.com/docs/email#empty + */ + public function emptyMailBox() + { + $this->request('/mailboxes/' . $this->mailbox . '/empty/', array(CURLOPT_CUSTOMREQUEST => 'POST')); + } + + /** + * Perform request to api + * + * @param string $path api path + * @param array $options additional curl options to set + * + * @return string + * @throws \Mailosaur\Exception + */ + protected function request($path, array $options = array()) + { + $curl = curl_init($this->apiUrl . $path); + + if (count($options) > 0) { + foreach ($options as $name => $value) { + curl_setopt($curl, $name, $value); + } + } + + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curl, CURLOPT_USERPWD, $this->key . ':'); + + $response = curl_exec($curl); + $requestState = curl_getinfo($curl); + + if ($requestState['http_code'] != 200 && $requestState['http_code'] != 204) { + throw new Exception('Bad request. Check your credentials.'); + } + + return $response; + } + + /** + * Create email objects from response data + * + * @param array $responseData + * + * @return Email[] + */ + public function wrapEmails(array $responseData) + { + $emails = array(); + + foreach ($responseData as $email) { + $emails[] = Models\Email::fillFromResponse($email); + } + + return $emails; + } + + /** + * Generate random email address + * + * @return string + */ + public function generateEmailAddress() + { + return mt_rand(0, 1000000) . '-' . mt_rand(2000000, 4000000) . '.' . $this->mailbox . '@mailosaur.io'; + } +} \ No newline at end of file diff --git a/src/Exception.php b/src/Exception.php new file mode 100644 index 0000000..0aa4ad4 --- /dev/null +++ b/src/Exception.php @@ -0,0 +1,6 @@ +address = $address->address; + } + + if (property_exists($address, 'name')) { + $this->name = $address->name; + } + } + } + + /** + * Get full email address + * + * @return string + */ + public function getFullAddress() + { + return $this->name . ' <' . $this->address . '>'; + } +} \ No newline at end of file diff --git a/src/Models/Attachment.php b/src/Models/Attachment.php new file mode 100644 index 0000000..39d0d33 --- /dev/null +++ b/src/Models/Attachment.php @@ -0,0 +1,55 @@ +id = $attachment->id; + } + + if (property_exists($attachment, 'contentType')) { + $this->contentType = $attachment->contentType; + } + + if (property_exists($attachment, 'fileName')) { + $this->fileName = $attachment->fileName; + } + + if (property_exists($attachment, 'length')) { + $this->length = (int)$attachment->length; + } + + if (property_exists($attachment, 'contentId')) { + $this->contentId = $attachment->contentId; + } + } + } + +} \ No newline at end of file diff --git a/src/Models/Email.php b/src/Models/Email.php new file mode 100644 index 0000000..e02a31c --- /dev/null +++ b/src/Models/Email.php @@ -0,0 +1,180 @@ +id = $response->id; + } + + if (property_exists($response, 'rawid')) { + $email->rawId = $response->rawid; + } + + if (property_exists($response, 'creationdate')) { + $email->creationDate = new \DateTime($response->creationdate); + } + + if (property_exists($response, 'senderhost')) { + $email->senderHost = $response->senderhost; + } + + if (property_exists($response, 'mailbox')) { + $email->mailbox = $response->mailbox; + } + + if (property_exists($response, 'from') && is_array($response->from) && count($response->from) > 0) { + foreach ($response->from as $from) { + $email->from[] = new Address($from); + } + } + + if (property_exists($response, 'to') && is_array($response->to) && count($response->to) > 0) { + foreach ($response->to as $to) { + $email->to[] = new Address($to); + } + } + + if (property_exists($response, 'html')) { + if (property_exists($response->html, 'body')) { + $email->html = $response->html->body; + } + + if (property_exists($response->html, 'links') && is_array($response->html->links) && count($response->html->links) > 0) { + foreach ($response->html->links as $link) { + $email->htmlLinks[] = new Link($link); + } + } + + if (property_exists($response->html, 'images') && is_array($response->html->images) && count($response->html->images) > 0) { + foreach ($response->html->images as $image) { + $email->images[] = new Image($image); + } + } + } + + if (property_exists($response, 'text')) { + if (property_exists($response->text, 'body')) { + $email->text = $response->text->body; + } + + if (property_exists($response->text, 'links') && is_array($response->text->links) && count($response->text->links) > 0) { + foreach ($response->text->links as $link) { + $email->textLinks[] = new Link($link); + } + } + } + + if (property_exists($response, 'headers')) { + $email->headers = clone $response->headers; + } + + if (property_exists($response, 'subject')) { + $email->subject = $response->subject; + } + + if (property_exists($response, 'priority')) { + $email->priority = $response->priority; + } + + if (property_exists($response, 'attachments') && is_array($response->attachments) && count($response->attachments) > 0) { + foreach ($response->attachments as $attachment) { + $email->attachments[] = new Attachment($attachment); + } + } + + return $email; + } + +} \ No newline at end of file diff --git a/src/Models/Image.php b/src/Models/Image.php new file mode 100644 index 0000000..1f8ba7b --- /dev/null +++ b/src/Models/Image.php @@ -0,0 +1,28 @@ +src = $image->src; + } + + if (property_exists($image, 'alt')) { + $this->alt = $image->alt; + } + } + } +} \ No newline at end of file diff --git a/src/Models/Link.php b/src/Models/Link.php new file mode 100644 index 0000000..a6aff3d --- /dev/null +++ b/src/Models/Link.php @@ -0,0 +1,28 @@ +href = $link->href; + } + + if (property_exists($link, 'text')) { + $this->text = $link->text; + } + } + } +} \ No newline at end of file diff --git a/tests/Mailosaur/ClientTest.php b/tests/Mailosaur/ClientTest.php new file mode 100644 index 0000000..26d9e03 --- /dev/null +++ b/tests/Mailosaur/ClientTest.php @@ -0,0 +1,340 @@ +mailbox = getenv('MAILOSAUR_MAILBOX_ID'); + $this->apiUrl = getenv('MAILOSAUR_BASE_URL'); + $this->apiKey = getenv('MAILOSAUR_API_KEY'); + + !getenv('MAILOSAUR_MAILBOX_PASSWORD') || $this->password = getenv('MAILOSAUR_MAILBOX_PASSWORD'); + !getenv('MAILOSAUR_SMTP_HOST') || $this->smtpHost = getenv('MAILOSAUR_SMTP_HOST'); + !getenv('MAILOSAUR_SMTP_PORT') || $this->smtpPort = getenv('MAILOSAUR_SMTP_PORT'); + !getenv('MAILOSAUR_USE_GENERATE') || $this->useGenerateEmail = true; + + $this->client = new Client($this->apiKey, $this->mailbox, $this->apiUrl); + $this->mailClient = new SmtpMailer(array( + 'host' => $this->smtpHost, + 'port' => $this->smtpPort, + 'username' => $this->mailbox, + 'password' => $this->password + )); + } + + public function testGenerateEmails() + { + $emails = array(); + for ($i = 0; $i < 1000; $i++) { + $address = $this->client->generateEmailAddress(); + if (!in_array($address, $emails)) { + $emails[] = $address; + } + } + + self::assertCount(1000, $emails); + } + + public function testInitial() + { + $this->client->emptyMailBox(); + + self::assertNotFalse($this->mailbox, 'mailbox is not set.'); + self::assertNotFalse($this->apiUrl, 'api url is not set.'); + self::assertNotFalse($this->apiKey, 'api key is not set.'); + self::assertNotFalse($this->smtpHost, 'smtp host is not set.'); + self::assertNotFalse($this->password, 'smtp connection password is not set.'); + self::assertCount(0, $this->client->getEmails()); + } + + /** + * @depends testInitial + */ + public function testGetEmails() + { + $this->sendTestEmail(); + $second = $this->sendTestEmail(); + $this->sendTestEmail(); + + $emails = $this->client->getEmails(); + + self::assertCount(3, $emails); + self::assertEmail($second, $emails[1]); + } + + /** + * @depends testInitial + */ + public function testGetEmailsBySearchPattern() + { + $this->sendTestEmail(); + $second = $this->sendTestEmail(); + $this->sendTestEmail(); + + $emails = $this->client->getEmailsBySearchPattern($second['subject']); + + self::assertCount(1, $emails); + self::assertEmail($second, $emails[0]); + } + + + /** + * @depends testInitial + */ + public function testGetEmailsByRecipient() + { + $this->sendTestEmail(); + $second = $this->sendTestEmail(); + $this->sendTestEmail(); + + $emails = $this->client->getEmailsByRecipient($second['to']->address); + + self::assertCount(1, $emails); + self::assertEmail($second, $emails[0]); + } + + + /** + * @depends testInitial + */ + public function testGetEmail() + { + $emailInfo = $this->sendTestEmail(); + + $emails = $this->client->getEmails(); + + self::assertCount(1, $emails); + + $email = $this->client->getEmail($emails[0]->id); + + $this->assertEmail($emailInfo, $email); + } + + public static function assertEmail($emailInfo, $email) + { + self::assertInstanceOf('\Mailosaur\Models\Email', $email); + self::assertInstanceOf('\Mailosaur\Models\Address', $email->to[0]); + self::assertInstanceOf('\Mailosaur\Models\Address', $email->from[0]); + self::assertInstanceOf('\Mailosaur\Models\Attachment', $email->attachments[0]); + self::assertInstanceOf('\DateTime', $email->creationDate); + self::assertInstanceOf('\stdClass', $email->headers); + + self::assertCount(1, $email->to); + self::assertEquals($emailInfo['to'], $email->to[0]); + + self::assertCount(1, $email->from); + self::assertEquals($emailInfo['from'], $email->from[0]); + + self::assertEquals($emailInfo['subject'], $email->subject); + self::assertEquals('normal', $email->priority); + + self::assertEquals($emailInfo['html'], $email->html); + self::assertEquals($emailInfo['text'], $email->text); + + self::assertEquals(count($emailInfo['textLinks']), count($email->textLinks)); + + foreach ($email->textLinks as $key => $link) { + self::assertArrayHasKey($key, $emailInfo['textLinks']); + self::assertEquals($emailInfo['textLinks'][$key], $link->href); + } + + self::assertEquals(count($emailInfo['htmlLinks']), count($email->htmlLinks)); + + foreach ($email->htmlLinks as $key => $link) { + self::assertArrayHasKey($key, $emailInfo['htmlLinks']); + self::assertEquals($emailInfo['htmlLinks'][$key]['href'], $link->href); + self::assertEquals($emailInfo['htmlLinks'][$key]['text'], $link->text); + } + + self::assertEquals(count($emailInfo['images']), count($email->images)); + self::assertEquals(count($emailInfo['images']) + count($emailInfo['attachments']), count($email->attachments)); + self::assertEquals(count($emailInfo['attachments']), count($email->attachments) - count($email->images)); + + foreach ($email->images as $image) { + self::assertArrayHasKey($image->alt, $emailInfo['images']); + self::assertStringStartsWith('cid:', $image->src); + } + + foreach ($email->attachments as $attachment) { + if (!empty($attachment->contentId)) { + self::assertArrayHasKey($attachment->fileName, $emailInfo['attachments']); + self::assertEquals(filesize($emailInfo['attachments'][$attachment->fileName]), $attachment->length); + self::assertEquals(mime_content_type($emailInfo['attachments'][$attachment->fileName]), $attachment->contentType); + } + } + + } + + public function testEmptyMailbox() + { + $this->sendTestEmail(); + $this->sendTestEmail(); + + $this->client->emptyMailBox(); + + self::assertCount(0, $this->client->getEmails()); + } + + /** + * @depends testInitial + */ + public function testDeleteEmail() + { + $first = $this->sendTestEmail(); + $this->sendTestEmail(); + + $emailsFiltered = $this->client->getEmailsByRecipient($first['to']->address); + + self::assertEquals(2, count($this->client->getEmails())); + self::assertEquals(1, count($emailsFiltered)); + self::assertInstanceOf('\Mailosaur\Models\Email', $this->client->getEmail($emailsFiltered[0]->id)); + + $this->client->deleteEmail($emailsFiltered[0]->id); + + self::assertEquals(0, count($this->client->getEmailsByRecipient($first['to']->address))); + } + + /** + * @depends testInitial + */ + public function testGetEML() + { + $first = $this->sendTestEmail(); + $this->sendTestEmail(); + + $emailsFiltered = $this->client->getEmailsByRecipient($first['to']->address); + + self::assertEquals(2, count($this->client->getEmails())); + self::assertEquals(1, count($emailsFiltered)); + self::assertInstanceOf('\Mailosaur\Models\Email', $this->client->getEmail($emailsFiltered[0]->id)); + + self::assertNotEmpty($this->client->getEML($emailsFiltered[0]->rawId)); + } + + /** + * @depends testInitial + */ + public function testGetAttachment() + { + $first = $this->sendTestEmail(); + $this->sendTestEmail(); + + $emailsFiltered = $this->client->getEmailsByRecipient($first['to']->address); + + self::assertEquals(2, count($this->client->getEmails())); + self::assertEquals(1, count($emailsFiltered)); + self::assertInstanceOf('\Mailosaur\Models\Email', $this->client->getEmail($emailsFiltered[0]->id)); + + $attachment = $this->client->getAttachment($emailsFiltered[0]->attachments[0]->id); + + self::assertNotEmpty($attachment); + self::assertEquals($emailsFiltered[0]->attachments[0]->length, strlen($attachment)); + } + + public function sendTestEmail() + { + $emailInfo = $this->createTestData(); + + $message = new Message(); + $message + ->setFrom($emailInfo['from']->getFullAddress()) + ->addTo($emailInfo['to']->getFullAddress()) + ->setSubject($emailInfo['subject']) + ->setHtmlBody($emailInfo['html'], __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR) + ->setBody($emailInfo['text']); + + foreach ($emailInfo['attachments'] as $attachment) { + $message->addAttachment($attachment); + } + + $emailInfo['html'] = $message->getHtmlBody(); + + $this->mailClient->send($message); + + return $emailInfo; + } + + public function createTestData() + { + $faker = \Faker\Factory::create(); + + $htmlLinks = array(); + $textLinks = array(); + + for ($i = 0; $i < 3; $i++) { + $htmlLinks[] = array( + 'href' => $faker->url, + 'text' => $faker->word, + ); + } + + for ($i = 0; $i < 2; $i++) { + $textLinks[] = $faker->url; + } + + $emailInfo = array( + 'from' => new Address((object)array('address' => $faker->email, 'name' => $faker->name)), + 'to' => new Address((object)array('address' => $this->useGenerateEmail ? $this->client->generateEmailAddress() : $faker->email, 'name' => $faker->name)), + 'subject' => $faker->text(50), + 'html' => '', + 'text' => $faker->text(200), + 'htmlLinks' => $htmlLinks, + 'textLinks' => $textLinks, + 'images' => array( + $faker->md5 => 'logo-m-circle-sm.png', + $faker->md5 => 'logo-m.png' + ), + 'attachments' => array( + 'logo-m-circle-sm.png' => __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'logo-m-circle-sm.png', + 'logo-m.png' => __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'logo-m.png' + ), + ); + + array_walk($htmlLinks, function (&$v, $k) { $v = '' . $v['text'] . ''; }); + + + $emailInfo['html'] .= $faker->text(200); + $emailInfo['html'] .= ' html links: '; + + $emailInfo['html'] .= implode(' ', $htmlLinks); + + $emailInfo['text'] .= ' text links: '; + $emailInfo['text'] .= implode(' ', $textLinks); + + foreach ($emailInfo['images'] as $alt => $src) { + $emailInfo['html'] .= '' . $alt . ''; + } + + $this->emailTestData = $emailInfo; + + return $emailInfo; + } + + public function tearDown() + { + $this->client->emptyMailBox(); + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..45d43b9 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +X+uL$Nkc;* zP;zf(X>4Tx07wm;mUmQB*%pV-y*Itk5+Wca^cs2zAksTX6$DXM^`x7XQc?|s+0 z08spb1j2M!0f022SQPH-!CVp(%f$Br7!UytSOLJ{W@ZFO_(THK{JlMynW#v{v-a*T zfMmPdEWc1DbJqWVks>!kBnAKqMb$PuekK>?0+ds;#ThdH1j_W4DKdsJG8Ul;qO2n0 z#IJ1jr{*iW$(WZWsE0n`c;fQ!l&-AnmjxZO1uWyz`0VP>&nP`#itsL#`S=Q!g`M=rU9)45( zJ;-|dRq-b5&z?byo>|{)?5r=n76A4nTALlSzLiw~v~31J<>9PP?;rs31pu_(obw)r zY+jPY;tVGXi|p)da{-@gE-UCa`=5eu%D;v=_nFJ?`&K)q7e9d`Nfk3?MdhZarb|T3 z%nS~f&t(1g5dY)AIcd$w!z`Siz!&j_=v7hZlnI21XuE|xfmo0(WD10T)!}~_HYW!e zew}L+XmwuzeT6wtxJd`dZ#@7*BLgIEKY9Xv>st^p3dp{^Xswa2bB{85{^$B13tWnB z;Y>jyQ|9&zk7RNsqAVGs--K+z0uqo1bf5|}fi5rtEMN^BfHQCd-XH*kfJhJnmIE$G z0%<@5vOzxB0181d*a3EfYH$G5fqKvcPJ%XY23!PJzzuK<41h;K3WmW;Fah3yX$XSw z5EY_9s*o0>51B&N5F1(uc|$=^I1~fLLy3?Ol0f;;Ca4%HgQ}rJP(Ab`bQ-z{U4#0d z2hboi2K@njgb|nm(_szR0JebHusa+GN5aeCM0gdP2N%HG;Yzp`J`T6S7vUT504#-H z!jlL<$Or?`Mpy_N@kBz9SR?@vA#0H$qyni$nvf2p8@Y{0k#Xb$28W?xm>3qu8RLgp zjNxKdVb)?wFx8l2m{v>|<~C*!GlBVnrDD~wrdTJeKXwT=5u1%I#8zOBU|X=4u>;s) z>^mF|$G{ol9B_WP7+f-LHLe7=57&&lfa}8z;U@8Tyei%l?}87(bMRt(A-)QK9Dg3) zj~~XrCy)tR1Z#p1A(kK{Y$Q|=8VKhI{e%(1G*N-5Pjn)N5P8I0VkxnX*g?EW941ba z6iJ387g8iCnY4jaNopcpCOsy-A(P2EWJhusSwLP-t|XrzUnLKcKTwn?CKOLf97RIe zPB}`sKzTrUL#0v;sBY9)s+hW+T2H-1eM)^VN0T#`^Oxhvt&^*fYnAJldnHel*Ozyf zUoM{~Um<@={-*r60#U(0!Bc^wuvVc);k3d%g-J!4qLpHZVwz%!VuRu}#Ze`^l7W)9 z5>Kf>>9Eozr6C$Z)1`URxU@~QI@)F0FdauXr2Es8>BaOP=)Lp_WhG@>R;lZ?BJkMlIuMhw8ApiF&yDYW2hFJ?fJhni{?u z85&g@mo&yT8JcdI$(rSw=QPK(Xj%)k1X|@<=e1rim6`6$RAwc!i#egKuI;BS(LSWz zt39n_sIypSqfWEV6J3%nTQ@-4i zi$R;gsG*9XzhRzXqv2yCs*$VFDx+GXJH|L;wsDH_KI2;^u!)^Xl1YupO;gy^-c(?^ z&$Q1BYvyPsG^;hc$D**@Sy`+`)}T4VJji^bd7Jqw3q6Zii=7tT7GEswEK@D(EFW1Z zSp`^awCb?>!`j4}Yh7b~$A)U-W3$et-R8BesV(1jzwLcHnq9En7Q0Tn&-M=XBKs!$ zF$X<|c!#|X_tWYh)GZit z(Q)Cp9CDE^WG;+fcyOWARoj*0TI>4EP1lX*cEoMO-Pk?Z{kZ!p4@(b`M~lalr<3Oz z&kJ6Nm#vN_+kA5{dW4@^Vjg_`q%qU1ULk& z3Fr!>1V#i_2R;ij2@(Z$1jE4r!MlPVFVbHmT+|iPIq0wy5aS{>yK?9ZAjVh%SOwMWgFjair&;wpi!{CU}&@N=Eg#~ zLQ&zpEzVmGY{hI9Z0+4-0xS$$Xe-OToc?Y*V;rTcf_ zb_jRe-RZjXSeas3UfIyD;9afd%<`i0x4T#DzE)vdabOQ=k7SRuGN`h>O0Q~1)u-yD z>VX=Mn&!Rgd$;YK+Q-}1zu#?t(*cbG#Ronf6db&N$oEidtwC+YVcg-Y!_VuY>bk#Y ze_ww@?MU&F&qswvrN_dLb=5o6*Egs)ls3YRlE$&)amR1{;Ppd$6RYV^Go!iq1UMl% z@#4q$AMc(FJlT1QeX8jv{h#)>&{~RGq1N2iiMFIRX?sk2-|2wUogK~{EkB$8eDsX= znVPf8XG_nK&J~=SIiGia@9y}|z3FhX{g&gcj=lwb=lWgyFW&aLedUh- zof`v-2Kw$UzI*>(+&$@i-u=-BsSjR1%z8NeX#HdC`Hh-Z(6xI-`hmHDqv!v)W&&nrf>M(RhcN6(D;jNN*%^u_SYjF;2ng}*8Ow)d6M ztDk;%`@Lsk$;9w$(d(H%O5UixIr`T2ZRcd@&&)v;tH?ScO2qa`f1ThRCSRoT0#o>_}1Pr0#13{?mbevimLU>7Q z%eHo=1zHlL_?otM5Nih&*@TK(l+09t4iVH!OHzz6n@tdsEXfL+?C!m%|G9COd-rwk zK8PEpXC~}_&iT)O{_i~g^Pm4*2n=$XVg}t9-B_d1z#HA5;{=V%;q1QPs5e93V5&l{ zXJ@6=IR(IJ77_}D0HlBrT7bWq!TO{W96)FzP|`>!JtJ`Zd$rx|AA=c$VEnO`#T|I* z9@9*sA!`xDJp@QjObSxkO$i((RM>aOZaWswL5K_R`DW0jrsu838Qp?lr^Ey{L}Lon zimRf!&*ggloh@Hp3BjhiI9A}Ktje^Ga%2Y~kgvMem>6jnP`c~C_O|M?W^hF-Dr(@R z)w$CM$324r=0@9jEbS=3If3VYT({BoUNmx|0$x^a+5{Z=4R)9mO{ei}g9A`=zPoix zq_ZCp@Up73G|CzG5kl^cr}DAN!u{Xic6nC5xw+#DwX#$LFR2=tt>+AHA?Rspg%2tp zg$~LKC6>yzf2rwL74W-iOcQjJ934CZrbhKZ9<=(G0X$1B8`_Tt*2+s1fiKxLYK$(~ z@Bt7QFK?QdIT1WB+@Un{TsZYhcaAhBB^!GP6K2ONjJ9m#BCdDBGzWmADB{J@u3a2 zcbRN0`-df})vm!&i3v25Q+E3!Fz=R9MvCd+htI-G$A1e9@RDikVCBr8K&m0#TP^$H&)Wf$vS&z2<)q8g z2OD2m44K1oVDsX=0fQsucJnUScAyMC?>s9lR;DqAcgSuXg-c!#f!|e=k(vy{y3y0s zD7OZ|1LaJk~@P}1Y$*7w0|S*^qtP|x$88Fd@mPm4=C zi8sts3py=j6r>N!f{VS@0HL7@)PQDf*Py)k}Q+;KOU<3Or%R1Tqeg4?v zP&B>-n9-A_5hF2 zz;^jFRQlh*wtzHYFi@KH(O3F=eYBTg=YOyij5ipcQW@?L<9svd2Y^#*WT6YSX=eT+ zs4RI33_6+n8?re^2DjQs}@mI(gK3b-<39p$m@)yd=5-~&ZiwmZgcaO_2&0#byyr~#^ z`|v)K00~3LQD}jaj7gkv0qCi(M?g`K<;)Af{zj+f!FQ&t!aMVQXyGHjEGTxQfl#{_&;!+mu8hjgur6Nh_I=9zW zv`hCmhMoladL%;pdD%r}#~Tj<3npBT^zMINg5cC$1|P6mHr;lx#{;fuQ7d1rRp<8Ugq;uj+Wi=)o5%OI|-)IS!`B_W3b*dMa z%$VbH-{q~i(SGs#-Yvx>18%8k`zNkWG5jiJ8tOD+YU#DNzAVKd1I{XVf&V!!iwG{t zQgMk%BN#KDT(Ss90Pw>bZ71-f+ZPiPLgZQ@=nrZ)wC?k7833Fy@NRw^+Wszo<{_AO zQ$ctX^$u9UL4nt8Ztvs;;d?`V$qRIQZADvPloCfGWK5{(El1O&#UqAe{Cdt1oN2UD zw9ZmtEf1x8&>8nK_t>r5(agQ+%@37d1pUbs=T{Gf(>E+|W(ftmhdp-;JY$BFlpuBN z=;{-ki;q5L?+K?;Sm3N-ow@xgK1VM_GV#GvXm$}={PxDqj&Pt^og#3?^tbJ;cD~zF zoXE=_b^FNG@0@$2+1*hSLWIZYi%rj z#0=cq`SR-ABEpfa80A{gzGbR>dX}FvK0WwD0-&4xO4lb~Xp35O-Apeuhzi z^*Lx8Fi=K}xIH+e@xN9Q2mW5fvPOaZH%neP3-B0+s Sz9qo`0000X+uL$Nkc;* zP;zf(X>4Tx07wm;mUmQB*%pV-y*Itk5+Wca^cs2zAksTX6$DXM^`x7XQc?|s+0 z08spb1j2M!0f022SQPH-!CVp(%f$Br7!UytSOLJ{W@ZFO_(THK{JlMynW#v{v-a*T zfMmPdEWc1DbJqWVks>!kBnAKqMb$PuekK>?0+ds;#ThdH1j_W4DKdsJG8Ul;qO2n0 z#IJ1jr{*iW$(WZWsE0n`c;fQ!l&-AnmjxZO1uWyz`0VP>&nP`#itsL#`S=Q!g`M=rU9)45( zJ;-|dRq-b5&z?byo>|{)?5r=n76A4nTALlSzLiw~v~31J<>9PP?;rs31pu_(obw)r zY+jPY;tVGXi|p)da{-@gE-UCa`=5eu%D;v=_nFJ?`&K)q7e9d`Nfk3?MdhZarb|T3 z%nS~f&t(1g5dY)AIcd$w!z`Siz!&j_=v7hZlnI21XuE|xfmo0(WD10T)!}~_HYW!e zew}L+XmwuzeT6wtxJd`dZ#@7*BLgIEKY9Xv>st^p3dp{^Xswa2bB{85{^$B13tWnB z;Y>jyQ|9&zk7RNsqAVGs--K+z0uqo1bf5|}fi5rtEMN^BfHQCd-XH*kfJhJnmIE$G z0%<@5vOzxB0181d*a3EfYH$G5fqKvcPJ%XY23!PJzzuK<41h;K3WmW;Fah3yX$XSw z5EY_9s*o0>51B&N5F1(uc|$=^I1~fLLy3?Ol0f;;Ca4%HgQ}rJP(Ab`bQ-z{U4#0d z2hboi2K@njgb|nm(_szR0JebHusa+GN5aeCM0gdP2N%HG;Yzp`J`T6S7vUT504#-H z!jlL<$Or?`Mpy_N@kBz9SR?@vA#0H$qyni$nvf2p8@Y{0k#Xb$28W?xm>3qu8RLgp zjNxKdVb)?wFx8l2m{v>|<~C*!GlBVnrDD~wrdTJeKXwT=5u1%I#8zOBU|X=4u>;s) z>^mF|$G{ol9B_WP7+f-LHLe7=57&&lfa}8z;U@8Tyei%l?}87(bMRt(A-)QK9Dg3) zj~~XrCy)tR1Z#p1A(kK{Y$Q|=8VKhI{e%(1G*N-5Pjn)N5P8I0VkxnX*g?EW941ba z6iJ387g8iCnY4jaNopcpCOsy-A(P2EWJhusSwLP-t|XrzUnLKcKTwn?CKOLf97RIe zPB}`sKzTrUL#0v;sBY9)s+hW+T2H-1eM)^VN0T#`^Oxhvt&^*fYnAJldnHel*Ozyf zUoM{~Um<@={-*r60#U(0!Bc^wuvVc);k3d%g-J!4qLpHZVwz%!VuRu}#Ze`^l7W)9 z5>Kf>>9Eozr6C$Z)1`URxU@~QI@)F0FdauXr2Es8>BaOP=)Lp_WhG@>R;lZ?BJkMlIuMhw8ApiF&yDYW2hFJ?fJhni{?u z85&g@mo&yT8JcdI$(rSw=QPK(Xj%)k1X|@<=e1rim6`6$RAwc!i#egKuI;BS(LSWz zt39n_sIypSqfWEV6J3%nTQ@-4i zi$R;gsG*9XzhRzXqv2yCs*$VFDx+GXJH|L;wsDH_KI2;^u!)^Xl1YupO;gy^-c(?^ z&$Q1BYvyPsG^;hc$D**@Sy`+`)}T4VJji^bd7Jqw3q6Zii=7tT7GEswEK@D(EFW1Z zSp`^awCb?>!`j4}Yh7b~$A)U-W3$et-R8BesV(1jzwLcHnq9En7Q0Tn&-M=XBKs!$ zF$X<|c!#|X_tWYh)GZit z(Q)Cp9CDE^WG;+fcyOWARoj*0TI>4EP1lX*cEoMO-Pk?Z{kZ!p4@(b`M~lalr<3Oz z&kJ6Nm#vN_+kA5{dW4@^Vjg_`q%qU1ULk& z3Fr!>1V#i_2R;ij2@(Z$1jE4r!MlPVFVbHmT+|iPIq0wy5aS{>yK?9ZAjVh%SOwMWgFjair&;wpi!{CU}&@N=Eg#~ zLQ&zpEzVmGY{hI9Z0+4-0xS$$Xe-OToc?Y*V;rTcf_ zb_jRe-RZjXSeas3UfIyD;9afd%<`i0x4T#DzE)vdabOQ=k7SRuGN`h>O0Q~1)u-yD z>VX=Mn&!Rgd$;YK+Q-}1zu#?t(*cbG#Ronf6db&N$oEidtwC+YVcg-Y!_VuY>bk#Y ze_ww@?MU&F&qswvrN_dLb=5o6*Egs)ls3YRlE$&)amR1{;Ppd$6RYV^Go!iq1UMl% z@#4q$AMc(FJlT1QeX8jv{h#)>&{~RGq1N2iiMFIRX?sk2-|2wUogK~{EkB$8eDsX= znVPf8XG_nK&J~=SIiGia@9y}|z3FhX{g&gcj=lwb=lWgyFW&aLedUh- zof`v-2Kw$UzI*>(+&$@i-u=-BsSjR1%z8NeX#HdC`Hh-Z(6xI-`hmHDqv!v)W&&nrf>M(RhcN6(D;jNN*%^u_SYjF;2ng}*8Ow)d6M ztDk;%`@Lsk$;9w$(d(H%O5UixIr`T2ZRcd@XvfT?c6yt zbLPx9XU?26b2$;Q?s%N;$_b2V)ruFmO3tT{?EG7t*!bzJqj*pLINfFa1QkruxHgG6 zyAExKG2#O_qab7a0Fn5PX`<0>Z}}lPCvz!Z!nL|#6=O9`n?jtA;Y1bSKZqlPn#YLK zZzSleR?)h?arS}Z=GNvM+IAOURa+S6lg;cqa_Hl4&Un4c(Y!uUdF{&KSLlU>3jvA) zBN&{hj*$k5x3IRzJa@f+w~^lYy=LWzK}Ci7Bb>CkjI+|t<#BmpLBYiG*8TpM8W&S2 zt{jB_1Tfh#F^Z62w|ku4txAxZCEQvx-1ao`PXo~Yik?tDV8m{-h z)AlImd{H_R(78P(_*)7D?)E%<@L1=vq<}>`zQZ;VgP8!eEzuaWN(NBpqfpF0fWmGb z7EMjm;=9&r+tihYsZJqCv)kbvCG+-oN7%U4vL3v#^s;-PX{R}f@h*q&XT@QrtMVF) zrmY3vF^ZN=DnUDPN=*2CzQ4`bJ|SQ?uH*&RS}Pxf0xkpy!;B3Qy+iO27`3AAphV>+ z8b!vzbPT~V`2fr0B2!rw8951@g~qrXd(SvLl`|OA-+@*YXTd72i6DBx`lw zvY*y}J323qYusG~&c_3+ZhrV{01FnQn;*^#xmBMJu8%3sF%5HgDsF%}OPf-r3NNH` zvtq}NoQzzhY*}npf`OA>LF^IiW^baF{{mPY2w*h~x$Gh~PT>*LFhx+Q+--EORl~Kb zh1QKw^d3oxwPP3lnhE+5_AQ7$tKd{l#ime{YkyFHzrf=Q?CLETY!TD=1dQjVjQGMv zVkcfI+jI$-kzuX7YP^N<`54qRbfz22a(RckhoSTyPb06oRU^x^0@{N9 zDvb2B--(XG{aMu1=5y_+r*>mIhs)*!aI3*PEpv*U_(Qlu({?Mw;bUIL*$njLX9IlaXW1Co@^2t&Fwhe$y zl67J>ELj6p_WtZW``vcmJPJidK|3}mdd4ZycWogyHW^rwr!2=(+MWr2_)aT4`+Nz# zbR(ZO7G7q|Cw=GqVpX0IV5*VyOWgM6>Ig;d!E7E>^t4i7-JY^ymf6UYbn6dB1Mv5RW zFD$lBU32NYOSB=g5qvMeo>*qA*Vo=-OETHOrDu*SvbTbpe3e45+8%RKlL?V0WoZGX zWR+UyaW=2r8S;&UC3+mj=(H;9D``f6DNyA3lGHeAYab2ZzEt$SkY)v#0>)Fn`**j~ zdj|>e98L;)_3o#}lq&7g=u3~EnEn8c7{?-j7>ZMa0>qQ@eMR(bzfu7UlX)0m!p*#*S`u8--69r3^85AsGS(TiPx6_wwDFjBv( zDuA5nhBzMv*Ap_BmZZaDdlq1AgX@ryrT{D%N?p69H9DPE-jIczVQ+xA5T#ey-Q}#R@)RV^YUi~f*8K?2U3$Qv$hnL8zDFIXb z&IaHw;zqd~z`b)mc=j+kX~)9E3r1%Im;z2-zax%(FhcAW0N1O(^1;2$K{c+FsnBlA z3NXd^J%{(3_EV7?p=lp0T5n6SBEL^pX3pupiA%U8?IoFr@!I+RlWx0r8ZOv+r4QFI zyQK*e{!^l{#nJGsZ%K*m{+s|)Ox@1rMd-Li<$F^qBH{X!9ss^y!^eh8TYvX`9P`6+ z9VE{Ep8=1X0bD6!(ZRUuz#RbZhZ}JO|(Ci(>enC&1T- tezvxU#j3S){84+C(f47Q3UfZz{}