diff --git a/MailLibrary/ContactList.php b/MailLibrary/ContactList.php index a408bca..2f2a362 100644 --- a/MailLibrary/ContactList.php +++ b/MailLibrary/ContactList.php @@ -11,9 +11,9 @@ class ContactList implements Iterator, Countable { /** @var Contact[] */ - protected $contacts; + protected $contacts = []; - protected $builtContacts; + protected $builtContacts = []; public function addContact($mailbox = NULL, $host = NULL, $personal = NULL, $adl = NULL) { diff --git a/MailLibrary/Drivers/ImapDriver.php b/MailLibrary/Drivers/ImapDriver.php index eb059e7..7418556 100644 --- a/MailLibrary/Drivers/ImapDriver.php +++ b/MailLibrary/Drivers/ImapDriver.php @@ -10,6 +10,7 @@ use greeny\MailLibrary\Mailbox; use greeny\MailLibrary\Structures\IStructure; use greeny\MailLibrary\Structures\ImapStructure; +use Nette\Utils\Strings; use greeny\MailLibrary\Mail; use DateTime; @@ -227,18 +228,21 @@ public function checkFilter($key, $value = NULL) { public function getHeaders($mailId) { $raw = imap_fetchheader($this->resource, $mailId, FT_UID); - $lines = explode("\n", $raw); + $lines = explode("\n", Strings::fixEncoding($raw)); $headers = array(); $lastHeader = NULL; + + // normalize headers foreach($lines as $line) { - if(mb_substr($line, 0, 1, 'UTF-8') === " ") { - $headers[$lastHeader] .= $line; + $firstCharacter = mb_substr($line, 0, 1, 'UTF-8'); // todo: correct assumption that string must be UTF-8 encoded? + if(preg_match('/[\pZ\pC]/u', $firstCharacter) === 1) { // search for UTF-8 whitespaces + $headers[$lastHeader] .= " " . Strings::trim($line); } else { $parts = explode(':', $line); - $name = $parts[0]; + $name = Strings::trim($parts[0]); unset($parts[0]); - $headers[$name] = implode(':', $parts); + $headers[$name] = Strings::trim(implode(':', $parts)); $lastHeader = $name; } } @@ -254,7 +258,7 @@ public function getHeaders($mailId) $text = ''; foreach($decoded as $part) { if($part->charset !== 'UTF-8' && $part->charset !== 'default') { - $text .= mb_convert_encoding($part->text, 'UTF-8', $part->charset); + $text .= @mb_convert_encoding($part->text, 'UTF-8', $part->charset); // todo: handle this more properly } else { $text .= $part->text; } @@ -297,22 +301,39 @@ public function getStructure($mailId, Mailbox $mailbox) * Gets part of body * * @param int $mailId - * @param array $data + * @param array $data requires id and encoding keys * @return string + * @throws \greeny\MailLibrary\DriverException */ public function getBody($mailId, array $data) { $body = array(); foreach($data as $part) { - $data = ($part['id'] == 0) ? imap_body($this->resource, $mailId, FT_UID | FT_PEEK) : imap_fetchbody($this->resource, $mailId, $part['id'], FT_UID | FT_PEEK); - $encoding = $part['encoding']; - if($encoding === ImapStructure::ENCODING_BASE64) { - $data = base64_decode($data); - } else if($encoding === ImapStructure::ENCODING_QUOTED_PRINTABLE) { - $data = quoted_printable_decode($data); + assert(is_array($part)); + $dataMessage = ($part['id'] === 0) ? @imap_body($this->resource, $mailId, FT_UID | FT_PEEK) : @imap_fetchbody($this->resource, $mailId, $part['id'], FT_UID | FT_PEEK); + if($dataMessage === FALSE) { + throw new DriverException("Cannot read given message part - " . error_get_last()["message"]); + } + + // when there is no encoding of mime part available + if(!isset($part['encoding'])) { + $decodedMessage = $dataMessage; + + } elseif($part['encoding'] === ImapStructure::ENCODING_BASE64) { + $decodedMessage = base64_decode($dataMessage); + if($decodedMessage === FALSE) { + throw new DriverException('Malformed mime-part: cannot decode base64 mime-part'); + } + + } elseif($part['encoding'] === ImapStructure::ENCODING_QUOTED_PRINTABLE) { + $decodedMessage = quoted_printable_decode($dataMessage); + + } else { + throw new DriverException("Mime-part reading error: unknown encoding ({$part['encoding']})"); + } - $body[] = $data; + $body[] = $decodedMessage; } return implode('\n\n', $body); } diff --git a/MailLibrary/Mail.php b/MailLibrary/Mail.php index 0431948..6490e3d 100644 --- a/MailLibrary/Mail.php +++ b/MailLibrary/Mail.php @@ -5,6 +5,9 @@ namespace greeny\MailLibrary; +use greeny\MailLibrary\Structures\IStructure; +use Nette\Utils\Strings; + class Mail { const ANSWERED = 'ANSWERED'; const BCC = 'BCC'; @@ -39,10 +42,10 @@ class Mail { const ORDER_CC = SORTCC; const ORDER_SIZE = SORTSIZE; - /** @var \greeny\MailLibrary\Connection */ + /** @var Connection */ protected $connection; - /** @var \greeny\MailLibrary\Mailbox */ + /** @var Mailbox */ protected $mailbox; /** @var int */ @@ -51,7 +54,7 @@ class Mail { /** @var array */ protected $headers = NULL; - /** @var \greeny\MailLibrary\Structures\IStructure */ + /** @var IStructure */ protected $structure = NULL; /** @var array */ @@ -78,7 +81,8 @@ public function __construct(Connection $connection, Mailbox $mailbox, $id) public function __isset($name) { $this->headers !== NULL || $this->initializeHeaders(); - return isset($this->headers[$this->formatHeaderName($name)]); + $key = $this->normalizeHeaderName($this->lowerCamelCaseToHeaderName($name)); + return isset($this->headers[$key]); } /** @@ -86,10 +90,18 @@ public function __isset($name) * * @param string $name * @return mixed + * @deprecated */ public function __get($name) { - return $this->getHeader($name); + \trigger_error(\E_USER_DEPRECATED, 'use array access with execat header name instead'); + return $this->getHeader( + $this->normalizeHeaderName($this->lowerCamelCaseToHeaderName($name)) + ); + } + + public function __set($name, $value) { + throw new \Exception('Mail headers are read-only.'); } /** @@ -124,7 +136,12 @@ public function getHeaders() public function getHeader($name) { $this->headers !== NULL || $this->initializeHeaders(); - return $this->headers[$this->formatHeaderName($name)]; + $index = $this->normalizeHeaderName($name); + if(isset($this->headers[$index])) { + return $this->headers[$index]; + } + + return NULL; } /** @@ -135,9 +152,9 @@ public function getSender() { if($from) { $contacts = $from->getContactsObjects(); return (count($contacts) ? $contacts[0] : NULL); - } else { - return NULL; } + + return NULL; } /** @@ -176,6 +193,15 @@ public function getAttachments() return $this->structure->getAttachments(); } + /** + * @return \greeny\MailLibrary\MimePart[] + */ + public function getMimeParts() + { + $this->structure !== NULL || $this->initializeStructure(); + return $this->structure->getMimeParts(); + } + /** * @return array */ @@ -230,7 +256,7 @@ protected function initializeHeaders() $this->headers = array(); $this->connection->getDriver()->switchMailbox($this->mailbox->getName()); foreach($this->connection->getDriver()->getHeaders($this->id) as $key => $value) { - $this->headers[$this->formatHeaderName($key)] = $value; + $this->headers[$this->normalizeHeaderName($key)] = $value; } } @@ -240,6 +266,15 @@ protected function initializeStructure() $this->structure = $this->connection->getDriver()->getStructure($this->id, $this->mailbox); } + /** + * @internal + * @return IStructure + */ + public function getStructure() { + $this->structure !== NULL || $this->initializeStructure(); + return $this->structure; + } + protected function initializeFlags() { $this->connection->getDriver()->switchMailbox($this->mailbox->getName()); @@ -247,15 +282,29 @@ protected function initializeFlags() } /** - * Formats header name (X-Received-From => xReceivedFrom) + * Formats header name (X-Received-From => x-recieved-from) * - * @param string $name + * @param string $name Header name (with dashes, valid UTF-8 string) * @return string */ - protected function formatHeaderName($name) + protected function normalizeHeaderName($name) { - return lcfirst(preg_replace_callback("~-.~", function($matches){ + return Strings::normalize(Strings::lower($name)); + } + + /** + * Converts camel cased name to normalized header name (xReceivedFrom => x-recieved-from) + * + * @param string $camelCasedName + * @return string name with dashes + */ + protected function lowerCamelCaseToHeaderName($camelCasedName) { + // todo: test this + // todo: use something like this instead http://stackoverflow.com/a/1993772 + $dashedName = lcfirst(preg_replace_callback("~-.~", function($matches){ return ucfirst(substr($matches[0], 1)); - }, $name)); + }, $camelCasedName)); + + return $this->normalizeHeaderName($dashedName); } } diff --git a/MailLibrary/MimePart.php b/MailLibrary/MimePart.php new file mode 100644 index 0000000..b13e571 --- /dev/null +++ b/MailLibrary/MimePart.php @@ -0,0 +1,82 @@ +partId = $partId; + $this->mailId = $mailId; + $this->mimeType = $mimeType; + $this->name = $name; + $this->encoding = $encoding; + $this->driver = $driver; + } + + /** + * @return string + */ + public function getPartId() + { + return $this->partId; + } + + /** + * @return string + */ + public function getMimeType() + { + return $this->mimeType; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string + */ + public function getEncoding() + { + return $this->encoding; + } + + public function getContent() + { + // todo: this automatically decodes content type if supported, support for getting RAW content? + return $this->driver->getBody($this->mailId, [[ + 'id' => $this->partId, + 'encoding' => $this->encoding, + ]]); + } + +} diff --git a/MailLibrary/Structures/IStructure.php b/MailLibrary/Structures/IStructure.php index 0cef529..4e7554d 100644 --- a/MailLibrary/Structures/IStructure.php +++ b/MailLibrary/Structures/IStructure.php @@ -6,6 +6,7 @@ namespace greeny\MailLibrary\Structures; use greeny\MailLibrary\Attachment; +use greeny\MailLibrary\MimePart; interface IStructure { /** @@ -27,4 +28,9 @@ function getTextBody(); * @return Attachment[] */ function getAttachments(); -} \ No newline at end of file + + /** + * @return MimePart[] + */ + function getMimeParts(); +} diff --git a/MailLibrary/Structures/ImapStructure.php b/MailLibrary/Structures/ImapStructure.php index ec9ed2e..4004d88 100644 --- a/MailLibrary/Structures/ImapStructure.php +++ b/MailLibrary/Structures/ImapStructure.php @@ -8,6 +8,7 @@ use greeny\MailLibrary\Attachment; use greeny\MailLibrary\Drivers\ImapDriver; use greeny\MailLibrary\Mailbox; +use greeny\MailLibrary\MimePart; class ImapStructure implements IStructure { const TYPE_TEXT = 0; @@ -37,6 +38,9 @@ class ImapStructure implements IStructure { self::TYPE_OTHER => 'other', ); + /** @var MimePart[] */ + private $mimeParts = []; + /** @var \greeny\MailLibrary\Drivers\ImapDriver */ protected $driver; @@ -64,6 +68,9 @@ class ImapStructure implements IStructure { /** @var Mailbox */ protected $mailbox; + /** @var array */ + private $rawStructure = []; + /** * @param ImapDriver $driver * @param object $structure @@ -72,6 +79,7 @@ class ImapStructure implements IStructure { */ public function __construct(ImapDriver $driver, $structure, $mailId, Mailbox $mailbox) { + $this->rawStructure = $structure; $this->driver = $driver; $this->id = $mailId; $this->mailbox = $mailbox; @@ -84,6 +92,15 @@ public function __construct(ImapDriver $driver, $structure, $mailId, Mailbox $ma } } + /** + * @return array + * @internal use only with caution, format can change without warning + */ + public function getRawStructure() + { + return $this->rawStructure; + } + /** * @return string */ @@ -100,9 +117,9 @@ public function getHtmlBody() if($this->htmlBody === NULL) { $this->driver->switchMailbox($this->mailbox->getName()); return $this->htmlBody = $this->driver->getBody($this->id, $this->htmlBodyIds); - } else { - return $this->htmlBody; } + + return $this->htmlBody; } /** @@ -113,9 +130,9 @@ public function getTextBody() if($this->textBody === NULL) { $this->driver->switchMailbox($this->mailbox->getName()); return $this->textBody = $this->driver->getBody($this->id, $this->textBodyIds); - } else { - return $this->textBody; } + + return $this->textBody; } /** @@ -133,6 +150,21 @@ public function getAttachments() return $this->attachments; } + /** + * @return MimePart[] + */ + public function getMimeParts() + { + $this->driver->switchMailbox($this->mailbox->getName()); + return $this->mimeParts; + } + + /** @deprecated use getMimeParts() instead */ + public function getParts() { + \trigger_error(\E_USER_DEPRECATED, 'use getMimeParts() instead'); + return $this->getMimeParts(); + } + protected function addStructurePart($structure, $partId) { $type = $structure->type; @@ -151,13 +183,31 @@ protected function addStructurePart($structure, $partId) } } + /** @noinspection NestedTernaryOperatorInspection Yep, will fix this when we switch to PHP7 level; see ?? operator */ + $this->mimeParts[] = new MimePart( + $this->driver, // for lazy loading + $this->id, + $partId, + self::$typeTable[$type]. '/' . $subtype, + \imap_utf8( + empty($parameters['filename']) ? $parameters['filename'] : ( + empty($parameters['name']) ? $parameters['name'] : '' + ) + ), + $encoding + ); + + if(isset($parameters['filename']) || isset($parameters['name'])) { $this->attachmentsIds[] = array( 'id' => $partId, 'encoding' => $encoding, - 'name' => isset($parameters['filename']) ? $parameters['filename'] : $parameters['name'], + 'name' => \imap_utf8( + isset($parameters['filename']) ? $parameters['filename'] : $parameters['name'] + ), 'type' => self::$typeTable[$type]. '/' . $subtype, ); + } else if($type === self::TYPE_TEXT) { if($subtype === 'HTML') { $this->htmlBodyIds[] = array('id' => $partId, 'encoding' => $encoding); diff --git a/MailLibrary/loader.php b/MailLibrary/loader.php index 998517c..c7b4b68 100644 --- a/MailLibrary/loader.php +++ b/MailLibrary/loader.php @@ -6,24 +6,25 @@ require_once "exceptions.php"; spl_autoload_register(function ($type) { - static $paths = array( - 'greeny\maillibrary\connection' => 'Connection.php', - 'greeny\maillibrary\mailbox' => 'Mailbox.php', - 'greeny\maillibrary\selection' => 'Selection.php', - 'greeny\maillibrary\mail' => 'Mail.php', - 'greeny\maillibrary\contactlist' => 'ContactList.php', - 'greeny\maillibrary\contact' => 'Contact.php', - 'greeny\maillibrary\attachment' => 'Attachment.php', - 'greeny\maillibrary\structures\istructure' => 'Structures/IStructure.php', - 'greeny\maillibrary\structures\imapstructure' => 'Structures/ImapStructure.php', - 'greeny\maillibrary\drivers\idriver' => 'Drivers/IDriver.php', - 'greeny\maillibrary\drivers\imapdriver' => 'Drivers/ImapDriver.php', - 'greeny\maillibrary\extensions\maillibraryextension' => 'Extensions/MailLibraryExtension.php', - ); + static $paths = array( + 'greeny\maillibrary\connection' => 'Connection.php', + 'greeny\maillibrary\mailbox' => 'Mailbox.php', + 'greeny\maillibrary\selection' => 'Selection.php', + 'greeny\maillibrary\mail' => 'Mail.php', + 'greeny\maillibrary\contactlist' => 'ContactList.php', + 'greeny\maillibrary\contact' => 'Contact.php', + 'greeny\maillibrary\attachment' => 'Attachment.php', + 'greeny\maillibrary\mimepart' => 'MimePart.php', + 'greeny\maillibrary\structures\istructure' => 'Structures/IStructure.php', + 'greeny\maillibrary\structures\imapstructure' => 'Structures/ImapStructure.php', + 'greeny\maillibrary\drivers\idriver' => 'Drivers/IDriver.php', + 'greeny\maillibrary\drivers\imapdriver' => 'Drivers/ImapDriver.php', + 'greeny\maillibrary\extensions\maillibraryextension' => 'Extensions/MailLibraryExtension.php', + ); - $type = ltrim(strtolower($type), '\\'); // PHP namespace bug #49143 + $type = ltrim(strtolower($type), '\\'); // PHP namespace bug #49143 - if (isset($paths[$type])) { - require_once __DIR__ . '/' . $paths[$type]; - } + if (isset($paths[$type])) { + require_once __DIR__ . '/' . $paths[$type]; + } }); \ No newline at end of file diff --git a/README.md b/README.md index 79fc5d9..08f3980 100644 --- a/README.md +++ b/README.md @@ -3,4 +3,17 @@ MailLibrary A PHP library for downloading mails from server. -Documentation can be found at http://greeny.github.io/MailLibrary/. \ No newline at end of file +Documentation can be found at http://greeny.github.io/MailLibrary/. + +Testing +------- + +Install dependencies using composer and then run following in library root directory. + +````cmd +# Unix +vendor\bin\tester -c tests\php-unix.ini tests + +# Windows +vendor\bin\tester -c tests\php-windows.ini tests +```` diff --git a/composer.json b/composer.json index 8b0751c..d04e0ac 100644 --- a/composer.json +++ b/composer.json @@ -12,9 +12,11 @@ "files": ["MailLibrary/loader.php"] }, "require": { - "php": ">= 5.3.0" + "php": ">= 5.3.0", + "ext-imap": "*", + "nette/utils": "~2.2" }, "require-dev": { "nette/tester": "@dev" } -} \ No newline at end of file +} diff --git a/tests/php-unix.ini b/tests/php-unix.ini new file mode 100644 index 0000000..c758598 --- /dev/null +++ b/tests/php-unix.ini @@ -0,0 +1,5 @@ +[PHP] +;extension_dir = "./ext" +extension=mbstring.so +extension=imap.so +date.timezone = "Europe/Prague" diff --git a/tests/php-windows.ini b/tests/php-windows.ini new file mode 100644 index 0000000..f601ad4 --- /dev/null +++ b/tests/php-windows.ini @@ -0,0 +1,5 @@ +[PHP] +extension_dir = "./ext" +extension=php_mbstring.dll +extension=php_imap.dll +date.timezone = "Europe/Prague"