From 625a6cac6c9ee3f820c68af626ca5accbfd0b8f9 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 11 Jan 2024 12:51:11 -0500 Subject: [PATCH] Included WebKlex PHP-IMAP Library in plugins folder to allow for future use when we convert IMAP to allow OAUTH2 --- plugins/php-imap/Address.php | 90 + plugins/php-imap/Attachment.php | 408 ++++ plugins/php-imap/Attribute.php | 325 ++++ plugins/php-imap/Client.php | 950 +++++++++ plugins/php-imap/ClientManager.php | 293 +++ .../Connection/Protocols/ImapProtocol.php | 1300 +++++++++++++ .../Connection/Protocols/LegacyProtocol.php | 814 ++++++++ .../Connection/Protocols/Protocol.php | 366 ++++ .../Protocols/ProtocolInterface.php | 447 +++++ .../Connection/Protocols/Response.php | 417 ++++ plugins/php-imap/EncodingAliases.php | 591 ++++++ plugins/php-imap/Events/Event.php | 28 + plugins/php-imap/Events/FlagDeletedEvent.php | 22 + plugins/php-imap/Events/FlagNewEvent.php | 40 + .../php-imap/Events/FolderDeletedEvent.php | 22 + plugins/php-imap/Events/FolderMovedEvent.php | 40 + plugins/php-imap/Events/FolderNewEvent.php | 36 + .../php-imap/Events/MessageCopiedEvent.php | 22 + .../php-imap/Events/MessageDeletedEvent.php | 22 + plugins/php-imap/Events/MessageMovedEvent.php | 40 + plugins/php-imap/Events/MessageNewEvent.php | 36 + .../php-imap/Events/MessageRestoredEvent.php | 22 + .../Exceptions/AuthFailedException.php | 24 + .../Exceptions/ConnectionFailedException.php | 24 + .../Exceptions/EventNotFoundException.php | 24 + .../Exceptions/FolderFetchingException.php | 24 + .../Exceptions/GetMessagesFailedException.php | 24 + .../Exceptions/ImapBadRequestException.php | 24 + .../Exceptions/ImapServerErrorException.php | 24 + .../InvalidMessageDateException.php | 24 + .../InvalidWhereQueryCriteriaException.php | 24 + .../Exceptions/MaskNotFoundException.php | 24 + .../MessageContentFetchingException.php | 24 + .../Exceptions/MessageFlagException.php | 24 + .../MessageHeaderFetchingException.php | 24 + .../Exceptions/MessageNotFoundException.php | 24 + .../MessageSearchValidationException.php | 24 + .../MessageSizeFetchingException.php | 24 + .../Exceptions/MethodNotFoundException.php | 24 + .../MethodNotSupportedException.php | 24 + .../NotSupportedCapabilityException.php | 24 + .../ProtocolNotSupportedException.php | 24 + .../php-imap/Exceptions/ResponseException.php | 91 + .../php-imap/Exceptions/RuntimeException.php | 24 + plugins/php-imap/Folder.php | 572 ++++++ plugins/php-imap/Header.php | 808 ++++++++ plugins/php-imap/IMAP.php | 375 ++++ plugins/php-imap/Message.php | 1703 +++++++++++++++++ plugins/php-imap/Part.php | 308 +++ plugins/php-imap/Query/Query.php | 1092 +++++++++++ plugins/php-imap/Query/WhereQuery.php | 555 ++++++ plugins/php-imap/Structure.php | 164 ++ .../php-imap/Support/AttachmentCollection.php | 26 + plugins/php-imap/Support/FlagCollection.php | 25 + plugins/php-imap/Support/FolderCollection.php | 26 + .../php-imap/Support/Masks/AttachmentMask.php | 44 + plugins/php-imap/Support/Masks/Mask.php | 137 ++ .../php-imap/Support/Masks/MessageMask.php | 86 + .../php-imap/Support/MessageCollection.php | 26 + .../php-imap/Support/PaginatedCollection.php | 82 + plugins/php-imap/Traits/HasEvents.php | 77 + plugins/php-imap/VERSION | 1 + plugins/php-imap/config/imap.php | 226 +++ 63 files changed, 13259 insertions(+) create mode 100644 plugins/php-imap/Address.php create mode 100755 plugins/php-imap/Attachment.php create mode 100644 plugins/php-imap/Attribute.php create mode 100755 plugins/php-imap/Client.php create mode 100644 plugins/php-imap/ClientManager.php create mode 100644 plugins/php-imap/Connection/Protocols/ImapProtocol.php create mode 100644 plugins/php-imap/Connection/Protocols/LegacyProtocol.php create mode 100644 plugins/php-imap/Connection/Protocols/Protocol.php create mode 100644 plugins/php-imap/Connection/Protocols/ProtocolInterface.php create mode 100644 plugins/php-imap/Connection/Protocols/Response.php create mode 100644 plugins/php-imap/EncodingAliases.php create mode 100644 plugins/php-imap/Events/Event.php create mode 100644 plugins/php-imap/Events/FlagDeletedEvent.php create mode 100644 plugins/php-imap/Events/FlagNewEvent.php create mode 100644 plugins/php-imap/Events/FolderDeletedEvent.php create mode 100644 plugins/php-imap/Events/FolderMovedEvent.php create mode 100644 plugins/php-imap/Events/FolderNewEvent.php create mode 100644 plugins/php-imap/Events/MessageCopiedEvent.php create mode 100644 plugins/php-imap/Events/MessageDeletedEvent.php create mode 100644 plugins/php-imap/Events/MessageMovedEvent.php create mode 100644 plugins/php-imap/Events/MessageNewEvent.php create mode 100644 plugins/php-imap/Events/MessageRestoredEvent.php create mode 100644 plugins/php-imap/Exceptions/AuthFailedException.php create mode 100644 plugins/php-imap/Exceptions/ConnectionFailedException.php create mode 100644 plugins/php-imap/Exceptions/EventNotFoundException.php create mode 100644 plugins/php-imap/Exceptions/FolderFetchingException.php create mode 100644 plugins/php-imap/Exceptions/GetMessagesFailedException.php create mode 100644 plugins/php-imap/Exceptions/ImapBadRequestException.php create mode 100644 plugins/php-imap/Exceptions/ImapServerErrorException.php create mode 100644 plugins/php-imap/Exceptions/InvalidMessageDateException.php create mode 100644 plugins/php-imap/Exceptions/InvalidWhereQueryCriteriaException.php create mode 100644 plugins/php-imap/Exceptions/MaskNotFoundException.php create mode 100644 plugins/php-imap/Exceptions/MessageContentFetchingException.php create mode 100644 plugins/php-imap/Exceptions/MessageFlagException.php create mode 100644 plugins/php-imap/Exceptions/MessageHeaderFetchingException.php create mode 100644 plugins/php-imap/Exceptions/MessageNotFoundException.php create mode 100644 plugins/php-imap/Exceptions/MessageSearchValidationException.php create mode 100644 plugins/php-imap/Exceptions/MessageSizeFetchingException.php create mode 100644 plugins/php-imap/Exceptions/MethodNotFoundException.php create mode 100644 plugins/php-imap/Exceptions/MethodNotSupportedException.php create mode 100644 plugins/php-imap/Exceptions/NotSupportedCapabilityException.php create mode 100644 plugins/php-imap/Exceptions/ProtocolNotSupportedException.php create mode 100644 plugins/php-imap/Exceptions/ResponseException.php create mode 100644 plugins/php-imap/Exceptions/RuntimeException.php create mode 100755 plugins/php-imap/Folder.php create mode 100644 plugins/php-imap/Header.php create mode 100644 plugins/php-imap/IMAP.php create mode 100755 plugins/php-imap/Message.php create mode 100644 plugins/php-imap/Part.php create mode 100644 plugins/php-imap/Query/Query.php create mode 100755 plugins/php-imap/Query/WhereQuery.php create mode 100644 plugins/php-imap/Structure.php create mode 100644 plugins/php-imap/Support/AttachmentCollection.php create mode 100644 plugins/php-imap/Support/FlagCollection.php create mode 100644 plugins/php-imap/Support/FolderCollection.php create mode 100644 plugins/php-imap/Support/Masks/AttachmentMask.php create mode 100755 plugins/php-imap/Support/Masks/Mask.php create mode 100644 plugins/php-imap/Support/Masks/MessageMask.php create mode 100644 plugins/php-imap/Support/MessageCollection.php create mode 100644 plugins/php-imap/Support/PaginatedCollection.php create mode 100644 plugins/php-imap/Traits/HasEvents.php create mode 100644 plugins/php-imap/VERSION create mode 100644 plugins/php-imap/config/imap.php diff --git a/plugins/php-imap/Address.php b/plugins/php-imap/Address.php new file mode 100644 index 00000000..b45c72de --- /dev/null +++ b/plugins/php-imap/Address.php @@ -0,0 +1,90 @@ +personal = $object->personal ?? ''; } + if (property_exists($object, "mailbox")){ $this->mailbox = $object->mailbox ?? ''; } + if (property_exists($object, "host")){ $this->host = $object->host ?? ''; } + if (property_exists($object, "mail")){ $this->mail = $object->mail ?? ''; } + if (property_exists($object, "full")){ $this->full = $object->full ?? ''; } + } + + + /** + * Return the stringified address + * + * @return string + */ + public function __toString() { + return $this->full ?: ""; + } + + /** + * Return the serialized address + * + * @return array + */ + public function __serialize(){ + return [ + "personal" => $this->personal, + "mailbox" => $this->mailbox, + "host" => $this->host, + "mail" => $this->mail, + "full" => $this->full, + ]; + } + + /** + * Convert instance to array + * + * @return array + */ + public function toArray(): array { + return $this->__serialize(); + } + + /** + * Return the stringified attribute + * + * @return string + */ + public function toString(): string { + return $this->__toString(); + } +} \ No newline at end of file diff --git a/plugins/php-imap/Attachment.php b/plugins/php-imap/Attachment.php new file mode 100755 index 00000000..15b83f63 --- /dev/null +++ b/plugins/php-imap/Attachment.php @@ -0,0 +1,408 @@ + null, + 'hash' => null, + 'type' => null, + 'part_number' => 0, + 'content_type' => null, + 'id' => null, + 'name' => null, + 'filename' => null, + 'description' => null, + 'disposition' => null, + 'img_src' => null, + 'size' => null, + ]; + + /** + * Default mask + * + * @var string $mask + */ + protected string $mask = AttachmentMask::class; + + /** + * Attachment constructor. + * @param Message $oMessage + * @param Part $part + */ + public function __construct(Message $oMessage, Part $part) { + $this->config = ClientManager::get('options'); + + $this->oMessage = $oMessage; + $this->part = $part; + $this->part_number = $part->part_number; + + if ($this->oMessage->getClient()) { + $default_mask = $this->oMessage->getClient()?->getDefaultAttachmentMask(); + if ($default_mask != null) { + $this->mask = $default_mask; + } + } else { + $default_mask = ClientManager::getMask("attachment"); + if ($default_mask != "") { + $this->mask = $default_mask; + } + } + + $this->findType(); + $this->fetch(); + } + + /** + * Call dynamic attribute setter and getter methods + * @param string $method + * @param array $arguments + * + * @return mixed + * @throws MethodNotFoundException + */ + public function __call(string $method, array $arguments) { + if (strtolower(substr($method, 0, 3)) === 'get') { + $name = Str::snake(substr($method, 3)); + + if (isset($this->attributes[$name])) { + return $this->attributes[$name]; + } + + return null; + } elseif (strtolower(substr($method, 0, 3)) === 'set') { + $name = Str::snake(substr($method, 3)); + + $this->attributes[$name] = array_pop($arguments); + + return $this->attributes[$name]; + } + + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); + } + + /** + * Magic setter + * @param $name + * @param $value + * + * @return mixed + */ + public function __set($name, $value) { + $this->attributes[$name] = $value; + + return $this->attributes[$name]; + } + + /** + * magic getter + * @param $name + * + * @return mixed|null + */ + public function __get($name) { + if (isset($this->attributes[$name])) { + return $this->attributes[$name]; + } + + return null; + } + + /** + * Determine the structure type + */ + protected function findType(): void { + $this->type = match ($this->part->type) { + IMAP::ATTACHMENT_TYPE_MESSAGE => 'message', + IMAP::ATTACHMENT_TYPE_APPLICATION => 'application', + IMAP::ATTACHMENT_TYPE_AUDIO => 'audio', + IMAP::ATTACHMENT_TYPE_IMAGE => 'image', + IMAP::ATTACHMENT_TYPE_VIDEO => 'video', + IMAP::ATTACHMENT_TYPE_MODEL => 'model', + IMAP::ATTACHMENT_TYPE_TEXT => 'text', + IMAP::ATTACHMENT_TYPE_MULTIPART => 'multipart', + default => 'other', + }; + } + + /** + * Fetch the given attachment + */ + protected function fetch(): void { + $content = $this->part->content; + + $this->content_type = $this->part->content_type; + $this->content = $this->oMessage->decodeString($content, $this->part->encoding); + + // Create a hash of the raw part - this can be used to identify the attachment in the message context. However, + // it is not guaranteed to be unique and collisions are possible. + // Some additional online resources: + // - https://en.wikipedia.org/wiki/Hash_collision + // - https://www.php.net/manual/en/function.hash.php + // - https://php.watch/articles/php-hash-benchmark + // Benchmark speeds: + // -xxh3 ~15.19(GB/s) (requires php-xxhash extension or >= php8.1) + // -crc32c ~14.12(GB/s) + // -sha256 ~0.25(GB/s) + // xxh3 would be nice to use, because of its extra speed and 32 instead of 8 bytes, but it is not compatible with + // php < 8.1. crc32c is the next fastest and is compatible with php >= 5.1. sha256 is the slowest, but is compatible + // with php >= 5.1 and is the most likely to be unique. crc32c is the best compromise between speed and uniqueness. + // Unique enough for our purposes, but not so slow that it could be a bottleneck. + $this->hash = hash("crc32c", $this->part->getHeader()->raw."\r\n\r\n".$this->part->content); + + if (($id = $this->part->id) !== null) { + $this->id = str_replace(['<', '>'], '', $id); + }else { + $this->id = $this->hash; + } + + $this->size = $this->part->bytes; + $this->disposition = $this->part->disposition; + + if (($filename = $this->part->filename) !== null) { + $this->filename = $this->decodeName($filename); + } + + if (($description = $this->part->description) !== null) { + $this->description = $this->part->getHeader()->decode($description); + } + + if (($name = $this->part->name) !== null) { + $this->name = $this->decodeName($name); + } + + if (IMAP::ATTACHMENT_TYPE_MESSAGE == $this->part->type) { + if ($this->part->ifdescription) { + if (!$this->name) { + $this->name = $this->part->description; + } + } else if (!$this->name) { + $this->name = $this->part->subtype; + } + } + $this->attributes = array_merge($this->part->getHeader()->getAttributes(), $this->attributes); + + if (!$this->filename) { + $this->filename = $this->hash; + } + + if (!$this->name && $this->filename != "") { + $this->name = $this->filename; + } + } + + /** + * Save the attachment content to your filesystem + * @param string $path + * @param string|null $filename + * + * @return boolean + */ + public function save(string $path, ?string $filename = null): bool { + $filename = $filename ? $this->decodeName($filename) : $this->filename; + + return file_put_contents($path . DIRECTORY_SEPARATOR . $filename, $this->getContent()) !== false; + } + + /** + * Decode a given name + * @param string|null $name + * + * @return string + */ + public function decodeName(?string $name): string { + if ($name !== null) { + if (str_contains($name, "''")) { + $parts = explode("''", $name); + if (EncodingAliases::has($parts[0])) { + $name = implode("''", array_slice($parts, 1)); + } + } + + $decoder = $this->config['decoder']['message']; + if (preg_match('/=\?([^?]+)\?(Q|B)\?(.+)\?=/i', $name, $matches)) { + $name = $this->part->getHeader()->decode($name); + } elseif ($decoder === 'utf-8' && extension_loaded('imap')) { + $name = \imap_utf8($name); + } + + // check if $name is url encoded + if (preg_match('/%[0-9A-F]{2}/i', $name)) { + $name = urldecode($name); + } + + // sanitize $name + // order of '..' is important + return str_replace(['\\', '/', chr(0), ':', '..'], '', $name); + } + return ""; + } + + /** + * Get the attachment mime type + * + * @return string|null + */ + public function getMimeType(): ?string { + return (new \finfo())->buffer($this->getContent(), FILEINFO_MIME_TYPE); + } + + /** + * Try to guess the attachment file extension + * + * @return string|null + */ + public function getExtension(): ?string { + $extension = null; + $guesser = "\Symfony\Component\Mime\MimeTypes"; + if (class_exists($guesser) !== false) { + /** @var Symfony\Component\Mime\MimeTypes $guesser */ + $extensions = $guesser::getDefault()->getExtensions($this->getMimeType()); + $extension = $extensions[0] ?? null; + } + if ($extension === null) { + $deprecated_guesser = "\Symfony\Component\HttpFoundation\File\MimeType\ExtensionGuesser"; + if (class_exists($deprecated_guesser) !== false) { + /** @var \Symfony\Component\HttpFoundation\File\MimeType\ExtensionGuesser $deprecated_guesser */ + $extension = $deprecated_guesser::getInstance()->guess($this->getMimeType()); + } + } + if ($extension === null) { + $parts = explode(".", $this->filename); + $extension = count($parts) > 1 ? end($parts) : null; + } + if ($extension === null) { + $parts = explode(".", $this->name); + $extension = count($parts) > 1 ? end($parts) : null; + } + return $extension; + } + + /** + * Get all attributes + * + * @return array + */ + public function getAttributes(): array { + return $this->attributes; + } + + /** + * @return Message + */ + public function getMessage(): Message { + return $this->oMessage; + } + + /** + * Set the default mask + * @param $mask + * + * @return $this + */ + public function setMask($mask): Attachment { + if (class_exists($mask)) { + $this->mask = $mask; + } + + return $this; + } + + /** + * Get the used default mask + * + * @return string + */ + public function getMask(): string { + return $this->mask; + } + + /** + * Get a masked instance by providing a mask name + * @param string|null $mask + * + * @return mixed + * @throws MaskNotFoundException + */ + public function mask(string $mask = null): mixed { + $mask = $mask !== null ? $mask : $this->mask; + if (class_exists($mask)) { + return new $mask($this); + } + + throw new MaskNotFoundException("Unknown mask provided: " . $mask); + } +} diff --git a/plugins/php-imap/Attribute.php b/plugins/php-imap/Attribute.php new file mode 100644 index 00000000..c50cab75 --- /dev/null +++ b/plugins/php-imap/Attribute.php @@ -0,0 +1,325 @@ +setName($name); + $this->add($value); + } + + /** + * Handle class invocation calls + * + * @return array|string + */ + public function __invoke(): array|string { + if ($this->count() > 1) { + return $this->toArray(); + } + return $this->toString(); + } + + /** + * Return the serialized address + * + * @return array + */ + public function __serialize(){ + return $this->values; + } + + /** + * Return the stringified attribute + * + * @return string + */ + public function __toString() { + return implode(", ", $this->values); + } + + /** + * Return the stringified attribute + * + * @return string + */ + public function toString(): string { + return $this->__toString(); + } + + /** + * Convert instance to array + * + * @return array + */ + public function toArray(): array { + return $this->__serialize(); + } + + /** + * Convert first value to a date object + * + * @return Carbon + */ + public function toDate(): Carbon { + $date = $this->first(); + if ($date instanceof Carbon) return $date; + + return Carbon::parse($date); + } + + /** + * Determine if a value exists at a given key. + * + * @param int|string $key + * @return bool + */ + public function has(mixed $key = 0): bool { + return array_key_exists($key, $this->values); + } + + /** + * Determine if a value exists at a given key. + * + * @param int|string $key + * @return bool + */ + public function exist(mixed $key = 0): bool { + return $this->has($key); + } + + /** + * Check if the attribute contains the given value + * @param mixed $value + * + * @return bool + */ + public function contains(mixed $value): bool { + return in_array($value, $this->values, true); + } + + /** + * Get a value by a given key. + * + * @param int|string $key + * @return mixed + */ + public function get(int|string $key = 0): mixed { + return $this->values[$key] ?? null; + } + + /** + * Set the value by a given key. + * + * @param mixed $key + * @param mixed $value + * @return Attribute + */ + public function set(mixed $value, mixed $key = 0): Attribute { + if (is_null($key)) { + $this->values[] = $value; + } else { + $this->values[$key] = $value; + } + return $this; + } + + /** + * Unset a value by a given key. + * + * @param int|string $key + * @return Attribute + */ + public function remove(int|string $key = 0): Attribute { + if (isset($this->values[$key])) { + unset($this->values[$key]); + } + return $this; + } + + /** + * Add one or more values to the attribute + * @param array|mixed $value + * @param boolean $strict + * + * @return Attribute + */ + public function add(mixed $value, bool $strict = false): Attribute { + if (is_array($value)) { + return $this->merge($value, $strict); + }elseif ($value !== null) { + $this->attach($value, $strict); + } + + return $this; + } + + /** + * Merge a given array of values with the current values array + * @param array $values + * @param boolean $strict + * + * @return Attribute + */ + public function merge(array $values, bool $strict = false): Attribute { + foreach ($values as $value) { + $this->attach($value, $strict); + } + + return $this; + } + + /** + * Attach a given value to the current value array + * @param $value + * @param bool $strict + * @return Attribute + */ + public function attach($value, bool $strict = false): Attribute { + if ($strict === true) { + if ($this->contains($value) === false) { + $this->values[] = $value; + } + }else{ + $this->values[] = $value; + } + return $this; + } + + /** + * Set the attribute name + * @param $name + * + * @return Attribute + */ + public function setName($name): Attribute { + $this->name = $name; + + return $this; + } + + /** + * Get the attribute name + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Get all values + * + * @return array + */ + public function all(): array { + reset($this->values); + return $this->values; + } + + /** + * Get the first value if possible + * + * @return mixed|null + */ + public function first(): mixed { + return reset($this->values); + } + + /** + * Get the last value if possible + * + * @return mixed|null + */ + public function last(): mixed { + return end($this->values); + } + + /** + * Get the number of values + * + * @return int + */ + public function count(): int { + return count($this->values); + } + + /** + * @see ArrayAccess::offsetExists + * @param mixed $offset + * @return bool + */ + public function offsetExists(mixed $offset): bool { + return $this->has($offset); + } + + /** + * @see ArrayAccess::offsetGet + * @param mixed $offset + * @return mixed + */ + public function offsetGet(mixed $offset): mixed { + return $this->get($offset); + } + + /** + * @see ArrayAccess::offsetSet + * @param mixed $offset + * @param mixed $value + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void { + $this->set($value, $offset); + } + + /** + * @see ArrayAccess::offsetUnset + * @param mixed $offset + * @return void + */ + public function offsetUnset(mixed $offset): void { + $this->remove($offset); + } + + /** + * @param callable $callback + * @return array + */ + public function map(callable $callback): array { + return array_map($callback, $this->values); + } +} \ No newline at end of file diff --git a/plugins/php-imap/Client.php b/plugins/php-imap/Client.php new file mode 100755 index 00000000..8027dc53 --- /dev/null +++ b/plugins/php-imap/Client.php @@ -0,0 +1,950 @@ + null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ]; + + /** + * Connection timeout + * @var int $timeout + */ + public int $timeout; + + /** + * Account username + * + * @var string + */ + public string $username; + + /** + * Account password. + * + * @var string + */ + public string $password; + + /** + * Additional data fetched from the server. + * + * @var array + */ + public array $extensions; + + /** + * Account authentication method. + * + * @var ?string + */ + public ?string $authentication; + + /** + * Active folder path. + * + * @var ?string + */ + protected ?string $active_folder = null; + + /** + * Default message mask + * + * @var string $default_message_mask + */ + protected string $default_message_mask = MessageMask::class; + + /** + * Default attachment mask + * + * @var string $default_attachment_mask + */ + protected string $default_attachment_mask = AttachmentMask::class; + + /** + * Used default account values + * + * @var array $default_account_config + */ + protected array $default_account_config = [ + 'host' => 'localhost', + 'port' => 993, + 'protocol' => 'imap', + 'encryption' => 'ssl', + 'validate_cert' => true, + 'username' => '', + 'password' => '', + 'authentication' => null, + "extensions" => [], + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + "timeout" => 30 + ]; + + /** + * Client constructor. + * @param array $config + * + * @throws MaskNotFoundException + */ + public function __construct(array $config = []) { + $this->setConfig($config); + $this->setMaskFromConfig($config); + $this->setEventsFromConfig($config); + } + + /** + * Client destructor + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function __destruct() { + $this->disconnect(); + } + + /** + * Clone the current Client instance + * + * @return Client + */ + public function clone(): Client { + $client = new self(); + $client->events = $this->events; + $client->timeout = $this->timeout; + $client->active_folder = $this->active_folder; + $client->default_account_config = $this->default_account_config; + $config = $this->getAccountConfig(); + foreach($config as $key => $value) { + $client->setAccountConfig($key, $config, $this->default_account_config); + } + $client->default_message_mask = $this->default_message_mask; + $client->default_attachment_mask = $this->default_message_mask; + return $client; + } + + /** + * Set the Client configuration + * @param array $config + * + * @return self + */ + public function setConfig(array $config): Client { + $default_account = ClientManager::get('default'); + $default_config = ClientManager::get("accounts.$default_account"); + + foreach ($this->default_account_config as $key => $value) { + $this->setAccountConfig($key, $config, $default_config); + } + + return $this; + } + + /** + * Get the current config + * + * @return array + */ + public function getConfig(): array { + $config = []; + foreach($this->default_account_config as $key => $value) { + $config[$key] = $this->$key; + } + return $config; + } + + /** + * Set a specific account config + * @param string $key + * @param array $config + * @param array $default_config + */ + private function setAccountConfig(string $key, array $config, array $default_config): void { + $value = $this->default_account_config[$key]; + if(isset($config[$key])) { + $value = $config[$key]; + }elseif(isset($default_config[$key])) { + $value = $default_config[$key]; + } + $this->$key = $value; + } + + /** + * Get the current account config + * + * @return array + */ + public function getAccountConfig(): array { + $config = []; + foreach($this->default_account_config as $key => $value) { + if(property_exists($this, $key)) { + $config[$key] = $this->$key; + } + } + return $config; + } + + /** + * Look for a possible events in any available config + * @param $config + */ + protected function setEventsFromConfig($config): void { + $this->events = ClientManager::get("events"); + if(isset($config['events'])){ + foreach($config['events'] as $section => $events) { + $this->events[$section] = array_merge($this->events[$section], $events); + } + } + } + + /** + * Look for a possible mask in any available config + * @param $config + * + * @throws MaskNotFoundException + */ + protected function setMaskFromConfig($config): void { + + if(isset($config['masks'])){ + if(isset($config['masks']['message'])) { + if(class_exists($config['masks']['message'])) { + $this->default_message_mask = $config['masks']['message']; + }else{ + throw new MaskNotFoundException("Unknown mask provided: ".$config['masks']['message']); + } + }else{ + $default_mask = ClientManager::getMask("message"); + if($default_mask != ""){ + $this->default_message_mask = $default_mask; + }else{ + throw new MaskNotFoundException("Unknown message mask provided"); + } + } + if(isset($config['masks']['attachment'])) { + if(class_exists($config['masks']['attachment'])) { + $this->default_attachment_mask = $config['masks']['attachment']; + }else{ + throw new MaskNotFoundException("Unknown mask provided: ". $config['masks']['attachment']); + } + }else{ + $default_mask = ClientManager::getMask("attachment"); + if($default_mask != ""){ + $this->default_attachment_mask = $default_mask; + }else{ + throw new MaskNotFoundException("Unknown attachment mask provided"); + } + } + }else{ + $default_mask = ClientManager::getMask("message"); + if($default_mask != ""){ + $this->default_message_mask = $default_mask; + }else{ + throw new MaskNotFoundException("Unknown message mask provided"); + } + + $default_mask = ClientManager::getMask("attachment"); + if($default_mask != ""){ + $this->default_attachment_mask = $default_mask; + }else{ + throw new MaskNotFoundException("Unknown attachment mask provided"); + } + } + } + + /** + * Get the current imap resource + * + * @return ProtocolInterface + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getConnection(): ProtocolInterface { + $this->checkConnection(); + return $this->connection; + } + + /** + * Determine if connection was established. + * + * @return bool + */ + public function isConnected(): bool { + return $this->connection && $this->connection->connected(); + } + + /** + * Determine if connection was established and connect if not. + * Returns true if the connection was closed and has been reopened. + * + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function checkConnection(): bool { + try { + if (!$this->isConnected()) { + $this->connect(); + return true; + } + } catch (\Throwable) { + $this->connect(); + } + return false; + } + + /** + * Force the connection to reconnect + * + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function reconnect(): void { + if ($this->isConnected()) { + $this->disconnect(); + } + $this->connect(); + } + + /** + * Connect to server. + * + * @return $this + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function connect(): Client { + $this->disconnect(); + $protocol = strtolower($this->protocol); + + if (in_array($protocol, ['imap', 'imap4', 'imap4rev1'])) { + $this->connection = new ImapProtocol($this->validate_cert, $this->encryption); + $this->connection->setConnectionTimeout($this->timeout); + $this->connection->setProxy($this->proxy); + }else{ + if (extension_loaded('imap') === false) { + throw new ConnectionFailedException("connection setup failed", 0, new ProtocolNotSupportedException($protocol." is an unsupported protocol")); + } + $this->connection = new LegacyProtocol($this->validate_cert, $this->encryption); + if (str_starts_with($protocol, "legacy-")) { + $protocol = substr($protocol, 7); + } + $this->connection->setProtocol($protocol); + } + + if (ClientManager::get('options.debug')) { + $this->connection->enableDebug(); + } + + if (!ClientManager::get('options.uid_cache')) { + $this->connection->disableUidCache(); + } + + try { + $this->connection->connect($this->host, $this->port); + } catch (ErrorException|RuntimeException $e) { + throw new ConnectionFailedException("connection setup failed", 0, $e); + } + $this->authenticate(); + + return $this; + } + + /** + * Authenticate the current session + * + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + protected function authenticate(): void { + if ($this->authentication == "oauth") { + if (!$this->connection->authenticate($this->username, $this->password)->validatedData()) { + throw new AuthFailedException(); + } + } elseif (!$this->connection->login($this->username, $this->password)->validatedData()) { + throw new AuthFailedException(); + } + } + + /** + * Disconnect from server. + * + * @return $this + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function disconnect(): Client { + if ($this->isConnected()) { + $this->connection->logout(); + } + $this->active_folder = null; + + return $this; + } + + /** + * Get a folder instance by a folder name + * @param string $folder_name + * @param string|null $delimiter + * @param bool $utf7 + * @return Folder|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function getFolder(string $folder_name, ?string $delimiter = null, bool $utf7 = false): ?Folder { + // Set delimiter to false to force selection via getFolderByName (maybe useful for uncommon folder names) + $delimiter = is_null($delimiter) ? ClientManager::get('options.delimiter', "/") : $delimiter; + + if (str_contains($folder_name, (string)$delimiter)) { + return $this->getFolderByPath($folder_name, $utf7); + } + + return $this->getFolderByName($folder_name); + } + + /** + * Get a folder instance by a folder name + * @param $folder_name + * @param bool $soft_fail If true, it will return null instead of throwing an exception + * + * @return Folder|null + * @throws FolderFetchingException + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getFolderByName($folder_name, bool $soft_fail = false): ?Folder { + return $this->getFolders(false, null, $soft_fail)->where("name", $folder_name)->first(); + } + + /** + * Get a folder instance by a folder path + * @param $folder_path + * @param bool $utf7 + * @param bool $soft_fail If true, it will return null instead of throwing an exception + * + * @return Folder|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function getFolderByPath($folder_path, bool $utf7 = false, bool $soft_fail = false): ?Folder { + if (!$utf7) $folder_path = EncodingAliases::convert($folder_path, "utf-8", "utf7-imap"); + return $this->getFolders(false, null, $soft_fail)->where("path", $folder_path)->first(); + } + + /** + * Get folders list. + * If hierarchical order is set to true, it will make a tree of folders, otherwise it will return flat array. + * + * @param boolean $hierarchical + * @param string|null $parent_folder + * @param bool $soft_fail If true, it will return an empty collection instead of throwing an exception + * + * @return FolderCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function getFolders(bool $hierarchical = true, string $parent_folder = null, bool $soft_fail = false): FolderCollection { + $this->checkConnection(); + $folders = FolderCollection::make([]); + + $pattern = $parent_folder.($hierarchical ? '%' : '*'); + $items = $this->connection->folders('', $pattern)->validatedData(); + + if(!empty($items)){ + foreach ($items as $folder_name => $item) { + $folder = new Folder($this, $folder_name, $item["delimiter"], $item["flags"]); + + if ($hierarchical && $folder->hasChildren()) { + $pattern = $folder->full_name.$folder->delimiter.'%'; + + $children = $this->getFolders(true, $pattern, $soft_fail); + $folder->setChildren($children); + } + + $folders->push($folder); + } + + return $folders; + }else if (!$soft_fail){ + throw new FolderFetchingException("failed to fetch any folders"); + } + + return $folders; + } + + /** + * Get folders list. + * If hierarchical order is set to true, it will make a tree of folders, otherwise it will return flat array. + * + * @param boolean $hierarchical + * @param string|null $parent_folder + * @param bool $soft_fail If true, it will return an empty collection instead of throwing an exception + * + * @return FolderCollection + * @throws FolderFetchingException + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getFoldersWithStatus(bool $hierarchical = true, string $parent_folder = null, bool $soft_fail = false): FolderCollection { + $this->checkConnection(); + $folders = FolderCollection::make([]); + + $pattern = $parent_folder.($hierarchical ? '%' : '*'); + $items = $this->connection->folders('', $pattern)->validatedData(); + + if(!empty($items)){ + foreach ($items as $folder_name => $item) { + $folder = new Folder($this, $folder_name, $item["delimiter"], $item["flags"]); + + if ($hierarchical && $folder->hasChildren()) { + $pattern = $folder->full_name.$folder->delimiter.'%'; + + $children = $this->getFoldersWithStatus(true, $pattern, $soft_fail); + $folder->setChildren($children); + } + + $folder->loadStatus(); + $folders->push($folder); + } + + return $folders; + }else if (!$soft_fail){ + throw new FolderFetchingException("failed to fetch any folders"); + } + + return $folders; + } + + /** + * Open a given folder. + * @param string $folder_path + * @param boolean $force_select + * + * @return array + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function openFolder(string $folder_path, bool $force_select = false): array { + if ($this->active_folder == $folder_path && $this->isConnected() && $force_select === false) { + return []; + } + $this->checkConnection(); + $this->active_folder = $folder_path; + return $this->connection->selectFolder($folder_path)->validatedData(); + } + + /** + * Set active folder + * @param string|null $folder_path + * + * @return void + */ + public function setActiveFolder(?string $folder_path = null): void { + $this->active_folder = $folder_path; + } + + /** + * Get active folder + * + * @return string|null + */ + public function getActiveFolder(): ?string { + return $this->active_folder; + } + + /** + * Create a new Folder + * @param string $folder_path + * @param boolean $expunge + * @param bool $utf7 + * @return Folder + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function createFolder(string $folder_path, bool $expunge = true, bool $utf7 = false): Folder { + $this->checkConnection(); + + if (!$utf7) $folder_path = EncodingAliases::convert($folder_path, "utf-8", "UTF7-IMAP"); + + $status = $this->connection->createFolder($folder_path)->validatedData(); + + if($expunge) $this->expunge(); + + $folder = $this->getFolderByPath($folder_path, true); + if($status && $folder) { + $event = $this->getEvent("folder", "new"); + $event::dispatch($folder); + } + + return $folder; + } + + /** + * Delete a given folder + * @param string $folder_path + * @param boolean $expunge + * + * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function deleteFolder(string $folder_path, bool $expunge = true): array { + $this->checkConnection(); + + $folder = $this->getFolderByPath($folder_path); + if ($this->active_folder == $folder->path){ + $this->active_folder = null; + } + $status = $this->getConnection()->deleteFolder($folder->path)->validatedData(); + if ($expunge) $this->expunge(); + + $event = $this->getEvent("folder", "deleted"); + $event::dispatch($folder); + + return $status; + } + + /** + * Check a given folder + * @param string $folder_path + * + * @return array + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function checkFolder(string $folder_path): array { + $this->checkConnection(); + return $this->connection->examineFolder($folder_path)->validatedData(); + } + + /** + * Get the current active folder + * + * @return string + */ + public function getFolderPath(): string { + return $this->active_folder; + } + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param array|null $ids + * @return array + * + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function Id(array $ids = null): array { + $this->checkConnection(); + return $this->connection->ID($ids)->validatedData(); + } + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * + * @return array + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getQuota(): array { + $this->checkConnection(); + return $this->connection->getQuota($this->username)->validatedData(); + } + + /** + * Retrieve the quota settings per user + * @param string $quota_root + * + * @return array + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): array { + $this->checkConnection(); + return $this->connection->getQuotaRoot($quota_root)->validatedData(); + } + + /** + * Delete all messages marked for deletion + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function expunge(): array { + $this->checkConnection(); + return $this->connection->expunge()->validatedData(); + } + + /** + * Set the connection timeout + * @param integer $timeout + * + * @return ProtocolInterface + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function setTimeout(int $timeout): ProtocolInterface { + $this->timeout = $timeout; + if ($this->isConnected()) { + $this->connection->setConnectionTimeout($timeout); + $this->reconnect(); + } + return $this->connection; + } + + /** + * Get the connection timeout + * + * @return int + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getTimeout(): int { + $this->checkConnection(); + return $this->connection->getConnectionTimeout(); + } + + /** + * Get the default message mask + * + * @return string + */ + public function getDefaultMessageMask(): string { + return $this->default_message_mask; + } + + /** + * Get the default events for a given section + * @param $section + * + * @return array + */ + public function getDefaultEvents($section): array { + if (isset($this->events[$section])) { + return is_array($this->events[$section]) ? $this->events[$section] : []; + } + return []; + } + + /** + * Set the default message mask + * @param string $mask + * + * @return $this + * @throws MaskNotFoundException + */ + public function setDefaultMessageMask(string $mask): Client { + if(class_exists($mask)) { + $this->default_message_mask = $mask; + + return $this; + } + + throw new MaskNotFoundException("Unknown mask provided: ".$mask); + } + + /** + * Get the default attachment mask + * + * @return string + */ + public function getDefaultAttachmentMask(): string { + return $this->default_attachment_mask; + } + + /** + * Set the default attachment mask + * @param string $mask + * + * @return $this + * @throws MaskNotFoundException + */ + public function setDefaultAttachmentMask(string $mask): Client { + if(class_exists($mask)) { + $this->default_attachment_mask = $mask; + + return $this; + } + + throw new MaskNotFoundException("Unknown mask provided: ".$mask); + } +} diff --git a/plugins/php-imap/ClientManager.php b/plugins/php-imap/ClientManager.php new file mode 100644 index 00000000..7f724ffe --- /dev/null +++ b/plugins/php-imap/ClientManager.php @@ -0,0 +1,293 @@ +setConfig($config); + } + + /** + * Dynamically pass calls to the default account. + * @param string $method + * @param array $parameters + * + * @return mixed + * @throws Exceptions\MaskNotFoundException + */ + public function __call(string $method, array $parameters) { + $callable = [$this->account(), $method]; + + return call_user_func_array($callable, $parameters); + } + + /** + * Safely create a new client instance which is not listed in accounts + * @param array $config + * + * @return Client + * @throws Exceptions\MaskNotFoundException + */ + public function make(array $config): Client { + return new Client($config); + } + + /** + * Get a dotted config parameter + * @param string $key + * @param null $default + * + * @return mixed|null + */ + public static function get(string $key, $default = null): mixed { + $parts = explode('.', $key); + $value = null; + foreach ($parts as $part) { + if ($value === null) { + if (isset(self::$config[$part])) { + $value = self::$config[$part]; + } else { + break; + } + } else { + if (isset($value[$part])) { + $value = $value[$part]; + } else { + break; + } + } + } + + return $value === null ? $default : $value; + } + + /** + * Get the mask for a given section + * @param string $section section name such as "message" or "attachment" + * + * @return string|null + */ + public static function getMask(string $section): ?string { + $default_masks = ClientManager::get("masks"); + if (isset($default_masks[$section])) { + if (class_exists($default_masks[$section])) { + return $default_masks[$section]; + } + } + return null; + } + + /** + * Resolve a account instance. + * @param string|null $name + * + * @return Client + * @throws Exceptions\MaskNotFoundException + */ + public function account(string $name = null): Client { + $name = $name ?: $this->getDefaultAccount(); + + // If the connection has not been resolved we will resolve it now as all + // the connections are resolved when they are actually needed, so we do + // not make any unnecessary connection to the various queue end-points. + if (!isset($this->accounts[$name])) { + $this->accounts[$name] = $this->resolve($name); + } + + return $this->accounts[$name]; + } + + /** + * Resolve an account. + * @param string $name + * + * @return Client + * @throws Exceptions\MaskNotFoundException + */ + protected function resolve(string $name): Client { + $config = $this->getClientConfig($name); + + return new Client($config); + } + + /** + * Get the account configuration. + * @param string|null $name + * + * @return array + */ + protected function getClientConfig(?string $name): array { + if ($name === null || $name === 'null' || $name === "") { + return ['driver' => 'null']; + } + $account = self::$config["accounts"][$name] ?? []; + + return is_array($account) ? $account : []; + } + + /** + * Get the name of the default account. + * + * @return string + */ + public function getDefaultAccount(): string { + return self::$config['default']; + } + + /** + * Set the name of the default account. + * @param string $name + * + * @return void + */ + public function setDefaultAccount(string $name): void { + self::$config['default'] = $name; + } + + + /** + * Merge the vendor settings with the local config + * + * The default account identifier will be used as default for any missing account parameters. + * If however the default account is missing a parameter the package default account parameter will be used. + * This can be disabled by setting imap.default in your config file to 'false' + * + * @param array|string $config + * + * @return $this + */ + public function setConfig(array|string $config): ClientManager { + + if (is_array($config) === false) { + $config = require $config; + } + + $config_key = 'imap'; + $path = __DIR__ . '/config/' . $config_key . '.php'; + + $vendor_config = require $path; + $config = $this->array_merge_recursive_distinct($vendor_config, $config); + + if (is_array($config)) { + if (isset($config['default'])) { + if (isset($config['accounts']) && $config['default']) { + + $default_config = $vendor_config['accounts']['default']; + if (isset($config['accounts'][$config['default']])) { + $default_config = array_merge($default_config, $config['accounts'][$config['default']]); + } + + if (is_array($config['accounts'])) { + foreach ($config['accounts'] as $account_key => $account) { + $config['accounts'][$account_key] = array_merge($default_config, $account); + } + } + } + } + } + + self::$config = $config; + + return $this; + } + + /** + * Marge arrays recursively and distinct + * + * Merges any number of arrays / parameters recursively, replacing + * entries with string keys with values from latter arrays. + * If the entry or the next value to be assigned is an array, then it + * automatically treats both arguments as an array. + * Numeric entries are appended, not replaced, but only if they are + * unique + * + * @return array|mixed + * + * @link http://www.php.net/manual/en/function.array-merge-recursive.php#96201 + * @author Mark Roduner + */ + private function array_merge_recursive_distinct(): mixed { + + $arrays = func_get_args(); + $base = array_shift($arrays); + + // From https://stackoverflow.com/a/173479 + $isAssoc = function(array $arr) { + if (array() === $arr) return false; + return array_keys($arr) !== range(0, count($arr) - 1); + }; + + if (!is_array($base)) $base = empty($base) ? array() : array($base); + + foreach ($arrays as $append) { + + if (!is_array($append)) $append = array($append); + + foreach ($append as $key => $value) { + + if (!array_key_exists($key, $base) and !is_numeric($key)) { + $base[$key] = $value; + continue; + } + + if ( + ( + is_array($value) + && $isAssoc($value) + ) + || ( + is_array($base[$key]) + && $isAssoc($base[$key]) + ) + ) { + // If the arrays are not associates we don't want to array_merge_recursive_distinct + // else merging $baseConfig['dispositions'] = ['attachment', 'inline'] with $customConfig['dispositions'] = ['attachment'] + // results in $resultConfig['dispositions'] = ['attachment', 'inline'] + $base[$key] = $this->array_merge_recursive_distinct($base[$key], $value); + } else if (is_numeric($key)) { + if (!in_array($value, $base)) $base[] = $value; + } else { + $base[$key] = $value; + } + + } + + } + + return $base; + } +} \ No newline at end of file diff --git a/plugins/php-imap/Connection/Protocols/ImapProtocol.php b/plugins/php-imap/Connection/Protocols/ImapProtocol.php new file mode 100644 index 00000000..4d54579f --- /dev/null +++ b/plugins/php-imap/Connection/Protocols/ImapProtocol.php @@ -0,0 +1,1300 @@ +setCertValidation($cert_validation); + $this->encryption = $encryption; + } + + /** + * Handle the class destruction / tear down + */ + public function __destruct() { + $this->logout(); + } + + /** + * Open connection to IMAP server + * @param string $host hostname or IP address of IMAP server + * @param int|null $port of IMAP server, default is 143 and 993 for ssl + * + * @throws ConnectionFailedException + */ + public function connect(string $host, int $port = null): bool { + $transport = 'tcp'; + $encryption = ''; + + if ($this->encryption) { + $encryption = strtolower($this->encryption); + if (in_array($encryption, ['ssl', 'tls'])) { + $transport = $encryption; + $port = $port === null ? 993 : $port; + } + } + $port = $port === null ? 143 : $port; + try { + $response = new Response(0, $this->debug); + $this->stream = $this->createStream($transport, $host, $port, $this->connection_timeout); + if (!$this->stream || !$this->assumedNextLine($response, '* OK')) { + throw new ConnectionFailedException('connection refused'); + } + if ($encryption == 'starttls') { + $this->enableStartTls(); + } + } catch (Exception $e) { + throw new ConnectionFailedException('connection failed', 0, $e); + } + return true; + } + + /** + * Enable tls on the current connection + * + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + protected function enableStartTls() { + $response = $this->requestAndResponse('STARTTLS'); + $result = $response->successful() && stream_socket_enable_crypto($this->stream, true, $this->getCryptoMethod()); + if (!$result) { + throw new ConnectionFailedException('failed to enable TLS'); + } + } + + /** + * Get the next line from stream + * + * @return string next line + * @throws RuntimeException + */ + public function nextLine(Response $response): string { + $line = ""; + while (($next_char = fread($this->stream, 1)) !== false && !in_array($next_char, ["","\n"])) { + $line .= $next_char; + } + if ($line === "" && ($next_char === false || $next_char === "")) { + throw new RuntimeException('empty response'); + } + $line .= "\n"; + $response->addResponse($line); + if ($this->debug) echo "<< " . $line; + return $line; + } + + /** + * Get the next line and check if it starts with a given string + * @param Response $response + * @param string $start + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextLine(Response $response, string $start): bool { + return str_starts_with($this->nextLine($response), $start); + } + + /** + * Get the next line and split the tag + * @param string|null $tag reference tag + * + * @return string next line + * @throws RuntimeException + */ + protected function nextTaggedLine(Response $response, ?string &$tag): string { + $line = $this->nextLine($response); + if (str_contains($line, ' ')) { + list($tag, $line) = explode(' ', $line, 2); + } + + return $line ?? ''; + } + + /** + * Get the next line and check if it contains a given string and split the tag + * @param Response $response + * @param string $start + * @param $tag + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextTaggedLine(Response $response, string $start, &$tag): bool { + return str_contains($this->nextTaggedLine($response, $tag), $start); + } + + /** + * Split a given line in values. A value is literal of any form or a list + * @param Response $response + * @param string $line + * + * @return array + * @throws RuntimeException + */ + protected function decodeLine(Response $response, string $line): array { + $tokens = []; + $stack = []; + + // replace any trailing including spaces with a single space + $line = rtrim($line) . ' '; + while (($pos = strpos($line, ' ')) !== false) { + $token = substr($line, 0, $pos); + if (!strlen($token)) { + $line = substr($line, $pos + 1); + continue; + } + while ($token[0] == '(') { + $stack[] = $tokens; + $tokens = []; + $token = substr($token, 1); + } + if ($token[0] == '"') { + if (preg_match('%^\(*\"((.|\\\|\")*?)\"( |$)%', $line, $matches)) { + $tokens[] = $matches[1]; + $line = substr($line, strlen($matches[0])); + continue; + } + } + if ($token[0] == '{') { + $endPos = strpos($token, '}'); + $chars = substr($token, 1, $endPos - 1); + if (is_numeric($chars)) { + $token = ''; + while (strlen($token) < $chars) { + $token .= $this->nextLine($response); + } + $line = ''; + if (strlen($token) > $chars) { + $line = substr($token, $chars); + $token = substr($token, 0, $chars); + } else { + $line .= $this->nextLine($response); + } + $tokens[] = $token; + $line = trim($line) . ' '; + continue; + } + } + if ($stack && $token[strlen($token) - 1] == ')') { + // closing braces are not separated by spaces, so we need to count them + $braces = strlen($token); + $token = rtrim($token, ')'); + // only count braces if more than one + $braces -= strlen($token) + 1; + // only add if token had more than just closing braces + if (rtrim($token) != '') { + $tokens[] = rtrim($token); + } + $token = $tokens; + $tokens = array_pop($stack); + // special handling if more than one closing brace + while ($braces-- > 0) { + $tokens[] = $token; + $token = $tokens; + $tokens = array_pop($stack); + } + } + $tokens[] = $token; + $line = substr($line, $pos + 1); + } + + // maybe the server forgot to send some closing braces + while ($stack) { + $child = $tokens; + $tokens = array_pop($stack); + $tokens[] = $child; + } + + return $tokens; + } + + /** + * Read abd decode a response "line" + * @param Response $response + * @param array|string $tokens to decode + * @param string $wantedTag targeted tag + * @param bool $dontParse if true only the unparsed line is returned in $tokens + * + * @return bool + * @throws RuntimeException + */ + public function readLine(Response $response, array|string &$tokens = [], string $wantedTag = '*', bool $dontParse = false): bool { + $line = $this->nextTaggedLine($response, $tag); // get next tag + if (!$dontParse) { + $tokens = $this->decodeLine($response, $line); + } else { + $tokens = $line; + } + + // if tag is wanted tag we might be at the end of a multiline response + return $tag == $wantedTag; + } + + /** + * Read all lines of response until given tag is found + * @param Response $response + * @param string $tag request tag + * @param bool $dontParse if true every line is returned unparsed instead of the decoded tokens + * + * @return array + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function readResponse(Response $response, string $tag, bool $dontParse = false): array { + $lines = []; + $tokens = ""; // define $tokens variable before first use + do { + $readAll = $this->readLine($response, $tokens, $tag, $dontParse); + $lines[] = $tokens; + } while (!$readAll); + + $original = $tokens; + if ($dontParse) { + // First two chars are still needed for the response code + $tokens = [trim(substr($tokens, 0, 3))]; + } + + $original = is_array($original)?$original : [$original]; + + // last line has response code + if ($tokens[0] == 'OK') { + return $lines ?: [true]; + } elseif ($tokens[0] == 'NO' || $tokens[0] == 'BAD' || $tokens[0] == 'BYE') { + throw new ImapServerErrorException(implode("\n", $original)); + } + + throw new ImapBadRequestException(implode("\n", $original)); + } + + /** + * Send a new request + * @param string $command + * @param array $tokens additional parameters to command, use escapeString() to prepare + * @param string|null $tag provide a tag otherwise an autogenerated is returned + * + * @return Response + * @throws RuntimeException + */ + public function sendRequest(string $command, array $tokens = [], string &$tag = null): Response { + if (!$tag) { + $this->noun++; + $tag = 'TAG' . $this->noun; + } + + $line = $tag . ' ' . $command; + + $response = new Response($this->noun, $this->debug); + + foreach ($tokens as $token) { + if (is_array($token)) { + $this->write($response, $line . ' ' . $token[0]); + if (!$this->assumedNextLine($response, '+ ')) { + throw new RuntimeException('failed to send literal string'); + } + $line = $token[1]; + } else { + $line .= ' ' . $token; + } + } + $this->write($response, $line); + + return $response; + } + + /** + * Write data to the current stream + * @param Response $response + * @param string $data + * + * @return void + * @throws RuntimeException + */ + public function write(Response $response, string $data): void { + $command = $data . "\r\n"; + if ($this->debug) echo ">> " . $command . "\n"; + + $response->addCommand($command); + + if (fwrite($this->stream, $command) === false) { + throw new RuntimeException('failed to write - connection closed?'); + } + } + + /** + * Send a request and get response at once + * + * @param string $command + * @param array $tokens parameters as in sendRequest() + * @param bool $dontParse if true unparsed lines are returned instead of tokens + * + * @return Response response as in readResponse() + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function requestAndResponse(string $command, array $tokens = [], bool $dontParse = false): Response { + $response = $this->sendRequest($command, $tokens, $tag); + $response->setResult($this->readResponse($response, $tag, $dontParse)); + + return $response; + } + + /** + * Escape one or more literals i.e. for sendRequest + * @param array|string $string the literal/-s + * + * @return string|array escape literals, literals with newline ar returned + * as array('{size}', 'string'); + */ + public function escapeString(array|string $string): array|string { + if (func_num_args() < 2) { + if (str_contains($string, "\n")) { + return ['{' . strlen($string) . '}', $string]; + } else { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $string) . '"'; + } + } + $result = []; + foreach (func_get_args() as $string) { + $result[] = $this->escapeString($string); + } + return $result; + } + + /** + * Escape a list with literals or lists + * @param array $list list with literals or lists as PHP array + * + * @return string escaped list for imap + */ + public function escapeList(array $list): string { + $result = []; + foreach ($list as $v) { + if (!is_array($v)) { + $result[] = $v; + continue; + } + $result[] = $this->escapeList($v); + } + return '(' . implode(' ', $result) . ')'; + } + + /** + * Login to a new session. + * + * @param string $user username + * @param string $password password + * + * @return Response + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + */ + public function login(string $user, string $password): Response { + try { + $command = 'LOGIN'; + $params = $this->escapeString($user, $password); + + return $this->requestAndResponse($command, $params, true); + } catch (RuntimeException $e) { + throw new AuthFailedException("failed to authenticate", 0, $e); + } + } + + /** + * Authenticate your current IMAP session. + * @param string $user username + * @param string $token access token + * + * @return Response + * @throws AuthFailedException + */ + public function authenticate(string $user, string $token): Response { + try { + $authenticateParams = ['XOAUTH2', base64_encode("user=$user\1auth=Bearer $token\1\1")]; + $response = $this->sendRequest('AUTHENTICATE', $authenticateParams); + + while (true) { + $tokens = ""; + $is_plus = $this->readLine($response, $tokens, '+', true); + if ($is_plus) { + // try to log the challenge somewhere where it can be found + error_log("got an extra server challenge: $tokens"); + // respond with an empty response. + $response->stack($this->sendRequest('')); + } else { + if (preg_match('/^NO /i', $tokens) || + preg_match('/^BAD /i', $tokens)) { + error_log("got failure response: $tokens"); + return $response->addError("got failure response: $tokens"); + } else if (preg_match("/^OK /i", $tokens)) { + return $response->setResult(is_array($tokens) ? $tokens : [$tokens]); + } + } + } + } catch (RuntimeException $e) { + throw new AuthFailedException("failed to authenticate", 0, $e); + } + } + + /** + * Logout of imap server + * + * @return Response + */ + public function logout(): Response { + if (!$this->stream) { + $this->reset(); + return new Response(0, $this->debug); + }elseif ($this->meta()["timed_out"]) { + $this->reset(); + return new Response(0, $this->debug); + } + + $result = null; + try { + $result = $this->requestAndResponse('LOGOUT', [], true); + fclose($this->stream); + } catch (\Throwable) {} + + $this->reset(); + + return $result ?? new Response(0, $this->debug); + } + + /** + * Reset the current stream and uid cache + * + * @return void + */ + public function reset(): void { + $this->stream = null; + $this->uid_cache = []; + } + + /** + * Get an array of available capabilities + * + * @return Response list of capabilities + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getCapabilities(): Response { + $response = $this->requestAndResponse('CAPABILITY'); + + if (!$response->getResponse()) return $response; + + return $response->setResult($response->validatedData()[0]); + } + + /** + * Examine and select have the same response. + * @param string $command can be 'EXAMINE' or 'SELECT' + * @param string $folder target folder + * + * @return Response + * @throws RuntimeException + */ + public function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): Response { + $response = $this->sendRequest($command, [$this->escapeString($folder)], $tag); + + $result = []; + $tokens = []; // define $tokens variable before first use + while (!$this->readLine($response, $tokens, $tag, false)) { + if ($tokens[0] == 'FLAGS') { + array_shift($tokens); + $result['flags'] = $tokens; + continue; + } + switch ($tokens[1]) { + case 'EXISTS': + case 'RECENT': + $result[strtolower($tokens[1])] = (int)$tokens[0]; + break; + case '[UIDVALIDITY': + $result['uidvalidity'] = (int)$tokens[2]; + break; + case '[UIDNEXT': + $result['uidnext'] = (int)$tokens[2]; + break; + case '[UNSEEN': + $result['unseen'] = (int)$tokens[2]; + break; + case '[NONEXISTENT]': + throw new RuntimeException("folder doesn't exist"); + default: + // ignore + break; + } + } + + $response->setResult($result); + + if ($tokens[0] != 'OK') { + $response->addError("request failed"); + } + return $response; + } + + /** + * Change the current folder + * @param string $folder change to this folder + * + * @return Response see examineOrSelect() + * @throws RuntimeException + */ + public function selectFolder(string $folder = 'INBOX'): Response { + $this->uid_cache = []; + + return $this->examineOrSelect('SELECT', $folder); + } + + /** + * Examine a given folder + * @param string $folder examine this folder + * + * @return Response see examineOrSelect() + * @throws RuntimeException + */ + public function examineFolder(string $folder = 'INBOX'): Response { + return $this->examineOrSelect('EXAMINE', $folder); + } + + /** + * Fetch one or more items of one or more messages + * @param array|string $items items to fetch [RFC822.HEADER, FLAGS, RFC822.TEXT, etc] + * @param array|int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response if only one item of one message is fetched it's returned as string + * if items of one message are fetched it's returned as (name => value) + * if one item of messages are fetched it's returned as (msgno => value) + * if items of messages are fetched it's returned as (msgno => (name => value)) + * @throws RuntimeException + */ + public function fetch(array|string $items, array|int $from, mixed $to = null, int|string $uid = IMAP::ST_UID): Response { + if (is_array($from)) { + $set = implode(',', $from); + } elseif ($to === null) { + $set = $from; + } elseif ($to == INF) { + $set = $from . ':*'; + } else { + $set = $from . ':' . (int)$to; + } + + $items = (array)$items; + $itemList = $this->escapeList($items); + + $response = $this->sendRequest($this->buildUIDCommand("FETCH", $uid), [$set, $itemList], $tag); + $result = []; + $tokens = []; // define $tokens variable before first use + while (!$this->readLine($response, $tokens, $tag)) { + // ignore other responses + if ($tokens[1] != 'FETCH') { + continue; + } + + $uidKey = 0; + $data = []; + + // find array key of UID value; try the last elements, or search for it + if ($uid === IMAP::ST_UID) { + $count = count($tokens[2]); + if ($tokens[2][$count - 2] == 'UID') { + $uidKey = $count - 1; + } else if ($tokens[2][0] == 'UID') { + $uidKey = 1; + } else { + $found = array_search('UID', $tokens[2]); + if ($found === false || $found === -1) { + continue; + } + + $uidKey = $found + 1; + } + } + + // ignore other messages + if ($to === null && !is_array($from) && ($uid === IMAP::ST_UID ? $tokens[2][$uidKey] != $from : $tokens[0] != $from)) { + continue; + } + + // if we only want one item we return that one directly + if (count($items) == 1) { + if ($tokens[2][0] == $items[0]) { + $data = $tokens[2][1]; + } elseif ($uid === IMAP::ST_UID && $tokens[2][2] == $items[0]) { + $data = $tokens[2][3]; + } else { + $expectedResponse = 0; + // maybe the server send another field we didn't wanted + $count = count($tokens[2]); + // we start with 2, because 0 was already checked + for ($i = 2; $i < $count; $i += 2) { + if ($tokens[2][$i] != $items[0]) { + continue; + } + $data = $tokens[2][$i + 1]; + $expectedResponse = 1; + break; + } + if (!$expectedResponse) { + continue; + } + } + } else { + while (key($tokens[2]) !== null) { + $data[current($tokens[2])] = next($tokens[2]); + next($tokens[2]); + } + } + + // if we want only one message we can ignore everything else and just return + if ($to === null && !is_array($from) && ($uid === IMAP::ST_UID ? $tokens[2][$uidKey] == $from : $tokens[0] == $from)) { + // we still need to read all lines + if (!$this->readLine($response, $tokens, $tag)) + return $response->setResult($data); + } + if ($uid === IMAP::ST_UID) { + $result[$tokens[2][$uidKey]] = $data; + } else { + $result[$tokens[0]] = $data; + } + } + + if ($to === null && !is_array($from)) { + throw new RuntimeException('the single id was not found in response'); + } + + return $response->setResult($result); + } + + /** + * Fetch message headers + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function content(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["$rfc.TEXT"], is_array($uids)?$uids:[$uids], null, $uid); + } + + /** + * Fetch message headers + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function headers(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["$rfc.HEADER"], is_array($uids)?$uids:[$uids], null, $uid); + } + + /** + * Fetch message flags + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function flags(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["FLAGS"], is_array($uids)?$uids:[$uids], null, $uid); + } + + /** + * Fetch message sizes + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function sizes(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["RFC822.SIZE"], is_array($uids)?$uids:[$uids], null, $uid); + } + + /** + * Get uid for a given id + * @param int|null $id message number + * + * @return Response message number for given message or all messages as array + * @throws MessageNotFoundException + */ + public function getUid(?int $id = null): Response { + if (!$this->enable_uid_cache || empty($this->uid_cache) || count($this->uid_cache) <= 0) { + try { + $this->setUidCache((array)$this->fetch('UID', 1, INF)->data()); // set cache for this folder + } catch (RuntimeException) { + } + } + $uids = $this->uid_cache; + + if ($id == null) { + return Response::empty($this->debug)->setResult($uids); + } + + foreach ($uids as $k => $v) { + if ($k == $id) { + return Response::empty($this->debug)->setResult($v); + } + } + + // clear uid cache and run method again + if ($this->enable_uid_cache && $this->uid_cache) { + $this->setUidCache(null); + return $this->getUid($id); + } + + throw new MessageNotFoundException('unique id not found'); + } + + /** + * Get a message number for a uid + * @param string $id uid + * + * @return Response message number + * @throws MessageNotFoundException + */ + public function getMessageNumber(string $id): Response { + foreach ($this->getUid()->data() as $k => $v) { + if ($v == $id) { + return Response::empty($this->debug)->setResult((int)$k); + } + } + + throw new MessageNotFoundException('message number not found: ' . $id); + } + + /** + * Get a list of available folders + * + * @param string $reference mailbox reference for list + * @param string $folder mailbox name match with wildcards + * + * @return Response folders that matched $folder as array(name => array('delimiter' => .., 'flags' => ..)) + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function folders(string $reference = '', string $folder = '*'): Response { + $response = $this->requestAndResponse('LIST', $this->escapeString($reference, $folder))->setCanBeEmpty(true); + $list = $response->data(); + + $result = []; + if ($list[0] !== true) { + foreach ($list as $item) { + if (count($item) != 4 || $item[0] != 'LIST') { + continue; + } + $item[3] = str_replace("\\\\", "\\", str_replace("\\\"", "\"", $item[3])); + $result[$item[3]] = ['delimiter' => $item[2], 'flags' => $item[1]]; + } + } + + return $response->setResult($result); + } + + /** + * Manage flags + * + * @param array|string $flags flags to set, add or remove - see $mode + * @param int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given + * @param bool $silent if false the return values are the new flags for the wanted messages + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * @param string|null $item command used to store a flag + * + * @return Response new flags if $silent is false, else true or false depending on success + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function store( + array|string $flags, int $from, int $to = null, string $mode = null, bool $silent = true, int|string $uid = IMAP::ST_UID, string $item = null + ): Response { + $flags = $this->escapeList(is_array($flags) ? $flags : [$flags]); + $set = $this->buildSet($from, $to); + + $command = $this->buildUIDCommand("STORE", $uid); + $item = ($mode == '-' ? "-" : "+") . ($item === null ? "FLAGS" : $item) . ($silent ? '.SILENT' : ""); + + $response = $this->requestAndResponse($command, [$set, $item, $flags], $silent); + + if ($silent) { + return $response; + } + + $result = []; + foreach ($response as $token) { + if ($token[1] != 'FETCH' || $token[2][0] != 'FLAGS') { + continue; + } + $result[$token[0]] = $token[2][1]; + } + + + return $response->setResult($result); + } + + /** + * Append a new message to given folder + * + * @param string $folder name of target folder + * @param string $message full message content + * @param array|null $flags flags for new message + * @param string|null $date date for new message + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function appendMessage(string $folder, string $message, array $flags = null, string $date = null): Response { + $tokens = []; + $tokens[] = $this->escapeString($folder); + if ($flags !== null) { + $tokens[] = $this->escapeList($flags); + } + if ($date !== null) { + $tokens[] = $this->escapeString($date); + } + $tokens[] = $this->escapeString($message); + + return $this->requestAndResponse('APPEND', $tokens, true); + } + + /** + * Copy a message set from current folder to another folder + * + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function copyMessage(string $folder, $from, int $to = null, int|string $uid = IMAP::ST_UID): Response { + $set = $this->buildSet($from, $to); + $command = $this->buildUIDCommand("COPY", $uid); + + return $this->requestAndResponse($command, [$set, $this->escapeString($folder)], true); + } + + /** + * Copy multiple messages to the target folder + * + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response Tokens if operation successful, false if an error occurred + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function copyManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + $command = $this->buildUIDCommand("COPY", $uid); + + $set = implode(',', $messages); + $tokens = [$set, $this->escapeString($folder)]; + + return $this->requestAndResponse($command, $tokens, true); + } + + /** + * Move a message set from current folder to another folder + * + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function moveMessage(string $folder, $from, int $to = null, int|string $uid = IMAP::ST_UID): Response { + $set = $this->buildSet($from, $to); + $command = $this->buildUIDCommand("MOVE", $uid); + + return $this->requestAndResponse($command, [$set, $this->escapeString($folder)], true); + } + + /** + * Move multiple messages to the target folder + * + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function moveManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + $command = $this->buildUIDCommand("MOVE", $uid); + $set = implode(',', $messages); + $tokens = [$set, $this->escapeString($folder)]; + + return $this->requestAndResponse($command, $tokens, true); + } + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param array|null $ids + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function ID($ids = null): Response { + $token = "NIL"; + if (is_array($ids) && !empty($ids)) { + $token = "("; + foreach ($ids as $id) { + $token .= '"' . $id . '" '; + } + $token = rtrim($token) . ")"; + } + + return $this->requestAndResponse("ID", [$token], true); + } + + /** + * Create a new folder (and parent folders if needed) + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function createFolder(string $folder): Response { + return $this->requestAndResponse('CREATE', [$this->escapeString($folder)], true); + } + + /** + * Rename an existing folder + * + * @param string $old old name + * @param string $new new name + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function renameFolder(string $old, string $new): Response { + return $this->requestAndResponse('RENAME', $this->escapeString($old, $new), true); + } + + /** + * Delete a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function deleteFolder(string $folder): Response { + return $this->requestAndResponse('DELETE', [$this->escapeString($folder)], true); + } + + /** + * Subscribe to a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function subscribeFolder(string $folder): Response { + return $this->requestAndResponse('SUBSCRIBE', [$this->escapeString($folder)], true); + } + + /** + * Unsubscribe from a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function unsubscribeFolder(string $folder): Response { + return $this->requestAndResponse('UNSUBSCRIBE', [$this->escapeString($folder)], true); + } + + /** + * Apply session saved changes to the server + * + * @return Response + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function expunge(): Response { + $this->uid_cache = []; + return $this->requestAndResponse('EXPUNGE'); + } + + /** + * Send noop command + * + * @return Response + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function noop(): Response { + return $this->requestAndResponse('NOOP'); + } + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * + * @param $username + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * + * @Doc https://www.rfc-editor.org/rfc/rfc2087.txt + */ + public function getQuota($username): Response { + $command = "GETQUOTA"; + $params = ['"#user/' . $username . '"']; + + return $this->requestAndResponse($command, $params); + } + + /** + * Retrieve the quota settings per user + * + * @param string $quota_root + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * + * @Doc https://www.rfc-editor.org/rfc/rfc2087.txt + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): Response { + $command = "GETQUOTAROOT"; + $params = [$quota_root]; + + return $this->requestAndResponse($command, $params); + } + + /** + * Send idle command + * + * @throws RuntimeException + */ + public function idle() { + $response = $this->sendRequest("IDLE"); + if (!$this->assumedNextLine($response, '+ ')) { + throw new RuntimeException('idle failed'); + } + } + + /** + * Send done command + * @throws RuntimeException + */ + public function done(): bool { + $response = new Response($this->noun, $this->debug); + $this->write($response, "DONE"); + if (!$this->assumedNextTaggedLine($response, 'OK', $tags)) { + throw new RuntimeException('done failed'); + } + return true; + } + + /** + * Search for matching messages + * + * @param array $params + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response message ids + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function search(array $params, int|string $uid = IMAP::ST_UID): Response { + $command = $this->buildUIDCommand("SEARCH", $uid); + $response = $this->requestAndResponse($command, $params)->setCanBeEmpty(true); + + foreach ($response->data() as $ids) { + if ($ids[0] === 'SEARCH') { + array_shift($ids); + return $response->setResult($ids); + } + } + + return $response; + } + + /** + * Get a message overview + * @param string $sequence + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + * @throws MessageNotFoundException + * @throws InvalidMessageDateException + */ + public function overview(string $sequence, int|string $uid = IMAP::ST_UID): Response { + $result = []; + list($from, $to) = explode(":", $sequence); + + $response = $this->getUid(); + $ids = []; + foreach ($response->data() as $msgn => $v) { + $id = $uid === IMAP::ST_UID ? $v : $msgn; + if (($to >= $id && $from <= $id) || ($to === "*" && $from <= $id)) { + $ids[] = $id; + } + } + if (!empty($ids)) { + $headers = $this->headers($ids, "RFC822", $uid); + $response->stack($headers); + foreach ($headers->data() as $id => $raw_header) { + $result[$id] = (new Header($raw_header, false))->getAttributes(); + } + } + return $response->setResult($result)->setCanBeEmpty(true); + } + + /** + * Enable the debug mode + * + * @return void + */ + public function enableDebug(): void { + $this->debug = true; + } + + /** + * Disable the debug mode + * + * @return void + */ + public function disableDebug(): void { + $this->debug = false; + } + + /** + * Build a valid UID number set + * @param $from + * @param null $to + * + * @return int|string + */ + public function buildSet($from, $to = null): int|string { + $set = (int)$from; + if ($to !== null) { + $set .= ':' . ($to == INF ? '*' : (int)$to); + } + return $set; + } +} diff --git a/plugins/php-imap/Connection/Protocols/LegacyProtocol.php b/plugins/php-imap/Connection/Protocols/LegacyProtocol.php new file mode 100644 index 00000000..10bc9d9f --- /dev/null +++ b/plugins/php-imap/Connection/Protocols/LegacyProtocol.php @@ -0,0 +1,814 @@ +setCertValidation($cert_validation); + $this->encryption = $encryption; + } + + /** + * Public destructor + */ + public function __destruct() { + $this->logout(); + } + + /** + * Save the information for a nw connection + * @param string $host + * @param int|null $port + */ + public function connect(string $host, int $port = null) { + if ($this->encryption) { + $encryption = strtolower($this->encryption); + if ($encryption == "ssl") { + $port = $port === null ? 993 : $port; + } + } + $port = $port === null ? 143 : $port; + $this->host = $host; + $this->port = $port; + } + + /** + * Login to a new session. + * @param string $user username + * @param string $password password + * + * @return Response + */ + public function login(string $user, string $password): Response { + return $this->response()->wrap(function($response) use ($user, $password) { + /** @var Response $response */ + try { + $this->stream = \imap_open( + $this->getAddress(), + $user, + $password, + 0, + $attempts = 3, + ClientManager::get('options.open') + ); + $response->addCommand("imap_open"); + } catch (\ErrorException $e) { + $errors = \imap_errors(); + $message = $e->getMessage() . '. ' . implode("; ", (is_array($errors) ? $errors : array())); + throw new AuthFailedException($message); + } + + if (!$this->stream) { + $errors = \imap_errors(); + $message = implode("; ", (is_array($errors) ? $errors : array())); + throw new AuthFailedException($message); + } + + $errors = \imap_errors(); + $response->addCommand("imap_errors"); + if (is_array($errors)) { + $status = $this->examineFolder(); + $response->stack($status); + if ($status->data()['exists'] !== 0) { + $message = implode("; ", $errors); + throw new RuntimeException($message); + } + } + + if ($this->stream !== false) { + return ["TAG" . $response->Noun() . " OK [] Logged in\r\n"]; + } + + $response->addError("failed to login"); + return []; + }); + } + + /** + * Authenticate your current session. + * @param string $user username + * @param string $token access token + * + * @return Response + * @throws AuthFailedException + * @throws RuntimeException + */ + public function authenticate(string $user, string $token): Response { + return $this->login($user, $token); + } + + /** + * Get full address of mailbox. + * + * @return string + */ + protected function getAddress(): string { + $address = "{" . $this->host . ":" . $this->port . "/" . $this->protocol; + if (!$this->cert_validation) { + $address .= '/novalidate-cert'; + } + if (in_array($this->encryption, ['tls', 'notls', 'ssl'])) { + $address .= '/' . $this->encryption; + } elseif ($this->encryption === "starttls") { + $address .= '/tls'; + } + + $address .= '}'; + + return $address; + } + + /** + * Logout of the current session + * + * @return Response + */ + public function logout(): Response { + return $this->response()->wrap(function($response) { + /** @var Response $response */ + if ($this->stream) { + $this->uid_cache = []; + $response->addCommand("imap_close"); + if (\imap_close($this->stream, IMAP::CL_EXPUNGE)) { + $this->stream = false; + return [ + 0 => "BYE Logging out\r\n", + 1 => "TAG" . $response->Noun() . " OK Logout completed (0.001 + 0.000 secs).\r\n", + ]; + } + $this->stream = false; + } + return []; + }); + } + + /** + * Get an array of available capabilities + * + * @throws MethodNotSupportedException + */ + public function getCapabilities(): Response { + throw new MethodNotSupportedException(); + } + + /** + * Change the current folder + * @param string $folder change to this folder + * + * @return Response see examineOrselect() + * @throws RuntimeException + */ + public function selectFolder(string $folder = 'INBOX'): Response { + $flags = IMAP::OP_READONLY; + if (in_array($this->protocol, ["pop3", "nntp"])) { + $flags = IMAP::NIL; + } + if ($this->stream === false) { + throw new RuntimeException("failed to reopen stream."); + } + + return $this->response("imap_reopen")->wrap(function($response) use ($folder, $flags) { + /** @var Response $response */ + \imap_reopen($this->stream, $this->getAddress() . $folder, $flags, 3); + $this->uid_cache = []; + + $status = $this->examineFolder($folder); + $response->stack($status); + + return $status->data(); + }); + } + + /** + * Examine a given folder + * @param string $folder examine this folder + * + * @return Response + * @throws RuntimeException + */ + public function examineFolder(string $folder = 'INBOX'): Response { + if (str_starts_with($folder, ".")) { + throw new RuntimeException("Segmentation fault prevented. Folders starts with an illegal char '.'."); + } + return $this->response("imap_status")->wrap(function($response) use ($folder) { + /** @var Response $response */ + $status = \imap_status($this->stream, $this->getAddress() . $folder, IMAP::SA_ALL); + + return $status ? [ + "flags" => [], + "exists" => $status->messages, + "recent" => $status->recent, + "unseen" => $status->unseen, + "uidnext" => $status->uidnext, + ] : []; + }); + } + + /** + * Fetch message content + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function content(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + foreach ($uids as $id) { + $response->addCommand("imap_fetchbody"); + $result[$id] = \imap_fetchbody($this->stream, $id, "", $uid === IMAP::ST_UID ? IMAP::ST_UID : IMAP::NIL); + } + + return $result; + }); + } + + /** + * Fetch message headers + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function headers(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + foreach ($uids as $id) { + $response->addCommand("imap_fetchheader"); + $result[$id] = \imap_fetchheader($this->stream, $id, $uid ? IMAP::ST_UID : IMAP::NIL); + } + + return $result; + }); + } + + /** + * Fetch message flags + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function flags(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + foreach ($uids as $id) { + $response->addCommand("imap_fetch_overview"); + $raw_flags = \imap_fetch_overview($this->stream, $id, $uid ? IMAP::ST_UID : IMAP::NIL); + $flags = []; + if (is_array($raw_flags) && isset($raw_flags[0])) { + $raw_flags = (array)$raw_flags[0]; + foreach ($raw_flags as $flag => $value) { + if ($value === 1 && in_array($flag, ["size", "uid", "msgno", "update"]) === false) { + $flags[] = "\\" . ucfirst($flag); + } + } + } + $result[$id] = $flags; + } + + return $result; + }); + } + + /** + * Fetch message sizes + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function sizes(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + $uid_text = implode("','", $uids); + $response->addCommand("imap_fetch_overview"); + if ($uid == IMAP::ST_UID) { + $raw_overview = \imap_fetch_overview($this->stream, $uid_text, IMAP::FT_UID); + } else { + $raw_overview = \imap_fetch_overview($this->stream, $uid_text); + } + if ($raw_overview !== false) { + foreach ($raw_overview as $overview_element) { + $overview_element = (array)$overview_element; + $result[$overview_element[$uid == IMAP::ST_UID ? 'uid' : 'msgno']] = $overview_element['size']; + } + } + return $result; + }); + } + + /** + * Get uid for a given id + * @param int|null $id message number + * + * @return Response message number for given message or all messages as array + */ + public function getUid(int $id = null): Response { + return $this->response()->wrap(function($response) use ($id) { + /** @var Response $response */ + if ($id === null) { + if ($this->enable_uid_cache && $this->uid_cache) { + return $this->uid_cache; + } + + $overview = $this->overview("1:*"); + $response->stack($overview); + $uids = []; + foreach ($overview->data() as $set) { + $uids[$set->msgno] = $set->uid; + } + + $this->setUidCache($uids); + return $uids; + } + + $response->addCommand("imap_uid"); + $uid = \imap_uid($this->stream, $id); + if ($uid) { + return $uid; + } + + return []; + }); + } + + /** + * Get a message number for a uid + * @param string $id uid + * + * @return Response message number + */ + public function getMessageNumber(string $id): Response { + return $this->response("imap_msgno")->wrap(function($response) use ($id) { + /** @var Response $response */ + return \imap_msgno($this->stream, $id); + }); + } + + /** + * Get a message overview + * @param string $sequence uid sequence + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function overview(string $sequence, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_fetch_overview")->wrap(function($response) use ($sequence, $uid) { + /** @var Response $response */ + return \imap_fetch_overview($this->stream, $sequence, $uid ? IMAP::ST_UID : IMAP::NIL) ?: []; + }); + } + + /** + * Get a list of available folders + * @param string $reference mailbox reference for list + * @param string $folder mailbox name match with wildcards + * + * @return Response folders that matched $folder as array(name => array('delimiter' => .., 'flags' => ..)) + */ + public function folders(string $reference = '', string $folder = '*'): Response { + return $this->response("imap_getmailboxes")->wrap(function($response) use ($reference, $folder) { + /** @var Response $response */ + $result = []; + + $items = \imap_getmailboxes($this->stream, $this->getAddress(), $reference . $folder); + if (is_array($items)) { + foreach ($items as $item) { + $name = $this->decodeFolderName($item->name); + $result[$name] = ['delimiter' => $item->delimiter, 'flags' => []]; + } + } else { + throw new RuntimeException(\imap_last_error()); + } + + return $result; + }); + } + + /** + * Manage flags + * @param array|string $flags flags to set, add or remove - see $mode + * @param int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given + * @param bool $silent if false the return values are the new flags for the wanted messages + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * @param string|null $item unused attribute + * + * @return Response new flags if $silent is false, else true or false depending on success + */ + public function store(array|string $flags, int $from, int $to = null, string $mode = null, bool $silent = true, int|string $uid = IMAP::ST_UID, string $item = null): Response { + $flag = trim(is_array($flags) ? implode(" ", $flags) : $flags); + + return $this->response()->wrap(function($response) use ($mode, $from, $flag, $uid, $silent) { + /** @var Response $response */ + + if ($mode == "+") { + $response->addCommand("imap_setflag_full"); + $status = \imap_setflag_full($this->stream, $from, $flag, $uid ? IMAP::ST_UID : IMAP::NIL); + } else { + $response->addCommand("imap_clearflag_full"); + $status = \imap_clearflag_full($this->stream, $from, $flag, $uid ? IMAP::ST_UID : IMAP::NIL); + } + + if ($silent === true) { + if ($status) { + return [ + "TAG" . $response->Noun() . " OK Store completed (0.001 + 0.000 secs).\r\n" + ]; + } + return []; + } + + return $this->flags($from); + }); + } + + /** + * Append a new message to given folder + * @param string $folder name of target folder + * @param string $message full message content + * @param array|null $flags flags for new message + * @param mixed $date date for new message + * + * @return Response + */ + public function appendMessage(string $folder, string $message, array $flags = null, mixed $date = null): Response { + return $this->response("imap_append")->wrap(function($response) use ($folder, $message, $flags, $date) { + /** @var Response $response */ + if ($date != null) { + if ($date instanceof \Carbon\Carbon) { + $date = $date->format('d-M-Y H:i:s O'); + } + if (\imap_append($this->stream, $this->getAddress() . $folder, $message, $flags, $date)) { + return [ + "OK Append completed (0.001 + 0.000 secs).\r\n" + ]; + } + } else if (\imap_append($this->stream, $this->getAddress() . $folder, $message, $flags)) { + return [ + "OK Append completed (0.001 + 0.000 secs).\r\n" + ]; + } + return []; + }); + } + + /** + * Copy message set from current folder to other folder + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function copyMessage(string $folder, $from, int $to = null, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_mail_copy")->wrap(function($response) use ($from, $folder, $uid) { + /** @var Response $response */ + + if (\imap_mail_copy($this->stream, $from, $this->getAddress() . $folder, $uid ? IMAP::ST_UID : IMAP::NIL)) { + return [ + "TAG" . $response->Noun() . " OK Copy completed (0.001 + 0.000 secs).\r\n" + ]; + } + throw new ImapBadRequestException("Invalid ID $from"); + }); + } + + /** + * Copy multiple messages to the target folder + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response Tokens if operation successful, false if an error occurred + */ + public function copyManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($messages, $folder, $uid) { + /** @var Response $response */ + foreach ($messages as $msg) { + $copy_response = $this->copyMessage($folder, $msg, null, $uid); + $response->stack($copy_response); + if (empty($copy_response->data())) { + return [ + "TAG" . $response->Noun() . " BAD Copy failed (0.001 + 0.000 secs).\r\n", + "Invalid ID $msg\r\n" + ]; + } + } + return [ + "TAG" . $response->Noun() . " OK Copy completed (0.001 + 0.000 secs).\r\n" + ]; + }); + } + + /** + * Move a message set from current folder to another folder + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response success + */ + public function moveMessage(string $folder, $from, int $to = null, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_mail_move")->wrap(function($response) use ($from, $folder, $uid) { + if (\imap_mail_move($this->stream, $from, $this->getAddress() . $folder, $uid ? IMAP::ST_UID : IMAP::NIL)) { + return [ + "TAG" . $response->Noun() . " OK Move completed (0.001 + 0.000 secs).\r\n" + ]; + } + throw new ImapBadRequestException("Invalid ID $from"); + }); + } + + /** + * Move multiple messages to the target folder + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response Tokens if operation successful, false if an error occurred + * @throws ImapBadRequestException + */ + public function moveManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($messages, $folder, $uid) { + foreach ($messages as $msg) { + $move_response = $this->moveMessage($folder, $msg, null, $uid); + $response = $response->include($response); + if (empty($move_response->data())) { + return [ + "TAG" . $response->Noun() . " BAD Move failed (0.001 + 0.000 secs).\r\n", + "Invalid ID $msg\r\n" + ]; + } + } + return [ + "TAG" . $response->Noun() . " OK Move completed (0.001 + 0.000 secs).\r\n" + ]; + }); + } + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param null $ids + * @return Response + * + * @throws MethodNotSupportedException + */ + public function ID($ids = null): Response { + throw new MethodNotSupportedException(); + } + + /** + * Create a new folder (and parent folders if needed) + * @param string $folder folder name + * + * @return Response + */ + public function createFolder(string $folder): Response { + return $this->response("imap_createmailbox")->wrap(function($response) use ($folder) { + return \imap_createmailbox($this->stream, $this->getAddress() . $folder) ? [ + 0 => "TAG" . $response->Noun() . " OK Create completed (0.004 + 0.000 + 0.003 secs).\r\n", + ] : []; + }); + } + + /** + * Rename an existing folder + * @param string $old old name + * @param string $new new name + * + * @return Response + */ + public function renameFolder(string $old, string $new): Response { + return $this->response("imap_renamemailbox")->wrap(function($response) use ($old, $new) { + return \imap_renamemailbox($this->stream, $this->getAddress() . $old, $this->getAddress() . $new) ? [ + 0 => "TAG" . $response->Noun() . " OK Move completed (0.004 + 0.000 + 0.003 secs).\r\n", + ] : []; + }); + } + + /** + * Delete a folder + * @param string $folder folder name + * + * @return Response + */ + public function deleteFolder(string $folder): Response { + return $this->response("imap_deletemailbox")->wrap(function($response) use ($folder) { + return \imap_deletemailbox($this->stream, $this->getAddress() . $folder) ? [ + 0 => "OK Delete completed (0.004 + 0.000 + 0.003 secs).\r\n", + ] : []; + }); + } + + /** + * Subscribe to a folder + * @param string $folder folder name + * + * @throws MethodNotSupportedException + */ + public function subscribeFolder(string $folder): Response { + throw new MethodNotSupportedException(); + } + + /** + * Unsubscribe from a folder + * @param string $folder folder name + * + * @throws MethodNotSupportedException + */ + public function unsubscribeFolder(string $folder): Response { + throw new MethodNotSupportedException(); + } + + /** + * Apply session saved changes to the server + * + * @return Response + */ + public function expunge(): Response { + return $this->response("imap_expunge")->wrap(function($response) { + return \imap_expunge($this->stream) ? [ + 0 => "TAG" . $response->Noun() . " OK Expunge completed (0.001 + 0.000 secs).\r\n", + ] : []; + }); + } + + /** + * Send noop command + * + * @throws MethodNotSupportedException + */ + public function noop(): Response { + throw new MethodNotSupportedException(); + } + + /** + * Send idle command + * + * @throws MethodNotSupportedException + */ + public function idle() { + throw new MethodNotSupportedException(); + } + + /** + * Send done command + * + * @throws MethodNotSupportedException + */ + public function done() { + throw new MethodNotSupportedException(); + } + + /** + * Search for matching messages + * @param array $params + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response message ids + */ + public function search(array $params, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_search")->wrap(function($response) use ($params, $uid) { + $response->setCanBeEmpty(true); + $result = \imap_search($this->stream, $params[0], $uid ? IMAP::ST_UID : IMAP::NIL); + return $result ?: []; + }); + } + + /** + * Enable the debug mode + */ + public function enableDebug() { + $this->debug = true; + } + + /** + * Disable the debug mode + */ + public function disableDebug() { + $this->debug = false; + } + + /** + * Decode name. + * It converts UTF7-IMAP encoding to UTF-8. + * + * @param $name + * + * @return array|false|string|string[]|null + */ + protected function decodeFolderName($name): array|bool|string|null { + preg_match('#\{(.*)}(.*)#', $name, $preg); + return mb_convert_encoding($preg[2], "UTF-8", "UTF7-IMAP"); + } + + /** + * @return string + */ + public function getProtocol(): string { + return $this->protocol; + } + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * @param $username + * + * @return Response + */ + public function getQuota($username): Response { + return $this->response("imap_get_quota")->wrap(function($response) use ($username) { + $result = \imap_get_quota($this->stream, 'user.' . $username); + return $result ?: []; + }); + } + + /** + * Retrieve the quota settings per user + * @param string $quota_root + * + * @return Response + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): Response { + return $this->response("imap_get_quotaroot")->wrap(function($response) use ($quota_root) { + $result = \imap_get_quotaroot($this->stream, $this->getAddress() . $quota_root); + return $result ?: []; + }); + } + + /** + * @param string $protocol + * @return LegacyProtocol + */ + public function setProtocol(string $protocol): LegacyProtocol { + if (($pos = strpos($protocol, "legacy")) > 0) { + $protocol = substr($protocol, 0, ($pos + 2) * -1); + } + $this->protocol = $protocol; + return $this; + } + + /** + * Create a new Response instance + * @param string|null $command + * + * @return Response + */ + protected function response(?string $command = ""): Response { + return Response::make(0, $command == "" ? [] : [$command], [], $this->debug); + } +} diff --git a/plugins/php-imap/Connection/Protocols/Protocol.php b/plugins/php-imap/Connection/Protocols/Protocol.php new file mode 100644 index 00000000..6fe88ee9 --- /dev/null +++ b/plugins/php-imap/Connection/Protocols/Protocol.php @@ -0,0 +1,366 @@ + null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ]; + + /** + * Cache for uid of active folder. + * + * @var array + */ + protected array $uid_cache = []; + + /** + * Get an available cryptographic method + * + * @return int + */ + public function getCryptoMethod(): int { + // Allow the best TLS version(s) we can + $cryptoMethod = STREAM_CRYPTO_METHOD_TLS_CLIENT; + + // PHP 5.6.7 dropped inclusion of TLS 1.1 and 1.2 in STREAM_CRYPTO_METHOD_TLS_CLIENT + // so add them back in manually if we can + if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { + $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + }elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT')) { + $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; + } + + return $cryptoMethod; + } + + /** + * Enable SSL certificate validation + * + * @return Protocol + */ + public function enableCertValidation(): Protocol { + $this->cert_validation = true; + return $this; + } + + /** + * Disable SSL certificate validation + * @return Protocol + */ + public function disableCertValidation(): Protocol { + $this->cert_validation = false; + return $this; + } + + /** + * Set SSL certificate validation + * @var int $cert_validation + * + * @return Protocol + */ + public function setCertValidation(int $cert_validation): Protocol { + $this->cert_validation = $cert_validation; + return $this; + } + + /** + * Should we validate SSL certificate? + * + * @return bool + */ + public function getCertValidation(): bool { + return $this->cert_validation; + } + + /** + * Set connection proxy settings + * @var array $options + * + * @return Protocol + */ + public function setProxy(array $options): Protocol { + foreach ($this->proxy as $key => $val) { + if (isset($options[$key])) { + $this->proxy[$key] = $options[$key]; + } + } + + return $this; + } + + /** + * Get the current proxy settings + * + * @return array + */ + public function getProxy(): array { + return $this->proxy; + } + + /** + * Prepare socket options + * @return array + *@var string $transport + * + */ + private function defaultSocketOptions(string $transport): array { + $options = []; + if ($this->encryption) { + $options["ssl"] = [ + 'verify_peer_name' => $this->getCertValidation(), + 'verify_peer' => $this->getCertValidation(), + ]; + } + + if ($this->proxy["socket"] != null) { + $options[$transport]["proxy"] = $this->proxy["socket"]; + $options[$transport]["request_fulluri"] = $this->proxy["request_fulluri"]; + + if ($this->proxy["username"] != null) { + $auth = base64_encode($this->proxy["username"].':'.$this->proxy["password"]); + + $options[$transport]["header"] = [ + "Proxy-Authorization: Basic $auth" + ]; + } + } + + return $options; + } + + /** + * Create a new resource stream + * @param $transport + * @param string $host hostname or IP address of IMAP server + * @param int $port of IMAP server, default is 143 (993 for ssl) + * @param int $timeout timeout in seconds for initiating session + * + * @return resource The socket created. + * @throws ConnectionFailedException + */ + public function createStream($transport, string $host, int $port, int $timeout) { + $socket = "$transport://$host:$port"; + $stream = stream_socket_client($socket, $errno, $errstr, $timeout, + STREAM_CLIENT_CONNECT, + stream_context_create($this->defaultSocketOptions($transport)) + ); + + if (!$stream) { + throw new ConnectionFailedException($errstr, $errno); + } + + if (false === stream_set_timeout($stream, $timeout)) { + throw new ConnectionFailedException('Failed to set stream timeout'); + } + + return $stream; + } + + /** + * Get the current connection timeout + * + * @return int + */ + public function getConnectionTimeout(): int { + return $this->connection_timeout; + } + + /** + * Set the connection timeout + * @param int $connection_timeout + * + * @return Protocol + */ + public function setConnectionTimeout(int $connection_timeout): Protocol { + $this->connection_timeout = $connection_timeout; + return $this; + } + + /** + * Get the UID key string + * @param int|string $uid + * + * @return string + */ + public function getUIDKey(int|string $uid): string { + if ($uid == IMAP::ST_UID || $uid == IMAP::FT_UID) { + return "UID"; + } + if (strlen($uid) > 0 && !is_numeric($uid)) { + return (string)$uid; + } + + return ""; + } + + /** + * Build a UID / MSGN command + * @param string $command + * @param int|string $uid + * + * @return string + */ + public function buildUIDCommand(string $command, int|string $uid): string { + return trim($this->getUIDKey($uid)." ".$command); + } + + /** + * Set the uid cache of current active folder + * + * @param array|null $uids + */ + public function setUidCache(?array $uids) { + if (is_null($uids)) { + $this->uid_cache = []; + return; + } + + $messageNumber = 1; + + $uid_cache = []; + foreach ($uids as $uid) { + $uid_cache[$messageNumber++] = (int)$uid; + } + + $this->uid_cache = $uid_cache; + } + + /** + * Enable the uid cache + * + * @return void + */ + public function enableUidCache(): void { + $this->enable_uid_cache = true; + } + + /** + * Disable the uid cache + * + * @return void + */ + public function disableUidCache(): void { + $this->enable_uid_cache = false; + } + + /** + * Set the encryption method + * @param string $encryption + * + * @return void + */ + public function setEncryption(string $encryption): void { + $this->encryption = $encryption; + } + + /** + * Get the encryption method + * @return string + */ + public function getEncryption(): string { + return $this->encryption; + } + + /** + * Check if the current session is connected + * + * @return bool + */ + public function connected(): bool { + return (bool)$this->stream; + } + + /** + * Retrieves header/meta data from the resource stream + * + * @return array + */ + public function meta(): array { + if (!$this->stream) { + return [ + "crypto" => [ + "protocol" => "", + "cipher_name" => "", + "cipher_bits" => 0, + "cipher_version" => "", + ], + "timed_out" => true, + "blocked" => true, + "eof" => true, + "stream_type" => "tcp_socket/unknown", + "mode" => "c", + "unread_bytes" => 0, + "seekable" => false, + ]; + } + return stream_get_meta_data($this->stream); + } + + /** + * Get the resource stream + * + * @return mixed + */ + public function getStream(): mixed { + return $this->stream; + } +} diff --git a/plugins/php-imap/Connection/Protocols/ProtocolInterface.php b/plugins/php-imap/Connection/Protocols/ProtocolInterface.php new file mode 100644 index 00000000..c02d7800 --- /dev/null +++ b/plugins/php-imap/Connection/Protocols/ProtocolInterface.php @@ -0,0 +1,447 @@ + array('delim' => .., 'flags' => ..)) + * @throws RuntimeException + */ + public function folders(string $reference = '', string $folder = '*'): Response; + + /** + * Set message flags + * @param array|string $flags flags to set, add or remove + * @param int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given + * @param bool $silent if false the return values are the new flags for the wanted messages + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * @param string|null $item command used to store a flag + * + * @return Response containing the new flags if $silent is false, else true or false depending on success + * @throws RuntimeException + */ + public function store(array|string $flags, int $from, ?int $to = null, ?string $mode = null, bool $silent = true, int|string $uid = IMAP::ST_UID, ?string $item = null): Response; + + /** + * Append a new message to given folder + * @param string $folder name of target folder + * @param string $message full message content + * @param array|null $flags flags for new message + * @param string|null $date date for new message + * + * @return Response + * @throws RuntimeException + */ + public function appendMessage(string $folder, string $message, ?array $flags = null, ?string $date = null): Response; + + /** + * Copy message set from current folder to other folder + * + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function copyMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response; + + /** + * Copy multiple messages to the target folder + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response Tokens if operation successful, false if an error occurred + * @throws RuntimeException + */ + public function copyManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response; + + /** + * Move a message set from current folder to another folder + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + */ + public function moveMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response; + + /** + * Move multiple messages to the target folder + * + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response Tokens if operation successful, false if an error occurred + * @throws RuntimeException + */ + public function moveManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response; + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param null $ids + * @return Response + * + * @throws RuntimeException + */ + public function ID($ids = null): Response; + + /** + * Create a new folder + * + * @param string $folder folder name + * @return Response + * @throws RuntimeException + */ + public function createFolder(string $folder): Response; + + /** + * Rename an existing folder + * + * @param string $old old name + * @param string $new new name + * @return Response + * @throws RuntimeException + */ + public function renameFolder(string $old, string $new): Response; + + /** + * Delete a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function deleteFolder(string $folder): Response; + + /** + * Subscribe to a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function subscribeFolder(string $folder): Response; + + /** + * Unsubscribe from a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function unsubscribeFolder(string $folder): Response; + + /** + * Send idle command + * + * @throws RuntimeException + */ + public function idle(); + + /** + * Send done command + * @throws RuntimeException + */ + public function done(); + + /** + * Apply session saved changes to the server + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function expunge(): Response; + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * @param $username + * + * @return Response + * @throws RuntimeException + */ + public function getQuota($username): Response; + + /** + * Retrieve the quota settings per user + * + * @param string $quota_root + * + * @return Response + * @throws ConnectionFailedException + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): Response; + + /** + * Send noop command + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function noop(): Response; + + /** + * Do a search request + * + * @param array $params + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response containing the message ids + * @throws RuntimeException + */ + public function search(array $params, int|string $uid = IMAP::ST_UID): Response; + + /** + * Get a message overview + * @param string $sequence uid sequence + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + * @throws MessageNotFoundException + * @throws InvalidMessageDateException + */ + public function overview(string $sequence, int|string $uid = IMAP::ST_UID): Response; + + /** + * Enable the debug mode + */ + public function enableDebug(); + + /** + * Disable the debug mode + */ + public function disableDebug(); + + /** + * Enable uid caching + */ + public function enableUidCache(); + + /** + * Disable uid caching + */ + public function disableUidCache(); + + /** + * Set the uid cache of current active folder + * + * @param array|null $uids + */ + public function setUidCache(?array $uids); +} diff --git a/plugins/php-imap/Connection/Protocols/Response.php b/plugins/php-imap/Connection/Protocols/Response.php new file mode 100644 index 00000000..9a30d56d --- /dev/null +++ b/plugins/php-imap/Connection/Protocols/Response.php @@ -0,0 +1,417 @@ +debug = $debug; + $this->noun = $noun > 0 ? $noun : (int)str_replace(".", "", (string)microtime(true)); + } + + /** + * Make a new response instance + * @param int $noun + * @param array $commands + * @param array $responses + * @param bool $debug + * + * @return Response + */ + public static function make(int $noun, array $commands = [], array $responses = [], bool $debug = false): Response { + return (new self($noun, $debug))->setCommands($commands)->setResponse($responses); + } + + /** + * Create a new empty response + * @param bool $debug + * + * @return Response + */ + public static function empty(bool $debug = false): Response { + return (new self(0, $debug)); + } + + /** + * Stack another response + * @param Response $response + * + * @return void + */ + public function stack(Response $response): void { + $this->response_stack[] = $response; + } + + /** + * Get the associated response stack + * + * @return array + */ + public function getStack(): array { + return $this->response_stack; + } + + /** + * Get all assigned commands + * + * @return array + */ + public function getCommands(): array { + return $this->commands; + } + + /** + * Add a new command + * @param string $command + * + * @return Response + */ + public function addCommand(string $command): Response { + $this->commands[] = $command; + return $this; + } + + /** + * Set and overwrite all commands + * @param array $commands + * + * @return Response + */ + public function setCommands(array $commands): Response { + $this->commands = $commands; + return $this; + } + + /** + * Get all set errors + * + * @return array + */ + public function getErrors(): array { + $errors = $this->errors; + foreach($this->getStack() as $response) { + $errors = array_merge($errors, $response->getErrors()); + } + return $errors; + } + + /** + * Set and overwrite all existing errors + * @param array $errors + * + * @return Response + */ + public function setErrors(array $errors): Response { + $this->errors = $errors; + return $this; + } + + /** + * Set the response + * @param string $error + * + * @return Response + */ + public function addError(string $error): Response { + $this->errors[] = $error; + return $this; + } + + /** + * Set the response + * @param array $response + * + * @return Response + */ + public function addResponse(mixed $response): Response { + $this->response[] = $response; + return $this; + } + + /** + * Set the response + * @param array $response + * + * @return Response + */ + public function setResponse(array $response): Response { + $this->response = $response; + return $this; + } + + /** + * Get the assigned response + * + * @return array + */ + public function getResponse(): array { + return $this->response; + } + + /** + * Set the result data + * @param mixed $result + * + * @return Response + */ + public function setResult(mixed $result): Response { + $this->result = $result; + return $this; + } + + /** + * Wrap a result bearing action + * @param callable $callback + * + * @return Response + */ + public function wrap(callable $callback): Response { + $this->result = call_user_func($callback, $this); + return $this; + } + + /** + * Get the response data + * + * @return mixed + */ + public function data(): mixed { + if ($this->result !== null) { + return $this->result; + } + return $this->getResponse(); + } + + /** + * Get the response data as array + * + * @return array + */ + public function array(): array { + $data = $this->data(); + if(is_array($data)){ + return $data; + } + return [$data]; + } + + /** + * Get the response data as string + * + * @return string + */ + public function string(): string { + $data = $this->data(); + if(is_array($data)){ + return implode(" ", $data); + } + return (string)$data; + } + + /** + * Get the response data as integer + * + * @return int + */ + public function integer(): int { + $data = $this->data(); + if(is_array($data) && isset($data[0])){ + return (int)$data[0]; + } + return (int)$data; + } + + /** + * Get the response data as boolean + * + * @return bool + */ + public function boolean(): bool { + return (bool)$this->data(); + } + + /** + * Validate and retrieve the response data + * + * @throws ResponseException + */ + public function validatedData(): mixed { + return $this->validate()->data(); + } + + /** + * Validate the response date + * + * @throws ResponseException + */ + public function validate(): Response { + if ($this->failed()) { + throw ResponseException::make($this, $this->debug); + } + return $this; + } + + /** + * Check if the Response can be considered successful + * + * @return bool + */ + public function successful(): bool { + foreach(array_merge($this->getResponse(), $this->array()) as $data) { + if (!$this->verify_data($data)) { + return false; + } + } + foreach($this->getStack() as $response) { + if (!$response->successful()) { + return false; + } + } + return ($this->boolean() || $this->canBeEmpty()) && !$this->getErrors(); + } + + + /** + * Check if the Response can be considered failed + * @param mixed $data + * + * @return bool + */ + public function verify_data(mixed $data): bool { + if (is_array($data)) { + foreach ($data as $line) { + if (is_array($line)) { + if(!$this->verify_data($line)){ + return false; + } + }else{ + if (!$this->verify_line((string)$line)) { + return false; + } + } + } + }else{ + if (!$this->verify_line((string)$data)) { + return false; + } + } + return true; + } + + /** + * Verify a single line + * @param string $line + * + * @return bool + */ + public function verify_line(string $line): bool { + return !str_starts_with($line, "TAG".$this->noun." BAD ") && !str_starts_with($line, "TAG".$this->noun." NO "); + } + + /** + * Check if the Response can be considered failed + * + * @return bool + */ + public function failed(): bool { + return !$this->successful(); + } + + /** + * Get the Response noun + * + * @return int + */ + public function Noun(): int { + return $this->noun; + } + + /** + * Set the Response to be allowed to be empty + * @param bool $can_be_empty + * + * @return $this + */ + public function setCanBeEmpty(bool $can_be_empty): Response { + $this->can_be_empty = $can_be_empty; + return $this; + } + + /** + * Check if the Response can be empty + * + * @return bool + */ + public function canBeEmpty(): bool { + return $this->can_be_empty; + } +} \ No newline at end of file diff --git a/plugins/php-imap/EncodingAliases.php b/plugins/php-imap/EncodingAliases.php new file mode 100644 index 00000000..888fc7fa --- /dev/null +++ b/plugins/php-imap/EncodingAliases.php @@ -0,0 +1,591 @@ + "us-ascii", + "us-ascii" => "us-ascii", + "ansi_x3.4-1968" => "us-ascii", + "646" => "us-ascii", + "iso-8859-1" => "ISO-8859-1", + "iso-8859-2" => "ISO-8859-2", + "iso-8859-3" => "ISO-8859-3", + "iso-8859-4" => "ISO-8859-4", + "iso-8859-5" => "ISO-8859-5", + "iso-8859-6" => "ISO-8859-6", + "iso-8859-6-i" => "ISO-8859-6-I", + "iso-8859-6-e" => "ISO-8859-6-E", + "iso-8859-7" => "ISO-8859-7", + "iso-8859-8" => "ISO-8859-8", + "iso-8859-8-i" => "ISO-8859-8-I", + "iso-8859-8-e" => "ISO-8859-8-E", + "iso-8859-9" => "ISO-8859-9", + "iso-8859-10" => "ISO-8859-10", + "iso-8859-11" => "ISO-8859-11", + "iso-8859-13" => "ISO-8859-13", + "iso-8859-14" => "ISO-8859-14", + "iso-8859-15" => "ISO-8859-15", + "iso-8859-16" => "ISO-8859-16", + "iso-ir-111" => "ISO-IR-111", + "iso-2022-cn" => "ISO-2022-CN", + "iso-2022-cn-ext" => "ISO-2022-CN", + "iso-2022-kr" => "ISO-2022-KR", + "iso-2022-jp" => "ISO-2022-JP", + "utf-16be" => "UTF-16BE", + "utf-16le" => "UTF-16LE", + "utf-16" => "UTF-16", + "windows-1250" => "windows-1250", + "windows-1251" => "windows-1251", + "windows-1252" => "windows-1252", + "windows-1253" => "windows-1253", + "windows-1254" => "windows-1254", + "windows-1255" => "windows-1255", + "windows-1256" => "windows-1256", + "windows-1257" => "windows-1257", + "windows-1258" => "windows-1258", + "ibm866" => "IBM866", + "ibm850" => "IBM850", + "ibm852" => "IBM852", + "ibm855" => "IBM855", + "ibm857" => "IBM857", + "ibm862" => "IBM862", + "ibm864" => "IBM864", + "utf-8" => "UTF-8", + "utf-7" => "UTF-7", + "utf-7-imap" => "UTF7-IMAP", + "utf7-imap" => "UTF7-IMAP", + "shift_jis" => "Shift_JIS", + "big5" => "Big5", + "euc-jp" => "EUC-JP", + "euc-kr" => "EUC-KR", + "gb2312" => "GB2312", + "gb18030" => "gb18030", + "viscii" => "VISCII", + "koi8-r" => "KOI8-R", + "koi8_r" => "KOI8-R", + "cskoi8r" => "KOI8-R", + "koi" => "KOI8-R", + "koi8" => "KOI8-R", + "koi8-u" => "KOI8-U", + "tis-620" => "TIS-620", + "t.61-8bit" => "T.61-8bit", + "hz-gb-2312" => "HZ-GB-2312", + "big5-hkscs" => "Big5-HKSCS", + "gbk" => "gbk", + "cns11643" => "x-euc-tw", + // + // Aliases for ISO-8859-1 + // + "latin1" => "ISO-8859-1", + "iso_8859-1" => "ISO-8859-1", + "iso8859-1" => "ISO-8859-1", + "iso8859-2" => "ISO-8859-2", + "iso8859-3" => "ISO-8859-3", + "iso8859-4" => "ISO-8859-4", + "iso8859-5" => "ISO-8859-5", + "iso8859-6" => "ISO-8859-6", + "iso8859-7" => "ISO-8859-7", + "iso8859-8" => "ISO-8859-8", + "iso8859-9" => "ISO-8859-9", + "iso8859-10" => "ISO-8859-10", + "iso8859-11" => "ISO-8859-11", + "iso8859-13" => "ISO-8859-13", + "iso8859-14" => "ISO-8859-14", + "iso8859-15" => "ISO-8859-15", + "iso_8859-1:1987" => "ISO-8859-1", + "iso-ir-100" => "ISO-8859-1", + "l1" => "ISO-8859-1", + "ibm819" => "ISO-8859-1", + "cp819" => "ISO-8859-1", + "csisolatin1" => "ISO-8859-1", + // + // Aliases for ISO-8859-2 + // + "latin2" => "ISO-8859-2", + "iso_8859-2" => "ISO-8859-2", + "iso_8859-2:1987" => "ISO-8859-2", + "iso-ir-101" => "ISO-8859-2", + "l2" => "ISO-8859-2", + "csisolatin2" => "ISO-8859-2", + // + // Aliases for ISO-8859-3 + // + "latin3" => "ISO-8859-3", + "iso_8859-3" => "ISO-8859-3", + "iso_8859-3:1988" => "ISO-8859-3", + "iso-ir-109" => "ISO-8859-3", + "l3" => "ISO-8859-3", + "csisolatin3" => "ISO-8859-3", + // + // Aliases for ISO-8859-4 + // + "latin4" => "ISO-8859-4", + "iso_8859-4" => "ISO-8859-4", + "iso_8859-4:1988" => "ISO-8859-4", + "iso-ir-110" => "ISO-8859-4", + "l4" => "ISO-8859-4", + "csisolatin4" => "ISO-8859-4", + // + // Aliases for ISO-8859-5 + // + "cyrillic" => "ISO-8859-5", + "iso_8859-5" => "ISO-8859-5", + "iso_8859-5:1988" => "ISO-8859-5", + "iso-ir-144" => "ISO-8859-5", + "csisolatincyrillic" => "ISO-8859-5", + // + // Aliases for ISO-8859-6 + // + "arabic" => "ISO-8859-6", + "iso_8859-6" => "ISO-8859-6", + "iso_8859-6:1987" => "ISO-8859-6", + "iso-ir-127" => "ISO-8859-6", + "ecma-114" => "ISO-8859-6", + "asmo-708" => "ISO-8859-6", + "csisolatinarabic" => "ISO-8859-6", + // + // Aliases for ISO-8859-6-I + // + "csiso88596i" => "ISO-8859-6-I", + // + // Aliases for ISO-8859-6-E", + // + "csiso88596e" => "ISO-8859-6-E", + // + // Aliases for ISO-8859-7", + // + "greek" => "ISO-8859-7", + "greek8" => "ISO-8859-7", + "sun_eu_greek" => "ISO-8859-7", + "iso_8859-7" => "ISO-8859-7", + "iso_8859-7:1987" => "ISO-8859-7", + "iso-ir-126" => "ISO-8859-7", + "elot_928" => "ISO-8859-7", + "ecma-118" => "ISO-8859-7", + "csisolatingreek" => "ISO-8859-7", + // + // Aliases for ISO-8859-8", + // + "hebrew" => "ISO-8859-8", + "iso_8859-8" => "ISO-8859-8", + "visual" => "ISO-8859-8", + "iso_8859-8:1988" => "ISO-8859-8", + "iso-ir-138" => "ISO-8859-8", + "csisolatinhebrew" => "ISO-8859-8", + // + // Aliases for ISO-8859-8-I", + // + "csiso88598i" => "ISO-8859-8-I", + "iso-8859-8i" => "ISO-8859-8-I", + "logical" => "ISO-8859-8-I", + // + // Aliases for ISO-8859-8-E", + // + "csiso88598e" => "ISO-8859-8-E", + // + // Aliases for ISO-8859-9", + // + "latin5" => "ISO-8859-9", + "iso_8859-9" => "ISO-8859-9", + "iso_8859-9:1989" => "ISO-8859-9", + "iso-ir-148" => "ISO-8859-9", + "l5" => "ISO-8859-9", + "csisolatin5" => "ISO-8859-9", + // + // Aliases for UTF-8", + // + "unicode-1-1-utf-8" => "UTF-8", + // nl_langinfo(CODESET) in HP/UX returns 'utf8' under UTF-8 locales", + "utf8" => "UTF-8", + // + // Aliases for Shift_JIS", + // + "x-sjis" => "Shift_JIS", + "shift-jis" => "Shift_JIS", + "ms_kanji" => "Shift_JIS", + "csshiftjis" => "Shift_JIS", + "windows-31j" => "Shift_JIS", + "cp932" => "Shift_JIS", + "sjis" => "Shift_JIS", + // + // Aliases for EUC_JP", + // + "cseucpkdfmtjapanese" => "EUC-JP", + "x-euc-jp" => "EUC-JP", + // + // Aliases for ISO-2022-JP", + // + "csiso2022jp" => "ISO-2022-JP", + // The following are really not aliases ISO-2022-JP, but sharing the same decoder", + "iso-2022-jp-2" => "ISO-2022-JP", + "csiso2022jp2" => "ISO-2022-JP", + // + // Aliases for Big5", + // + "csbig5" => "Big5", + "cn-big5" => "Big5", + // x-x-big5 is not really a alias for Big5, add it only for MS FrontPage", + "x-x-big5" => "Big5", + // Sun Solaris", + "zh_tw-big5" => "Big5", + // + // Aliases for EUC-KR", + // + "cseuckr" => "EUC-KR", + "ks_c_5601-1987" => "EUC-KR", + "iso-ir-149" => "EUC-KR", + "ks_c_5601-1989" => "EUC-KR", + "ksc_5601" => "EUC-KR", + "ksc5601" => "EUC-KR", + "korean" => "EUC-KR", + "csksc56011987" => "EUC-KR", + "5601" => "EUC-KR", + "windows-949" => "EUC-KR", + // + // Aliases for GB2312", + // + // The following are really not aliases GB2312, add them only for MS FrontPage", + "gb_2312-80" => "GB2312", + "iso-ir-58" => "GB2312", + "chinese" => "GB2312", + "csiso58gb231280" => "GB2312", + "csgb2312" => "GB2312", + "zh_cn.euc" => "GB2312", + // Sun Solaris", + "gb_2312" => "GB2312", + // + // Aliases for windows-125x ", + // + "x-cp1250" => "windows-1250", + "x-cp1251" => "windows-1251", + "x-cp1252" => "windows-1252", + "x-cp1253" => "windows-1253", + "x-cp1254" => "windows-1254", + "x-cp1255" => "windows-1255", + "x-cp1256" => "windows-1256", + "x-cp1257" => "windows-1257", + "x-cp1258" => "windows-1258", + // + // Aliases for windows-874 ", + // + "windows-874" => "windows-874", + "ibm874" => "windows-874", + "dos-874" => "windows-874", + // + // Aliases for macintosh", + // + "macintosh" => "macintosh", + "x-mac-roman" => "macintosh", + "mac" => "macintosh", + "csmacintosh" => "macintosh", + // + // Aliases for IBM866", + // + "cp866" => "IBM866", + "cp-866" => "IBM866", + "866" => "IBM866", + "csibm866" => "IBM866", + // + // Aliases for IBM850", + // + "cp850" => "IBM850", + "850" => "IBM850", + "csibm850" => "IBM850", + // + // Aliases for IBM852", + // + "cp852" => "IBM852", + "852" => "IBM852", + "csibm852" => "IBM852", + // + // Aliases for IBM855", + // + "cp855" => "IBM855", + "855" => "IBM855", + "csibm855" => "IBM855", + // + // Aliases for IBM857", + // + "cp857" => "IBM857", + "857" => "IBM857", + "csibm857" => "IBM857", + // + // Aliases for IBM862", + // + "cp862" => "IBM862", + "862" => "IBM862", + "csibm862" => "IBM862", + // + // Aliases for IBM864", + // + "cp864" => "IBM864", + "864" => "IBM864", + "csibm864" => "IBM864", + "ibm-864" => "IBM864", + // + // Aliases for T.61-8bit", + // + "t.61" => "T.61-8bit", + "iso-ir-103" => "T.61-8bit", + "csiso103t618bit" => "T.61-8bit", + // + // Aliases for UTF-7", + // + "x-unicode-2-0-utf-7" => "UTF-7", + "unicode-2-0-utf-7" => "UTF-7", + "unicode-1-1-utf-7" => "UTF-7", + "csunicode11utf7" => "UTF-7", + // + // Aliases for ISO-10646-UCS-2", + // + "csunicode" => "UTF-16BE", + "csunicode11" => "UTF-16BE", + "iso-10646-ucs-basic" => "UTF-16BE", + "csunicodeascii" => "UTF-16BE", + "iso-10646-unicode-latin1" => "UTF-16BE", + "csunicodelatin1" => "UTF-16BE", + "iso-10646" => "UTF-16BE", + "iso-10646-j-1" => "UTF-16BE", + // + // Aliases for ISO-8859-10", + // + "latin6" => "ISO-8859-10", + "iso-ir-157" => "ISO-8859-10", + "l6" => "ISO-8859-10", + // Currently .properties cannot handle : in key", + //iso_8859-10:1992" => "ISO-8859-10", + "csisolatin6" => "ISO-8859-10", + // + // Aliases for ISO-8859-15", + // + "iso_8859-15" => "ISO-8859-15", + "csisolatin9" => "ISO-8859-15", + "l9" => "ISO-8859-15", + // + // Aliases for ISO-IR-111", + // + "ecma-cyrillic" => "ISO-IR-111", + "csiso111ecmacyrillic" => "ISO-IR-111", + // + // Aliases for ISO-2022-KR", + // + "csiso2022kr" => "ISO-2022-KR", + // + // Aliases for VISCII", + // + "csviscii" => "VISCII", + // + // Aliases for x-euc-tw", + // + "zh_tw-euc" => "x-euc-tw", + // + // Following names appears in unix nl_langinfo(CODESET)", + // They can be compiled as platform specific if necessary", + // DONT put things here if it does not look generic enough (like hp15CN)", + // + "iso88591" => "ISO-8859-1", + "iso88592" => "ISO-8859-2", + "iso88593" => "ISO-8859-3", + "iso88594" => "ISO-8859-4", + "iso88595" => "ISO-8859-5", + "iso88596" => "ISO-8859-6", + "iso88597" => "ISO-8859-7", + "iso88598" => "ISO-8859-8", + "iso88599" => "ISO-8859-9", + "iso885910" => "ISO-8859-10", + "iso885911" => "ISO-8859-11", + "iso885912" => "ISO-8859-12", + "iso885913" => "ISO-8859-13", + "iso885914" => "ISO-8859-14", + "iso885915" => "ISO-8859-15", + "cp1250" => "windows-1250", + "cp1251" => "windows-1251", + "cp1252" => "windows-1252", + "cp1253" => "windows-1253", + "cp1254" => "windows-1254", + "cp1255" => "windows-1255", + "cp1256" => "windows-1256", + "cp1257" => "windows-1257", + "cp1258" => "windows-1258", + "x-gbk" => "gbk", + "windows-936" => "gbk", + "ansi-1251" => "windows-1251", + ]; + + /** + * Returns proper encoding mapping, if exists. If it doesn't, return unchanged $encoding + * @param string|null $encoding + * @param string|null $fallback + * + * @return string + */ + public static function get(?string $encoding, string $fallback = null): string { + if (isset(self::$aliases[strtolower($encoding ?? '')])) { + return self::$aliases[strtolower($encoding ?? '')]; + } + return $fallback ?: $encoding; + } + + + /** + * Convert the encoding of a string + * @param $str + * @param string $from + * @param string $to + * + * @return mixed + */ + public static function convert($str, string $from = "ISO-8859-2", string $to = "UTF-8"): mixed { + $from = self::get($from, self::detectEncoding($str)); + $to = self::get($to, self::detectEncoding($str)); + + if ($from === $to) { + return $str; + } + + // We don't need to do convertEncoding() if charset is ASCII (us-ascii): + // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded + // https://stackoverflow.com/a/11303410 + // + // us-ascii is the same as ASCII: + // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) + // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and + // based on the typographical symbols predominantly in use there. + // https://en.wikipedia.org/wiki/ASCII + // + // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. + if (strtolower($from) == 'us-ascii' && $to == 'UTF-8') { + return $str; + } + + try { + if (function_exists('iconv') && !self::isUtf7($from) && !self::isUtf7($to)) { + return iconv($from, $to, $str); + } + if (!$from) { + return mb_convert_encoding($str, $to); + } + return mb_convert_encoding($str, $to, $from); + } catch (\Exception $e) { + if (str_contains($from, '-')) { + $from = str_replace('-', '', $from); + return self::convert($str, $from, $to); + } + return $str; + } + } + + /** + * Attempts to detect the encoding of a string + * @param string $string + * + * @return string + */ + public static function detectEncoding(string $string): string { + $encoding = mb_detect_encoding($string, array_filter(self::getEncodings(), function($value){ + return !in_array($value, [ + 'ISO-8859-6-I', 'ISO-8859-6-E', 'ISO-8859-8-I', 'ISO-8859-8-E', + 'ISO-8859-11', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16', 'ISO-IR-111',"ISO-2022-CN", + "windows-1250", "windows-1253", "windows-1255", "windows-1256", "windows-1257", "windows-1258", + "IBM852", "IBM855", "IBM857", "IBM866", "IBM864", "IBM862", "KOI8-R", "KOI8-U", + "TIS-620", "ISO-8859-1", "ISO-8859-2", "ISO-8859-3", "ISO-8859-4", + "VISCII", "T.61-8bit", "Big5-HKSCS", "windows-874", "macintosh", "ISO-8859-12", "ISO-8859-7", + "IMAP-UTF-7" + ]); + }), true); + if ($encoding === false) { + $encoding = 'UTF-8'; + } + return $encoding; + } + + /** + * Returns all available encodings + * + * @return array + */ + public static function getEncodings(): array { + $encodings = []; + foreach (self::$aliases as $encoding) { + if (!in_array($encoding, $encodings)) { + $encodings[] = $encoding; + } + } + return $encodings; + } + + /** + * Returns true if the encoding is UTF-7 like + * @param string $encoding + * + * @return bool + */ + public static function isUtf7(string $encoding): bool { + return str_contains(str_replace("-", "", strtolower($encoding)), "utf7"); + } + + /** + * Check if an encoding is supported + * @param string $encoding + * + * @return bool + */ + public static function has(string $encoding): bool { + return isset(self::$aliases[strtolower($encoding)]); + } +} diff --git a/plugins/php-imap/Events/Event.php b/plugins/php-imap/Events/Event.php new file mode 100644 index 00000000..f9e3e8f6 --- /dev/null +++ b/plugins/php-imap/Events/Event.php @@ -0,0 +1,28 @@ +message = $arguments[0]; + $this->flag = $arguments[1]; + } +} diff --git a/plugins/php-imap/Events/FolderDeletedEvent.php b/plugins/php-imap/Events/FolderDeletedEvent.php new file mode 100644 index 00000000..89b5083f --- /dev/null +++ b/plugins/php-imap/Events/FolderDeletedEvent.php @@ -0,0 +1,22 @@ +old_folder = $folders[0]; + $this->new_folder = $folders[1]; + } +} diff --git a/plugins/php-imap/Events/FolderNewEvent.php b/plugins/php-imap/Events/FolderNewEvent.php new file mode 100644 index 00000000..0c576cad --- /dev/null +++ b/plugins/php-imap/Events/FolderNewEvent.php @@ -0,0 +1,36 @@ +folder = $folders[0]; + } +} diff --git a/plugins/php-imap/Events/MessageCopiedEvent.php b/plugins/php-imap/Events/MessageCopiedEvent.php new file mode 100644 index 00000000..a6a3a447 --- /dev/null +++ b/plugins/php-imap/Events/MessageCopiedEvent.php @@ -0,0 +1,22 @@ +old_message = $messages[0]; + $this->new_message = $messages[1]; + } +} diff --git a/plugins/php-imap/Events/MessageNewEvent.php b/plugins/php-imap/Events/MessageNewEvent.php new file mode 100644 index 00000000..38892c49 --- /dev/null +++ b/plugins/php-imap/Events/MessageNewEvent.php @@ -0,0 +1,36 @@ +message = $messages[0]; + } +} diff --git a/plugins/php-imap/Events/MessageRestoredEvent.php b/plugins/php-imap/Events/MessageRestoredEvent.php new file mode 100644 index 00000000..25b6520a --- /dev/null +++ b/plugins/php-imap/Events/MessageRestoredEvent.php @@ -0,0 +1,22 @@ +getErrors() as $error) { + $message .= "\t- $error\n"; + } + + if(!$response->data()) { + $message .= "\t- Empty response\n"; + } + + if ($debug) { + $message .= self::debug_message($response); + } + + foreach($response->getStack() as $_response) { + $exception = self::make($_response, $debug, $exception); + } + + return new self($message."Error occurred", 0, $exception); + } + + /** + * Generate a debug message containing all commands send and responses received + * @param Response $response + * + * @return string + */ + protected static function debug_message(Response $response): string { + $commands = $response->getCommands(); + $message = "Commands send:\n"; + if ($commands) { + foreach($commands as $command) { + $message .= "\t".str_replace("\r\n", "\\r\\n", $command)."\n"; + } + }else{ + $message .= "\tNo command send!\n"; + } + + $responses = $response->getResponse(); + $message .= "Responses received:\n"; + if ($responses) { + foreach($responses as $_response) { + if (is_array($_response)) { + foreach($_response as $value) { + $message .= "\t".str_replace("\r\n", "\\r\\n", "$value")."\n"; + } + }else{ + $message .= "\t".str_replace("\r\n", "\\r\\n", "$_response")."\n"; + } + } + }else{ + $message .= "\tNo responses received!\n"; + } + + return $message; + } +} diff --git a/plugins/php-imap/Exceptions/RuntimeException.php b/plugins/php-imap/Exceptions/RuntimeException.php new file mode 100644 index 00000000..926be1d1 --- /dev/null +++ b/plugins/php-imap/Exceptions/RuntimeException.php @@ -0,0 +1,24 @@ +client = $client; + + $this->events["message"] = $client->getDefaultEvents("message"); + $this->events["folder"] = $client->getDefaultEvents("folder"); + + $this->setDelimiter($delimiter); + $this->path = $folder_name; + $this->full_name = $this->decodeName($folder_name); + $this->name = $this->getSimpleName($this->delimiter, $this->full_name); + $this->children = new FolderCollection(); + $this->has_children = false; + + $this->parseAttributes($attributes); + } + + /** + * Get a new search query instance + * @param string[] $extensions + * + * @return WhereQuery + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ResponseException + */ + public function query(array $extensions = []): WhereQuery { + $this->getClient()->checkConnection(); + $this->getClient()->openFolder($this->path); + $extensions = count($extensions) > 0 ? $extensions : $this->getClient()->extensions; + + return new WhereQuery($this->getClient(), $extensions); + } + + /** + * Get a new search query instance + * @param string[] $extensions + * + * @return WhereQuery + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ResponseException + */ + public function search(array $extensions = []): WhereQuery { + return $this->query($extensions); + } + + /** + * Get a new search query instance + * @param string[] $extensions + * + * @return WhereQuery + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ResponseException + */ + public function messages(array $extensions = []): WhereQuery { + return $this->query($extensions); + } + + /** + * Determine if folder has children. + * + * @return bool + */ + public function hasChildren(): bool { + return $this->has_children; + } + + /** + * Set children. + * @param FolderCollection $children + * + * @return Folder + */ + public function setChildren(FolderCollection $children): Folder { + $this->children = $children; + + return $this; + } + + /** + * Get children. + * + * @return FolderCollection + */ + public function getChildren(): FolderCollection { + return $this->children; + } + + /** + * Decode name. + * It converts UTF7-IMAP encoding to UTF-8. + * @param $name + * + * @return string|array|bool|string[]|null + */ + protected function decodeName($name): string|array|bool|null { + $parts = []; + foreach (explode($this->delimiter, $name) as $item) { + $parts[] = EncodingAliases::convert($item, "UTF7-IMAP", "UTF-8"); + } + + return implode($this->delimiter, $parts); + } + + /** + * Get simple name (without parent folders). + * @param $delimiter + * @param $full_name + * + * @return string|bool + */ + protected function getSimpleName($delimiter, $full_name): string|bool { + $arr = explode($delimiter, $full_name); + return end($arr); + } + + /** + * Parse attributes and set it to object properties. + * @param $attributes + */ + protected function parseAttributes($attributes): void { + $this->no_inferiors = in_array('\NoInferiors', $attributes); + $this->no_select = in_array('\NoSelect', $attributes); + $this->marked = in_array('\Marked', $attributes); + $this->referral = in_array('\Referral', $attributes); + $this->has_children = in_array('\HasChildren', $attributes); + } + + /** + * Move or rename the current folder + * @param string $new_name + * @param boolean $expunge + * + * @return array + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function move(string $new_name, bool $expunge = true): array { + $this->client->checkConnection(); + $status = $this->client->getConnection()->renameFolder($this->full_name, $new_name)->validatedData(); + if ($expunge) $this->client->expunge(); + + $folder = $this->client->getFolder($new_name); + $event = $this->getEvent("folder", "moved"); + $event::dispatch($this, $folder); + + return $status; + } + + /** + * Get a message overview + * @param string|null $sequence uid sequence + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws InvalidMessageDateException + * @throws MessageNotFoundException + * @throws ResponseException + */ + public function overview(string $sequence = null): array { + $this->client->openFolder($this->path); + $sequence = $sequence === null ? "1:*" : $sequence; + $uid = ClientManager::get('options.sequence', IMAP::ST_MSGN); + $response = $this->client->getConnection()->overview($sequence, $uid); + return $response->validatedData(); + } + + /** + * Append a string message to the current mailbox + * @param string $message + * @param array|null $options + * @param string|Carbon|null $internal_date + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function appendMessage(string $message, array $options = null, Carbon|string $internal_date = null): array { + /** + * Check if $internal_date is parsed. If it is null it should not be set. Otherwise, the message can't be stored. + * If this parameter is set, it will set the INTERNALDATE on the appended message. The parameter should be a + * date string that conforms to the rfc2060 specifications for a date_time value or be a Carbon object. + */ + + if ($internal_date instanceof Carbon) { + $internal_date = $internal_date->format('d-M-Y H:i:s O'); + } + + return $this->client->getConnection()->appendMessage($this->path, $message, $options, $internal_date)->validatedData(); + } + + /** + * Rename the current folder + * @param string $new_name + * @param boolean $expunge + * + * @return array + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws RuntimeException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws AuthFailedException + * @throws ResponseException + */ + public function rename(string $new_name, bool $expunge = true): array { + return $this->move($new_name, $expunge); + } + + /** + * Delete the current folder + * @param boolean $expunge + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws EventNotFoundException + * @throws AuthFailedException + * @throws ResponseException + */ + public function delete(bool $expunge = true): array { + $status = $this->client->getConnection()->deleteFolder($this->path)->validatedData(); + if ($this->client->getActiveFolder() == $this->path){ + $this->client->setActiveFolder(null); + } + + if ($expunge) $this->client->expunge(); + + $event = $this->getEvent("folder", "deleted"); + $event::dispatch($this); + + return $status; + } + + /** + * Subscribe the current folder + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function subscribe(): array { + $this->client->openFolder($this->path); + return $this->client->getConnection()->subscribeFolder($this->path)->validatedData(); + } + + /** + * Unsubscribe the current folder + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function unsubscribe(): array { + $this->client->openFolder($this->path); + return $this->client->getConnection()->unsubscribeFolder($this->path)->validatedData(); + } + + /** + * Idle the current connection + * @param callable $callback function(Message $message) gets called if a new message is received + * @param integer $timeout max 1740 seconds - recommended by rfc2177 §3. Should not be lower than the servers "* OK Still here" message interval + * + * @throws ConnectionFailedException + * @throws RuntimeException + * @throws AuthFailedException + * @throws NotSupportedCapabilityException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + public function idle(callable $callback, int $timeout = 300): void { + $this->client->setTimeout($timeout); + + if (!in_array("IDLE", $this->client->getConnection()->getCapabilities()->validatedData())) { + throw new Exceptions\NotSupportedCapabilityException("IMAP server does not support IDLE"); + } + + $idle_client = $this->client->clone(); + $idle_client->connect(); + $idle_client->openFolder($this->path, true); + $idle_client->getConnection()->idle(); + + $last_action = Carbon::now()->addSeconds($timeout); + + $sequence = ClientManager::get('options.sequence', IMAP::ST_MSGN); + + while (true) { + // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand + $line = $idle_client->getConnection()->nextLine(Response::empty()); + + if (($pos = strpos($line, "EXISTS")) !== false) { + $msgn = (int)substr($line, 2, $pos - 2); + + // Check if the stream is still alive or should be considered stale + if (!$this->client->isConnected() || $last_action->isBefore(Carbon::now())) { + // Reset the connection before interacting with it. Otherwise, the resource might be stale which + // would result in a stuck interaction. If you know of a way of detecting a stale resource, please + // feel free to improve this logic. I tried a lot but nothing seem to work reliably... + // Things that didn't work: + // - Closing the resource with fclose() + // - Verifying the resource with stream_get_meta_data() + // - Bool validating the resource stream (e.g.: (bool)$stream) + // - Sending a NOOP command + // - Sending a null package + // - Reading a null package + // - Catching the fs warning + + // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand + $this->client->getConnection()->reset(); + // Establish a new connection + $this->client->connect(); + } + $last_action = Carbon::now()->addSeconds($timeout); + + // Always reopen the folder - otherwise the new message number isn't known to the current remote session + $this->client->openFolder($this->path, true); + + $message = $this->query()->getMessageByMsgn($msgn); + $message->setSequence($sequence); + $callback($message); + + $event = $this->getEvent("message", "new"); + $event::dispatch($message); + } + } + } + + /** + * Get folder status information + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function getStatus(): array { + return $this->examine(); + } + + /** + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function loadStatus(): Folder { + $this->status = $this->getStatus(); + return $this; + } + + /** + * Examine the current folder + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function examine(): array { + return $this->client->getConnection()->examineFolder($this->path)->validatedData(); + } + + /** + * Select the current folder + * + * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function select(): array { + return $this->client->getConnection()->selectFolder($this->path)->validatedData(); + } + + /** + * Get the current Client instance + * + * @return Client + */ + public function getClient(): Client { + return $this->client; + } + + /** + * Set the delimiter + * @param $delimiter + */ + public function setDelimiter($delimiter): void { + if (in_array($delimiter, [null, '', ' ', false]) === true) { + $delimiter = ClientManager::get('options.delimiter', '/'); + } + + $this->delimiter = $delimiter; + } +} diff --git a/plugins/php-imap/Header.php b/plugins/php-imap/Header.php new file mode 100644 index 00000000..9c8ae046 --- /dev/null +++ b/plugins/php-imap/Header.php @@ -0,0 +1,808 @@ +raw = $raw_header; + $this->config = ClientManager::get('options'); + $this->parse(); + } + + /** + * Call dynamic attribute setter and getter methods + * @param string $method + * @param array $arguments + * + * @return Attribute|mixed + * @throws MethodNotFoundException + */ + public function __call(string $method, array $arguments) { + if (strtolower(substr($method, 0, 3)) === 'get') { + $name = preg_replace('/(.)(?=[A-Z])/u', '$1_', substr(strtolower($method), 3)); + + if (in_array($name, array_keys($this->attributes))) { + return $this->attributes[$name]; + } + + } + + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); + } + + /** + * Magic getter + * @param $name + * + * @return Attribute|null + */ + public function __get($name) { + return $this->get($name); + } + + /** + * Get a specific header attribute + * @param $name + * + * @return Attribute + */ + public function get($name): Attribute { + $name = str_replace(["-", " "], "_", strtolower($name)); + if (isset($this->attributes[$name])) { + return $this->attributes[$name]; + } + + return new Attribute($name); + } + + /** + * Check if a specific attribute exists + * @param string $name + * + * @return bool + */ + public function has(string $name): bool { + $name = str_replace(["-", " "], "_", strtolower($name)); + return isset($this->attributes[$name]); + } + + /** + * Set a specific attribute + * @param string $name + * @param array|mixed $value + * @param boolean $strict + * + * @return Attribute|array + */ + public function set(string $name, mixed $value, bool $strict = false): Attribute|array { + if (isset($this->attributes[$name]) && $strict === false) { + $this->attributes[$name]->add($value, true); + } else { + $this->attributes[$name] = new Attribute($name, $value); + } + + return $this->attributes[$name]; + } + + /** + * Perform a regex match all on the raw header and return the first result + * @param $pattern + * + * @return mixed|null + */ + public function find($pattern): mixed { + if (preg_match_all($pattern, $this->raw, $matches)) { + if (isset($matches[1])) { + if (count($matches[1]) > 0) { + return $matches[1][0]; + } + } + } + return null; + } + + /** + * Try to find a boundary if possible + * + * @return string|null + */ + public function getBoundary(): ?string { + $regex = $this->config["boundary"] ?? "/boundary=(.*?(?=;)|(.*))/i"; + $boundary = $this->find($regex); + + if ($boundary === null) { + return null; + } + + return $this->clearBoundaryString($boundary); + } + + /** + * Remove all unwanted chars from a given boundary + * @param string $str + * + * @return string + */ + private function clearBoundaryString(string $str): string { + return str_replace(['"', '\r', '\n', "\n", "\r", ";", "\s"], "", $str); + } + + /** + * Parse the raw headers + * + * @throws InvalidMessageDateException + */ + protected function parse(): void { + $header = $this->rfc822_parse_headers($this->raw); + + $this->extractAddresses($header); + + if (property_exists($header, 'subject')) { + $this->set("subject", $this->decode($header->subject)); + } + if (property_exists($header, 'references')) { + $this->set("references", array_map(function ($item) { + return str_replace(['<', '>'], '', $item); + }, explode(" ", $header->references))); + } + if (property_exists($header, 'message_id')) { + $this->set("message_id", str_replace(['<', '>'], '', $header->message_id)); + } + if (property_exists($header, 'in_reply_to')) { + $this->set("in_reply_to", str_replace(['<', '>'], '', $header->in_reply_to)); + } + + $this->parseDate($header); + foreach ($header as $key => $value) { + $key = trim(rtrim(strtolower($key))); + if (!isset($this->attributes[$key])) { + $this->set($key, $value); + } + } + + $this->extractHeaderExtensions(); + $this->findPriority(); + } + + /** + * Parse mail headers from a string + * @link https://php.net/manual/en/function.imap-rfc822-parse-headers.php + * @param $raw_headers + * + * @return object + */ + public function rfc822_parse_headers($raw_headers): object { + $headers = []; + $imap_headers = []; + if (extension_loaded('imap') && $this->config["rfc822"]) { + $raw_imap_headers = (array)\imap_rfc822_parse_headers($raw_headers); + foreach ($raw_imap_headers as $key => $values) { + $key = strtolower(str_replace("-", "_", $key)); + $imap_headers[$key] = $values; + } + } + $lines = explode("\r\n", preg_replace("/\r\n\s/", ' ', $raw_headers)); + $prev_header = null; + foreach ($lines as $line) { + if (str_starts_with($line, "\n")) { + $line = substr($line, 1); + } + + if (str_starts_with($line, "\t")) { + $line = substr($line, 1); + $line = trim(rtrim($line)); + if ($prev_header !== null) { + $headers[$prev_header][] = $line; + } + } elseif (str_starts_with($line, " ")) { + $line = substr($line, 1); + $line = trim(rtrim($line)); + if ($prev_header !== null) { + if (!isset($headers[$prev_header])) { + $headers[$prev_header] = ""; + } + if (is_array($headers[$prev_header])) { + $headers[$prev_header][] = $line; + } else { + $headers[$prev_header] .= $line; + } + } + } else { + if (($pos = strpos($line, ":")) > 0) { + $key = trim(rtrim(strtolower(substr($line, 0, $pos)))); + $key = strtolower(str_replace("-", "_", $key)); + + $value = trim(rtrim(substr($line, $pos + 1))); + if (isset($headers[$key])) { + $headers[$key][] = $value; + } else { + $headers[$key] = [$value]; + } + $prev_header = $key; + } + } + } + + foreach ($headers as $key => $values) { + if (isset($imap_headers[$key])) { + continue; + } + $value = null; + switch ((string)$key) { + case 'from': + case 'to': + case 'cc': + case 'bcc': + case 'reply_to': + case 'sender': + $value = $this->decodeAddresses($values); + $headers[$key . "address"] = implode(", ", $values); + break; + case 'subject': + $value = implode(" ", $values); + break; + default: + if (is_array($values)) { + foreach ($values as $k => $v) { + if ($v == "") { + unset($values[$k]); + } + } + $available_values = count($values); + if ($available_values === 1) { + $value = array_pop($values); + } elseif ($available_values === 2) { + $value = implode(" ", $values); + } elseif ($available_values > 2) { + $value = array_values($values); + } else { + $value = ""; + } + } + break; + } + $headers[$key] = $value; + } + + return (object)array_merge($headers, $imap_headers); + } + + /** + * Decode MIME header elements + * @link https://php.net/manual/en/function.imap-mime-header-decode.php + * @param string $text The MIME text + * + * @return array The decoded elements are returned in an array of objects, where each + * object has two properties, charset and text. + */ + public function mime_header_decode(string $text): array { + if (extension_loaded('imap')) { + $result = \imap_mime_header_decode($text); + return is_array($result) ? $result : []; + } + $charset = $this->getEncoding($text); + return [(object)[ + "charset" => $charset, + "text" => $this->convertEncoding($text, $charset) + ]]; + } + + /** + * Check if a given pair of strings has been decoded + * @param $encoded + * @param $decoded + * + * @return bool + */ + private function notDecoded($encoded, $decoded): bool { + return str_starts_with($decoded, '=?') + && strlen($decoded) - 2 === strpos($decoded, '?=') + && str_contains($encoded, $decoded); + } + + /** + * Convert the encoding + * @param $str + * @param string $from + * @param string $to + * + * @return mixed|string + */ + public function convertEncoding($str, string $from = "ISO-8859-2", string $to = "UTF-8"): mixed { + $from = EncodingAliases::get($from, $this->fallback_encoding); + $to = EncodingAliases::get($to, $this->fallback_encoding); + + if ($from === $to) { + return $str; + } + + return EncodingAliases::convert($str, $from, $to); + } + + /** + * Get the encoding of a given abject + * @param object|string $structure + * + * @return string + */ + public function getEncoding(object|string $structure): string { + if (property_exists($structure, 'parameters')) { + foreach ($structure->parameters as $parameter) { + if (strtolower($parameter->attribute) == "charset") { + return EncodingAliases::get($parameter->value, $this->fallback_encoding); + } + } + } elseif (property_exists($structure, 'charset')) { + return EncodingAliases::get($structure->charset, $this->fallback_encoding); + } elseif (is_string($structure) === true) { + $result = mb_detect_encoding($structure); + return $result === false ? $this->fallback_encoding : $result; + } + + return $this->fallback_encoding; + } + + /** + * Test if a given value is utf-8 encoded + * @param $value + * + * @return bool + */ + private function is_uft8($value): bool { + return str_starts_with(strtolower($value), '=?utf-8?'); + } + + /** + * Try to decode a specific header + * @param mixed $value + * + * @return mixed + */ + public function decode(mixed $value): mixed { + if (is_array($value)) { + return $this->decodeArray($value); + } + $original_value = $value; + $decoder = $this->config['decoder']['message']; + + if ($value !== null) { + if ($decoder === 'utf-8') { + $decoded_values = $this->mime_header_decode($value); + $tempValue = ""; + foreach ($decoded_values as $decoded_value) { + $tempValue .= $this->convertEncoding($decoded_value->text, $decoded_value->charset); + } + if ($tempValue) { + $value = $tempValue; + } else if (extension_loaded('imap')) { + $value = \imap_utf8($value); + }else if (function_exists('iconv_mime_decode')){ + $value = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8"); + }else{ + $value = mb_decode_mimeheader($value); + } + }elseif ($decoder === 'iconv') { + $value = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8"); + }else if ($this->is_uft8($value)) { + $value = mb_decode_mimeheader($value); + } + + if ($this->notDecoded($original_value, $value)) { + $value = $this->convertEncoding($original_value, $this->getEncoding($original_value)); + } + } + + return $value; + } + + /** + * Decode a given array + * @param array $values + * + * @return array + */ + private function decodeArray(array $values): array { + foreach ($values as $key => $value) { + $values[$key] = $this->decode($value); + } + return $values; + } + + /** + * Try to extract the priority from a given raw header string + */ + private function findPriority(): void { + $priority = $this->get("x_priority"); + + $priority = match ((int)"$priority") { + IMAP::MESSAGE_PRIORITY_HIGHEST => IMAP::MESSAGE_PRIORITY_HIGHEST, + IMAP::MESSAGE_PRIORITY_HIGH => IMAP::MESSAGE_PRIORITY_HIGH, + IMAP::MESSAGE_PRIORITY_NORMAL => IMAP::MESSAGE_PRIORITY_NORMAL, + IMAP::MESSAGE_PRIORITY_LOW => IMAP::MESSAGE_PRIORITY_LOW, + IMAP::MESSAGE_PRIORITY_LOWEST => IMAP::MESSAGE_PRIORITY_LOWEST, + default => IMAP::MESSAGE_PRIORITY_UNKNOWN, + }; + + $this->set("priority", $priority); + } + + /** + * Extract a given part as address array from a given header + * @param $values + * + * @return array + */ + private function decodeAddresses($values): array { + $addresses = []; + + if (extension_loaded('mailparse') && $this->config["rfc822"]) { + foreach ($values as $address) { + foreach (\mailparse_rfc822_parse_addresses($address) as $parsed_address) { + if (isset($parsed_address['address'])) { + $mail_address = explode('@', $parsed_address['address']); + if (count($mail_address) == 2) { + $addresses[] = (object)[ + "personal" => $parsed_address['display'] ?? '', + "mailbox" => $mail_address[0], + "host" => $mail_address[1], + ]; + } + } + } + } + + return $addresses; + } + + foreach ($values as $address) { + foreach (preg_split('/, (?=(?:[^"]*"[^"]*")*[^"]*$)/', $address) as $split_address) { + $split_address = trim(rtrim($split_address)); + + if (strpos($split_address, ",") == strlen($split_address) - 1) { + $split_address = substr($split_address, 0, -1); + } + if (preg_match( + '/^(?:(?P.+)\s)?(?(name)<|[^\s]+?)(?(name)>|>?)$/', + $split_address, + $matches + )) { + $name = trim(rtrim($matches["name"])); + $email = trim(rtrim($matches["email"])); + list($mailbox, $host) = array_pad(explode("@", $email), 2, null); + $addresses[] = (object)[ + "personal" => $name, + "mailbox" => $mailbox, + "host" => $host, + ]; + } + } + } + + return $addresses; + } + + /** + * Extract a given part as address array from a given header + * @param object $header + */ + private function extractAddresses(object $header): void { + foreach (['from', 'to', 'cc', 'bcc', 'reply_to', 'sender'] as $key) { + if (property_exists($header, $key)) { + $this->set($key, $this->parseAddresses($header->$key)); + } + } + } + + /** + * Parse Addresses + * @param $list + * + * @return array + */ + private function parseAddresses($list): array { + $addresses = []; + + if (is_array($list) === false) { + return $addresses; + } + + foreach ($list as $item) { + $address = (object)$item; + + if (!property_exists($address, 'mailbox')) { + $address->mailbox = false; + } + if (!property_exists($address, 'host')) { + $address->host = false; + } + if (!property_exists($address, 'personal')) { + $address->personal = false; + } else { + $personalParts = $this->mime_header_decode($address->personal); + + $address->personal = ''; + foreach ($personalParts as $p) { + $address->personal .= $this->convertEncoding($p->text, $this->getEncoding($p)); + } + + if (str_starts_with($address->personal, "'")) { + $address->personal = str_replace("'", "", $address->personal); + } + } + + if ($address->host == ".SYNTAX-ERROR.") { + $address->host = ""; + } + if ($address->mailbox == "UNEXPECTED_DATA_AFTER_ADDRESS") { + $address->mailbox = ""; + } + + $address->mail = ($address->mailbox && $address->host) ? $address->mailbox . '@' . $address->host : false; + $address->full = ($address->personal) ? $address->personal . ' <' . $address->mail . '>' : $address->mail; + + $addresses[] = new Address($address); + } + + return $addresses; + } + + /** + * Search and extract potential header extensions + */ + private function extractHeaderExtensions(): void { + foreach ($this->attributes as $key => $value) { + if (is_array($value)) { + $value = implode(", ", $value); + } else { + $value = (string)$value; + } + // Only parse strings and don't parse any attributes like the user-agent + if (!in_array($key, ["user-agent", "subject"])) { + if (($pos = strpos($value, ";")) !== false) { + $original = substr($value, 0, $pos); + $this->set($key, trim(rtrim($original))); + + // Get all potential extensions + $extensions = explode(";", substr($value, $pos + 1)); + $previousKey = null; + $previousValue = ''; + + foreach ($extensions as $extension) { + if (($pos = strpos($extension, "=")) !== false) { + $key = substr($extension, 0, $pos); + $key = trim(rtrim(strtolower($key))); + + $matches = []; + + if (preg_match('/^(?P\w+)\*/', $key, $matches) !== 0) { + $key = $matches['key_name']; + $previousKey = $key; + + $value = substr($extension, $pos + 1); + $value = str_replace('"', "", $value); + $previousValue .= trim(rtrim($value)); + + continue; + } + + if ( + $previousKey !== null + && $previousKey !== $key + && isset($this->attributes[$previousKey]) === false + ) { + $this->set($previousKey, $previousValue); + + $previousValue = ''; + } + + if (isset($this->attributes[$key]) === false) { + $value = substr($extension, $pos + 1); + $value = str_replace('"', "", $value); + $value = trim(rtrim($value)); + + $this->set($key, $value); + } + + $previousKey = $key; + } + } + if ($previousValue !== '') { + $this->set($previousKey, $previousValue); + } + } + } + } + } + + /** + * Exception handling for invalid dates + * + * Known bad and "invalid" formats: + * ^ Datetime ^ Problem ^ Cause + * | Mon, 20 Nov 2017 20:31:31 +0800 (GMT+8:00) | Double timezone specification | A Windows feature + * | Thu, 8 Nov 2018 08:54:58 -0200 (-02) | + * | | and invalid timezone (max 6 char) | + * | 04 Jan 2018 10:12:47 UT | Missing letter "C" | Unknown + * | Thu, 31 May 2018 18:15:00 +0800 (added by) | Non-standard details added by the | Unknown + * | | mail server | + * | Sat, 31 Aug 2013 20:08:23 +0580 | Invalid timezone | PHPMailer bug https://sourceforge.net/p/phpmailer/mailman/message/6132703/ + * + * Please report any new invalid timestamps to [#45](https://github.com/Webklex/php-imap/issues) + * + * @param object $header + * + * @throws InvalidMessageDateException + */ + private function parseDate(object $header): void { + + if (property_exists($header, 'date')) { + $date = $header->date; + + if (preg_match('/\+0580/', $date)) { + $date = str_replace('+0580', '+0530', $date); + } + + $date = trim(rtrim($date)); + try { + if (str_contains($date, ' ')) { + $date = str_replace(' ', ' ', $date); + } + if (str_contains($date, ' UT ')) { + $date = str_replace(' UT ', ' UTC ', $date); + } + $parsed_date = Carbon::parse($date); + } catch (\Exception $e) { + switch (true) { + case preg_match('/([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}\-[0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})+$/i', $date) > 0: + $date = Carbon::createFromFormat("Y.m.d-H.i.s", $date); + break; + case preg_match('/([0-9]{2} [A-Z]{3} [0-9]{4} [0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+-][0-9]{1,4} [0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+-][0-9]{1,4})+$/i', $date) > 0: + $parts = explode(' ', $date); + array_splice($parts, -2); + $date = implode(' ', $parts); + break; + case preg_match('/([A-Z]{2,4}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})+$/i', $date) > 0: + $array = explode(',', $date); + array_shift($array); + $date = Carbon::createFromFormat("d M Y H:i:s O", trim(implode(',', $array))); + break; + case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: + $date .= 'C'; + break; + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}[\,]\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})+$/i', $date) > 0: + $date = str_replace(',', '', $date); + break; + // match case for: Di., 15 Feb. 2022 06:52:44 +0100 (MEZ)/Di., 15 Feb. 2022 06:52:44 +0100 (MEZ) + case preg_match('/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \([A-Z]{3,4}\))\/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \([A-Z]{3,4}\))+$/i', $date) > 0: + $dates = explode('/', $date); + $date = array_shift($dates); + $array = explode(',', $date); + array_shift($array); + $date = trim(implode(',', $array)); + $array = explode(' ', $date); + array_pop($array); + $date = trim(implode(' ', $array)); + $date = Carbon::createFromFormat("d M. Y H:i:s O", $date); + break; + // match case for: fr., 25 nov. 2022 06:27:14 +0100/fr., 25 nov. 2022 06:27:14 +0100 + case preg_match('/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})\/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})+$/i', $date) > 0: + $dates = explode('/', $date); + $date = array_shift($dates); + $array = explode(',', $date); + array_shift($array); + $date = trim(implode(',', $array)); + $date = Carbon::createFromFormat("d M. Y H:i:s O", $date); + break; + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ \+[0-9]{2,4}\ \(\+[0-9]{1,2}\))+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}[\,|\ \,]\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}.*)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\, \ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: + case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{2,4}\ [0-9]{2}\:[0-9]{2}\:[0-9]{2}\ [A-Z]{2}\ \-[0-9]{2}\:[0-9]{2}\ \([A-Z]{2,3}\ \-[0-9]{2}:[0-9]{2}\))+$/i', $date) > 0: + $array = explode('(', $date); + $array = array_reverse($array); + $date = trim(array_pop($array)); + break; + } + try { + $parsed_date = Carbon::parse($date); + } catch (\Exception $_e) { + if (!isset($this->config["fallback_date"])) { + throw new InvalidMessageDateException("Invalid message date. ID:" . $this->get("message_id") . " Date:" . $header->date . "/" . $date, 1100, $e); + } else { + $parsed_date = Carbon::parse($this->config["fallback_date"]); + } + } + } + + $this->set("date", $parsed_date); + } + } + + /** + * Get all available attributes + * + * @return array + */ + public function getAttributes(): array { + return $this->attributes; + } + + /** + * Set all header attributes + * @param array $attributes + * + * @return Header + */ + public function setAttributes(array $attributes): Header { + $this->attributes = $attributes; + return $this; + } + + /** + * Set the configuration used for parsing a raw header + * @param array $config + * + * @return Header + */ + public function setConfig(array $config): Header { + $this->config = $config; + return $this; + } + +} diff --git a/plugins/php-imap/IMAP.php b/plugins/php-imap/IMAP.php new file mode 100644 index 00000000..41ae8248 --- /dev/null +++ b/plugins/php-imap/IMAP.php @@ -0,0 +1,375 @@ +imap_close + * @link http://php.net/manual/en/imap.constants.php + */ + const CL_EXPUNGE = 32768; + + /** + * The parameter is a UID + * @link http://php.net/manual/en/imap.constants.php + */ + const FT_UID = 1; + + /** + * Do not set the \Seen flag if not already set + * @link http://php.net/manual/en/imap.constants.php + */ + const FT_PEEK = 2; + const FT_NOT = 4; + + /** + * The return string is in internal format, will not canonicalize to CRLF. + * @link http://php.net/manual/en/imap.constants.php + */ + const FT_INTERNAL = 8; + const FT_PREFETCHTEXT = 32; + + /** + * The sequence argument contains UIDs instead of sequence numbers + * @link http://php.net/manual/en/imap.constants.php + */ + const ST_UID = 1; + const ST_SILENT = 2; + const ST_MSGN = 3; + const ST_SET = 4; + + /** + * the sequence numbers contain UIDS + * @link http://php.net/manual/en/imap.constants.php + */ + const CP_UID = 1; + + /** + * Delete the messages from the current mailbox after copying + * with imap_mail_copy + * @link http://php.net/manual/en/imap.constants.php + */ + const CP_MOVE = 2; + + /** + * Return UIDs instead of sequence numbers + * @link http://php.net/manual/en/imap.constants.php + */ + const SE_UID = 1; + const SE_FREE = 2; + + /** + * Don't prefetch searched messages + * @link http://php.net/manual/en/imap.constants.php + */ + const SE_NOPREFETCH = 4; + const SO_FREE = 8; + const SO_NOSERVER = 16; + const SA_MESSAGES = 1; + const SA_RECENT = 2; + const SA_UNSEEN = 4; + const SA_UIDNEXT = 8; + const SA_UIDVALIDITY = 16; + const SA_ALL = 31; + + /** + * This mailbox has no "children" (there are no + * mailboxes below this one). + * @link http://php.net/manual/en/imap.constants.php + */ + const LATT_NOINFERIORS = 1; + + /** + * This is only a container, not a mailbox - you + * cannot open it. + * @link http://php.net/manual/en/imap.constants.php + */ + const LATT_NOSELECT = 2; + + /** + * This mailbox is marked. Only used by UW-IMAPD. + * @link http://php.net/manual/en/imap.constants.php + */ + const LATT_MARKED = 4; + + /** + * This mailbox is not marked. Only used by + * UW-IMAPD. + * @link http://php.net/manual/en/imap.constants.php + */ + const LATT_UNMARKED = 8; + const LATT_REFERRAL = 16; + const LATT_HASCHILDREN = 32; + const LATT_HASNOCHILDREN = 64; + + /** + * Sort criteria for imap_sort: + * message Date + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTDATE = 0; + + /** + * Sort criteria for imap_sort: + * arrival date + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTARRIVAL = 1; + + /** + * Sort criteria for imap_sort: + * mailbox in first From address + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTFROM = 2; + + /** + * Sort criteria for imap_sort: + * message subject + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTSUBJECT = 3; + + /** + * Sort criteria for imap_sort: + * mailbox in first To address + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTTO = 4; + + /** + * Sort criteria for imap_sort: + * mailbox in first cc address + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTCC = 5; + + /** + * Sort criteria for imap_sort: + * size of message in octets + * @link http://php.net/manual/en/imap.constants.php + */ + const SORTSIZE = 6; + const TYPETEXT = 0; + const TYPEMULTIPART = 1; + const TYPEMESSAGE = 2; + const TYPEAPPLICATION = 3; + const TYPEAUDIO = 4; + const TYPEIMAGE = 5; + const TYPEVIDEO = 6; + const TYPEMODEL = 7; + const TYPEOTHER = 8; + const ENC7BIT = 0; + const ENC8BIT = 1; + const ENCBINARY = 2; + const ENCBASE64 = 3; + const ENCQUOTEDPRINTABLE = 4; + const ENCOTHER = 5; + + /** + * Garbage collector, clear message cache elements. + * @link http://php.net/manual/en/imap.constants.php + */ + const IMAP_GC_ELT = 1; + + /** + * Garbage collector, clear envelopes and bodies. + * @link http://php.net/manual/en/imap.constants.php + */ + const IMAP_GC_ENV = 2; + + /** + * Garbage collector, clear texts. + * @link http://php.net/manual/en/imap.constants.php + */ + const IMAP_GC_TEXTS = 4; + +} \ No newline at end of file diff --git a/plugins/php-imap/Message.php b/plugins/php-imap/Message.php new file mode 100755 index 00000000..09a534f2 --- /dev/null +++ b/plugins/php-imap/Message.php @@ -0,0 +1,1703 @@ +boot(); + + $default_mask = $client->getDefaultMessageMask(); + if ($default_mask != null) { + $this->mask = $default_mask; + } + $this->events["message"] = $client->getDefaultEvents("message"); + $this->events["flag"] = $client->getDefaultEvents("flag"); + + $this->folder_path = $client->getFolderPath(); + + $this->setSequence($sequence); + $this->setFetchOption($fetch_options); + $this->setFetchBodyOption($fetch_body); + $this->setFetchFlagsOption($fetch_flags); + + $this->client = $client; + $this->client->openFolder($this->folder_path); + + $this->setSequenceId($uid, $msglist); + + if ($this->fetch_options == IMAP::FT_PEEK) { + $this->parseFlags(); + } + + $this->parseHeader(); + + if ($this->getFetchBodyOption() === true) { + $this->parseBody(); + } + + if ($this->getFetchFlagsOption() === true && $this->fetch_options !== IMAP::FT_PEEK) { + $this->parseFlags(); + } + } + + /** + * Create a new instance without fetching the message header and providing them raw instead + * @param int $uid + * @param int|null $msglist + * @param Client $client + * @param string $raw_header + * @param string $raw_body + * @param array $raw_flags + * @param null $fetch_options + * @param null $sequence + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws ReflectionException + * @throws RuntimeException + * @throws ResponseException + */ + public static function make(int $uid, ?int $msglist, Client $client, string $raw_header, string $raw_body, array $raw_flags, $fetch_options = null, $sequence = null): Message { + $reflection = new ReflectionClass(self::class); + /** @var Message $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + $instance->boot(); + + $default_mask = $client->getDefaultMessageMask(); + if ($default_mask != null) { + $instance->setMask($default_mask); + } + $instance->setEvents([ + "message" => $client->getDefaultEvents("message"), + "flag" => $client->getDefaultEvents("flag"), + ]); + $instance->setFolderPath($client->getFolderPath()); + $instance->setSequence($sequence); + $instance->setFetchOption($fetch_options); + + $instance->setClient($client); + $instance->setSequenceId($uid, $msglist); + + $instance->parseRawHeader($raw_header); + $instance->parseRawFlags($raw_flags); + $instance->parseRawBody($raw_body); + $instance->peek(); + + return $instance; + } + + /** + * Create a new message instance by reading and loading a file or remote location + * + * @throws RuntimeException + * @throws MessageContentFetchingException + * @throws ResponseException + * @throws ImapBadRequestException + * @throws InvalidMessageDateException + * @throws ConnectionFailedException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws AuthFailedException + * @throws MaskNotFoundException + */ + public static function fromFile($filename): Message { + $blob = file_get_contents($filename); + if ($blob === false) { + throw new RuntimeException("Unable to read file"); + } + return self::fromString($blob); + } + + /** + * Create a new message instance by reading and loading a string + * @param string $blob + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + */ + public static function fromString(string $blob): Message { + $reflection = new ReflectionClass(self::class); + /** @var Message $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + $instance->boot(); + + $default_mask = ClientManager::getMask("message"); + if($default_mask != ""){ + $instance->setMask($default_mask); + }else{ + throw new MaskNotFoundException("Unknown message mask provided"); + } + + if(!str_contains($blob, "\r\n")){ + $blob = str_replace("\n", "\r\n", $blob); + } + $raw_header = substr($blob, 0, strpos($blob, "\r\n\r\n")); + $raw_body = substr($blob, strlen($raw_header)+4); + + $instance->parseRawHeader($raw_header); + $instance->parseRawBody($raw_body); + + $instance->setUid(0); + + return $instance; + } + + /** + * Boot a new instance + */ + public function boot(): void { + $this->attributes = []; + + $this->config = ClientManager::get('options'); + $this->available_flags = ClientManager::get('flags'); + + $this->attachments = AttachmentCollection::make([]); + $this->flags = FlagCollection::make([]); + } + + /** + * Call dynamic attribute setter and getter methods + * @param string $method + * @param array $arguments + * + * @return mixed + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws MethodNotFoundException + * @throws MessageSizeFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function __call(string $method, array $arguments) { + if (strtolower(substr($method, 0, 3)) === 'get') { + $name = Str::snake(substr($method, 3)); + return $this->get($name); + } elseif (strtolower(substr($method, 0, 3)) === 'set') { + $name = Str::snake(substr($method, 3)); + + if (in_array($name, array_keys($this->attributes))) { + return $this->__set($name, array_pop($arguments)); + } + + } + + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); + } + + /** + * Magic setter + * @param $name + * @param $value + * + * @return mixed + */ + public function __set($name, $value) { + $this->attributes[$name] = $value; + + return $this->attributes[$name]; + } + + /** + * Magic getter + * @param $name + * + * @return Attribute|mixed|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws MessageSizeFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function __get($name) { + return $this->get($name); + } + + /** + * Get an available message or message header attribute + * @param $name + * + * @return Attribute|mixed|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + * @throws MessageSizeFetchingException + */ + public function get($name): mixed { + if (isset($this->attributes[$name]) && $this->attributes[$name] !== null) { + return $this->attributes[$name]; + } + + switch ($name){ + case "uid": + $this->attributes[$name] = $this->client->getConnection()->getUid($this->msgn)->validate()->integer(); + return $this->attributes[$name]; + case "msgn": + $this->attributes[$name] = $this->client->getConnection()->getMessageNumber($this->uid)->validate()->integer(); + return $this->attributes[$name]; + case "size": + if (!isset($this->attributes[$name])) { + $this->fetchSize(); + } + return $this->attributes[$name]; + } + + return $this->header->get($name); + } + + /** + * Check if the Message has a text body + * + * @return bool + */ + public function hasTextBody(): bool { + return isset($this->bodies['text']) && $this->bodies['text'] !== ""; + } + + /** + * Get the Message text body + * + * @return string + */ + public function getTextBody(): string { + if (!isset($this->bodies['text'])) { + return ""; + } + + return $this->bodies['text']; + } + + /** + * Check if the Message has a html body + * + * @return bool + */ + public function hasHTMLBody(): bool { + return isset($this->bodies['html']) && $this->bodies['html'] !== ""; + } + + /** + * Get the Message html body + * + * @return string + */ + public function getHTMLBody(): string { + if (!isset($this->bodies['html'])) { + return ""; + } + + return $this->bodies['html']; + } + + /** + * Parse all defined headers + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws InvalidMessageDateException + * @throws MessageHeaderFetchingException + * @throws ResponseException + */ + private function parseHeader(): void { + $sequence_id = $this->getSequenceId(); + $headers = $this->client->getConnection()->headers([$sequence_id], "RFC822", $this->sequence)->validatedData(); + if (!isset($headers[$sequence_id])) { + throw new MessageHeaderFetchingException("no headers found", 0); + } + + $this->parseRawHeader($headers[$sequence_id]); + } + + /** + * @param string $raw_header + * + * @throws InvalidMessageDateException + */ + public function parseRawHeader(string $raw_header): void { + $this->header = new Header($raw_header); + } + + /** + * Parse additional raw flags + * @param array $raw_flags + */ + public function parseRawFlags(array $raw_flags): void { + $this->flags = FlagCollection::make([]); + + foreach ($raw_flags as $flag) { + if (str_starts_with($flag, "\\")) { + $flag = substr($flag, 1); + } + $flag_key = strtolower($flag); + if ($this->available_flags === null || in_array($flag_key, $this->available_flags)) { + $this->flags->put($flag_key, $flag); + } + } + } + + /** + * Parse additional flags + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + private function parseFlags(): void { + $this->client->openFolder($this->folder_path); + $this->flags = FlagCollection::make([]); + + $sequence_id = $this->getSequenceId(); + try { + $flags = $this->client->getConnection()->flags([$sequence_id], $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageFlagException("flag could not be fetched", 0, $e); + } + + if (isset($flags[$sequence_id])) { + $this->parseRawFlags($flags[$sequence_id]); + } + } + + /** + * Parse the Message body + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function parseBody(): Message { + $this->client->openFolder($this->folder_path); + + $sequence_id = $this->getSequenceId(); + try { + $contents = $this->client->getConnection()->content([$sequence_id], "RFC822", $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageContentFetchingException("failed to fetch content", 0); + } + if (!isset($contents[$sequence_id])) { + throw new MessageContentFetchingException("no content found", 0); + } + $content = $contents[$sequence_id]; + + $body = $this->parseRawBody($content); + $this->peek(); + + return $body; + } + + /** + * Fetches the size for this message. + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageSizeFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + private function fetchSize(): void { + $sequence_id = $this->getSequenceId(); + $sizes = $this->client->getConnection()->sizes([$sequence_id], $this->sequence)->validatedData(); + if (!isset($sizes[$sequence_id])) { + throw new MessageSizeFetchingException("sizes did not set an array entry for the supplied sequence_id", 0); + } + $this->attributes["size"] = $sizes[$sequence_id]; + } + + /** + * Handle auto "Seen" flag handling + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function peek(): void { + if ($this->fetch_options == IMAP::FT_PEEK) { + if ($this->getFlags()->get("seen") == null) { + $this->unsetFlag("Seen"); + } + } elseif ($this->getFlags()->get("seen") == null) { + $this->setFlag("Seen"); + } + } + + /** + * Parse a given message body + * @param string $raw_body + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function parseRawBody(string $raw_body): Message { + $this->structure = new Structure($raw_body, $this->header); + $this->fetchStructure($this->structure); + + return $this; + } + + /** + * Fetch the Message structure + * @param Structure $structure + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + private function fetchStructure(Structure $structure): void { + $this->client?->openFolder($this->folder_path); + + foreach ($structure->parts as $part) { + $this->fetchPart($part); + } + } + + /** + * Fetch a given part + * @param Part $part + */ + private function fetchPart(Part $part): void { + if ($part->isAttachment()) { + $this->fetchAttachment($part); + } else { + $encoding = $this->getEncoding($part); + + $content = $this->decodeString($part->content, $part->encoding); + + // We don't need to do convertEncoding() if charset is ASCII (us-ascii): + // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded + // https://stackoverflow.com/a/11303410 + // + // us-ascii is the same as ASCII: + // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) + // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and + // based on the typographical symbols predominantly in use there. + // https://en.wikipedia.org/wiki/ASCII + // + // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. + if ($encoding != 'us-ascii') { + $content = $this->convertEncoding($content, $encoding); + } + + $this->addBody($part->subtype ?? '', $content); + } + } + + /** + * Add a body to the message + * @param string $subtype + * @param string $content + * + * @return void + */ + protected function addBody(string $subtype, string $content): void { + $subtype = strtolower($subtype); + $subtype = $subtype == "plain" || $subtype == "" ? "text" : $subtype; + + if (isset($this->bodies[$subtype]) && $this->bodies[$subtype] !== null && $this->bodies[$subtype] !== "") { + if ($content !== "") { + $this->bodies[$subtype] .= "\n".$content; + } + } else { + $this->bodies[$subtype] = $content; + } + } + + /** + * Fetch the Message attachment + * @param Part $part + */ + protected function fetchAttachment(Part $part): void { + $oAttachment = new Attachment($this, $part); + + if ($oAttachment->getSize() > 0) { + if ($oAttachment->getId() !== null && $this->attachments->offsetExists($oAttachment->getId())) { + $this->attachments->put($oAttachment->getId(), $oAttachment); + } else { + $this->attachments->push($oAttachment); + } + } + } + + /** + * Fail proof setter for $fetch_option + * @param $option + * + * @return Message + */ + public function setFetchOption($option): Message { + if (is_long($option) === true) { + $this->fetch_options = $option; + } elseif (is_null($option) === true) { + $config = ClientManager::get('options.fetch', IMAP::FT_UID); + $this->fetch_options = is_long($config) ? $config : 1; + } + + return $this; + } + + /** + * Set the sequence type + * @param int|null $sequence + * + * @return Message + */ + public function setSequence(?int $sequence): Message { + if (is_long($sequence)) { + $this->sequence = $sequence; + } elseif (is_null($sequence)) { + $config = ClientManager::get('options.sequence', IMAP::ST_MSGN); + $this->sequence = is_long($config) ? $config : IMAP::ST_MSGN; + } + + return $this; + } + + /** + * Fail proof setter for $fetch_body + * @param $option + * + * @return Message + */ + public function setFetchBodyOption($option): Message { + if (is_bool($option)) { + $this->fetch_body = $option; + } elseif (is_null($option)) { + $config = ClientManager::get('options.fetch_body', true); + $this->fetch_body = is_bool($config) ? $config : true; + } + + return $this; + } + + /** + * Fail proof setter for $fetch_flags + * @param $option + * + * @return Message + */ + public function setFetchFlagsOption($option): Message { + if (is_bool($option)) { + $this->fetch_flags = $option; + } elseif (is_null($option)) { + $config = ClientManager::get('options.fetch_flags', true); + $this->fetch_flags = is_bool($config) ? $config : true; + } + + return $this; + } + + /** + * Decode a given string + * @param $string + * @param $encoding + * + * @return string + */ + public function decodeString($string, $encoding): string { + switch ($encoding) { + case IMAP::MESSAGE_ENC_BINARY: + if (extension_loaded('imap')) { + return base64_decode(\imap_binary($string)); + } + return base64_decode($string); + case IMAP::MESSAGE_ENC_BASE64: + return base64_decode($string); + case IMAP::MESSAGE_ENC_QUOTED_PRINTABLE: + return quoted_printable_decode($string); + case IMAP::MESSAGE_ENC_8BIT: + case IMAP::MESSAGE_ENC_7BIT: + case IMAP::MESSAGE_ENC_OTHER: + default: + return $string; + } + } + + /** + * Convert the encoding + * @param $str + * @param string $from + * @param string $to + * + * @return mixed|string + */ + public function convertEncoding($str, string $from = "ISO-8859-2", string $to = "UTF-8"): mixed { + + $from = EncodingAliases::get($from); + $to = EncodingAliases::get($to); + + if ($from === $to) { + return $str; + } + + // We don't need to do convertEncoding() if charset is ASCII (us-ascii): + // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded + // https://stackoverflow.com/a/11303410 + // + // us-ascii is the same as ASCII: + // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) + // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and + // based on the typographical symbols predominantly in use there. + // https://en.wikipedia.org/wiki/ASCII + // + // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. + if (strtolower($from ?? '') == 'us-ascii' && $to == 'UTF-8') { + return $str; + } + + if (function_exists('iconv') && !EncodingAliases::isUtf7($from) && !EncodingAliases::isUtf7($to)) { + try { + return iconv($from, $to.'//IGNORE', $str); + } catch (\Exception $e) { + return @iconv($from, $to, $str); + } + } else { + if (!$from) { + return mb_convert_encoding($str, $to); + } + return mb_convert_encoding($str, $to, $from); + } + } + + /** + * Get the encoding of a given abject + * @param object|string $structure + * + * @return string + */ + public function getEncoding(object|string $structure): string { + if (property_exists($structure, 'parameters')) { + foreach ($structure->parameters as $parameter) { + if (strtolower($parameter->attribute) == "charset") { + return EncodingAliases::get($parameter->value, "ISO-8859-2"); + } + } + } elseif (property_exists($structure, 'charset')) { + return EncodingAliases::get($structure->charset, "ISO-8859-2"); + } elseif (is_string($structure) === true) { + return EncodingAliases::detectEncoding($structure); + } + + return 'UTF-8'; + } + + /** + * Get the messages folder + * + * @return ?Folder + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getFolder(): ?Folder { + return $this->client->getFolderByPath($this->folder_path); + } + + /** + * Create a message thread based on the current message + * @param Folder|null $sent_folder + * @param MessageCollection|null $thread + * @param Folder|null $folder + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function thread(Folder $sent_folder = null, MessageCollection &$thread = null, Folder $folder = null): MessageCollection { + $thread = $thread ?: MessageCollection::make([]); + $folder = $folder ?: $this->getFolder(); + $sent_folder = $sent_folder ?: $this->client->getFolderByPath(ClientManager::get("options.common_folders.sent", "INBOX/Sent")); + + /** @var Message $message */ + foreach ($thread as $message) { + if ($message->message_id->first() == $this->message_id->first()) { + return $thread; + } + } + $thread->push($this); + + $this->fetchThreadByInReplyTo($thread, $this->message_id, $folder, $folder, $sent_folder); + $this->fetchThreadByInReplyTo($thread, $this->message_id, $sent_folder, $folder, $sent_folder); + + foreach ($this->in_reply_to->all() as $in_reply_to) { + $this->fetchThreadByMessageId($thread, $in_reply_to, $folder, $folder, $sent_folder); + $this->fetchThreadByMessageId($thread, $in_reply_to, $sent_folder, $folder, $sent_folder); + } + + return $thread; + } + + /** + * Fetch a partial thread by message id + * @param MessageCollection $thread + * @param string $in_reply_to + * @param Folder $primary_folder + * @param Folder $secondary_folder + * @param Folder $sent_folder + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetchThreadByInReplyTo(MessageCollection &$thread, string $in_reply_to, Folder $primary_folder, Folder $secondary_folder, Folder $sent_folder): void { + $primary_folder->query()->inReplyTo($in_reply_to) + ->setFetchBody($this->getFetchBodyOption()) + ->leaveUnread()->get()->each(function($message) use (&$thread, $secondary_folder, $sent_folder) { + /** @var Message $message */ + $message->thread($sent_folder, $thread, $secondary_folder); + }); + } + + /** + * Fetch a partial thread by message id + * @param MessageCollection $thread + * @param string $message_id + * @param Folder $primary_folder + * @param Folder $secondary_folder + * @param Folder $sent_folder + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetchThreadByMessageId(MessageCollection &$thread, string $message_id, Folder $primary_folder, Folder $secondary_folder, Folder $sent_folder): void { + $primary_folder->query()->messageId($message_id) + ->setFetchBody($this->getFetchBodyOption()) + ->leaveUnread()->get()->each(function($message) use (&$thread, $secondary_folder, $sent_folder) { + /** @var Message $message */ + $message->thread($sent_folder, $thread, $secondary_folder); + }); + } + + /** + * Copy the current Messages to a mailbox + * @param string $folder_path + * @param boolean $expunge + * + * @return null|Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function copy(string $folder_path, bool $expunge = false): ?Message { + $this->client->openFolder($folder_path); + $status = $this->client->getConnection()->examineFolder($folder_path)->validatedData(); + + if (isset($status["uidnext"])) { + $next_uid = $status["uidnext"]; + if ((int)$next_uid <= 0) { + return null; + } + + /** @var Folder $folder */ + $folder = $this->client->getFolderByPath($folder_path); + + $this->client->openFolder($this->folder_path); + if ($this->client->getConnection()->copyMessage($folder->path, $this->getSequenceId(), null, $this->sequence)->validatedData()) { + return $this->fetchNewMail($folder, $next_uid, "copied", $expunge); + } + } + + return null; + } + + /** + * Move the current Messages to a mailbox + * @param string $folder_path + * @param boolean $expunge + * + * @return Message|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function move(string $folder_path, bool $expunge = false): ?Message { + $this->client->openFolder($folder_path); + $status = $this->client->getConnection()->examineFolder($folder_path)->validatedData(); + + if (isset($status["uidnext"])) { + $next_uid = $status["uidnext"]; + if ((int)$next_uid <= 0) { + return null; + } + + /** @var Folder $folder */ + $folder = $this->client->getFolderByPath($folder_path); + + $this->client->openFolder($this->folder_path); + if ($this->client->getConnection()->moveMessage($folder->path, $this->getSequenceId(), null, $this->sequence)->validatedData()) { + return $this->fetchNewMail($folder, $next_uid, "moved", $expunge); + } + } + + return null; + } + + /** + * Fetch a new message and fire a given event + * @param Folder $folder + * @param int $next_uid + * @param string $event + * @param boolean $expunge + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetchNewMail(Folder $folder, int $next_uid, string $event, bool $expunge): Message { + if ($expunge) $this->client->expunge(); + + $this->client->openFolder($folder->path); + + if ($this->sequence === IMAP::ST_UID) { + $sequence_id = $next_uid; + } else { + $sequence_id = $this->client->getConnection()->getMessageNumber($next_uid)->validatedData(); + } + + $message = $folder->query()->getMessage($sequence_id, null, $this->sequence); + $event = $this->getEvent("message", $event); + $event::dispatch($this, $message); + + return $message; + } + + /** + * Delete the current Message + * @param bool $expunge + * @param string|null $trash_path + * @param boolean $force_move + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function delete(bool $expunge = true, string $trash_path = null, bool $force_move = false): bool { + $status = $this->setFlag("Deleted"); + if ($force_move) { + $trash_path = $trash_path === null ? $this->config["common_folders"]["trash"] : $trash_path; + $this->move($trash_path); + } + if ($expunge) $this->client->expunge(); + + $event = $this->getEvent("message", "deleted"); + $event::dispatch($this); + + return $status; + } + + /** + * Restore a deleted Message + * @param boolean $expunge + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function restore(bool $expunge = true): bool { + $status = $this->unsetFlag("Deleted"); + if ($expunge) $this->client->expunge(); + + $event = $this->getEvent("message", "restored"); + $event::dispatch($this); + + return $status; + } + + /** + * Set a given flag + * @param array|string $flag + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function setFlag(array|string $flag): bool { + $this->client->openFolder($this->folder_path); + $flag = "\\" . trim(is_array($flag) ? implode(" \\", $flag) : $flag); + $sequence_id = $this->getSequenceId(); + try { + $status = $this->client->getConnection()->store([$flag], $sequence_id, $sequence_id, "+", true, $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageFlagException("flag could not be set", 0, $e); + } + $this->parseFlags(); + + $event = $this->getEvent("flag", "new"); + $event::dispatch($this, $flag); + + return (bool)$status; + } + + /** + * Unset a given flag + * @param array|string $flag + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function unsetFlag(array|string $flag): bool { + $this->client->openFolder($this->folder_path); + + $flag = "\\" . trim(is_array($flag) ? implode(" \\", $flag) : $flag); + $sequence_id = $this->getSequenceId(); + try { + $status = $this->client->getConnection()->store([$flag], $sequence_id, $sequence_id, "-", true, $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageFlagException("flag could not be removed", 0, $e); + } + $this->parseFlags(); + + $event = $this->getEvent("flag", "deleted"); + $event::dispatch($this, $flag); + + return (bool)$status; + } + + /** + * Set a given flag + * @param array|string $flag + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function addFlag(array|string $flag): bool { + return $this->setFlag($flag); + } + + /** + * Unset a given flag + * @param array|string $flag + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException + */ + public function removeFlag(array|string $flag): bool { + return $this->unsetFlag($flag); + } + + /** + * Get all message attachments. + * + * @return AttachmentCollection + */ + public function getAttachments(): AttachmentCollection { + return $this->attachments; + } + + /** + * Get all message attachments. + * + * @return AttachmentCollection + */ + public function attachments(): AttachmentCollection { + return $this->getAttachments(); + } + + /** + * Checks if there are any attachments present + * + * @return boolean + */ + public function hasAttachments(): bool { + return $this->attachments->isEmpty() === false; + } + + /** + * Get the raw body + * + * @return string + */ + public function getRawBody(): string { + if ($this->raw_body === "") { + $this->raw_body = $this->structure->raw; + } + + return $this->raw_body; + } + + /** + * Get the message header + * + * @return ?Header + */ + public function getHeader(): ?Header { + return $this->header; + } + + /** + * Get the current client + * + * @return ?Client + */ + public function getClient(): ?Client { + return $this->client; + } + + /** + * Get the used fetch option + * + * @return ?integer + */ + public function getFetchOptions(): ?int { + return $this->fetch_options; + } + + /** + * Get the used fetch body option + * + * @return boolean + */ + public function getFetchBodyOption(): bool { + return $this->fetch_body; + } + + /** + * Get the used fetch flags option + * + * @return boolean + */ + public function getFetchFlagsOption(): bool { + return $this->fetch_flags; + } + + /** + * Get all available bodies + * + * @return array + */ + public function getBodies(): array { + return $this->bodies; + } + + /** + * Get all set flags + * + * @return FlagCollection + */ + public function getFlags(): FlagCollection { + return $this->flags; + } + + /** + * Get all set flags + * + * @return FlagCollection + */ + public function flags(): FlagCollection { + return $this->getFlags(); + } + + /** + * Check if a flag is set + * + * @param string $flag + * @return boolean + */ + public function hasFlag(string $flag): bool { + $flag = str_replace("\\", "", strtolower($flag)); + return $this->getFlags()->has($flag); + } + + /** + * Get the fetched structure + * + * @return Structure|null + */ + public function getStructure(): ?Structure { + return $this->structure; + } + + /** + * Check if a message matches another by comparing basic attributes + * + * @param null|Message $message + * @return boolean + */ + public function is(Message $message = null): bool { + if (is_null($message)) { + return false; + } + + return $this->uid == $message->uid + && $this->message_id->first() == $message->message_id->first() + && $this->subject->first() == $message->subject->first() + && $this->date->toDate()->eq($message->date->toDate()); + } + + /** + * Get all message attributes + * + * @return array + */ + public function getAttributes(): array { + return array_merge($this->attributes, $this->header->getAttributes()); + } + + /** + * Set the message mask + * @param $mask + * + * @return Message + */ + public function setMask($mask): Message { + if (class_exists($mask)) { + $this->mask = $mask; + } + + return $this; + } + + /** + * Get the used message mask + * + * @return string + */ + public function getMask(): string { + return $this->mask; + } + + /** + * Get a masked instance by providing a mask name + * @param mixed|null $mask + * + * @return mixed + * @throws MaskNotFoundException + */ + public function mask(mixed $mask = null): mixed { + $mask = $mask !== null ? $mask : $this->mask; + if (class_exists($mask)) { + return new $mask($this); + } + + throw new MaskNotFoundException("Unknown mask provided: " . $mask); + } + + /** + * Get the message path aka folder path + * + * @return string + */ + public function getFolderPath(): string { + return $this->folder_path; + } + + /** + * Set the message path aka folder path + * @param $folder_path + * + * @return Message + */ + public function setFolderPath($folder_path): Message { + $this->folder_path = $folder_path; + + return $this; + } + + /** + * Set the config + * @param array $config + * + * @return Message + */ + public function setConfig(array $config): Message { + $this->config = $config; + + return $this; + } + + /** + * Get the config + * + * @return array + */ + public function getConfig(): array { + return $this->config; + } + + /** + * Set the available flags + * @param $available_flags + * + * @return Message + */ + public function setAvailableFlags($available_flags): Message { + $this->available_flags = $available_flags; + + return $this; + } + + /** + * Get the available flags + * + * @return array + */ + public function getAvailableFlags(): array { + return $this->available_flags; + } + + /** + * Set the attachment collection + * @param $attachments + * + * @return Message + */ + public function setAttachments($attachments): Message { + $this->attachments = $attachments; + + return $this; + } + + /** + * Set the flag collection + * @param $flags + * + * @return Message + */ + public function setFlags($flags): Message { + $this->flags = $flags; + + return $this; + } + + /** + * Set the client + * @param $client + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function setClient($client): Message { + $this->client = $client; + $this->client?->openFolder($this->folder_path); + + return $this; + } + + /** + * Set the message number + * @param int $uid + * + * @return Message + */ + public function setUid(int $uid): Message { + $this->uid = $uid; + $this->msgn = null; + $this->msglist = null; + + return $this; + } + + /** + * Set the message number + * @param int $msgn + * @param int|null $msglist + * + * @return Message + */ + public function setMsgn(int $msgn, int $msglist = null): Message { + $this->msgn = $msgn; + $this->msglist = $msglist; + $this->uid = null; + + return $this; + } + + /** + * Get the current sequence type + * + * @return int + */ + public function getSequence(): int { + return $this->sequence; + } + + /** + * Get the current sequence id (either a UID or a message number!) + * + * @return int + */ + public function getSequenceId(): int { + return $this->sequence === IMAP::ST_UID ? $this->uid : $this->msgn; + } + + /** + * Set the sequence id + * @param $uid + * @param int|null $msglist + */ + public function setSequenceId($uid, int $msglist = null): void { + if ($this->getSequence() === IMAP::ST_UID) { + $this->setUid($uid); + $this->setMsglist($msglist); + } else { + $this->setMsgn($uid, $msglist); + } + } + + /** + * Safe the entire message in a file + * @param $filename + * + * @return bool|int + */ + public function save($filename): bool|int { + return file_put_contents($filename, $this->header->raw."\r\n\r\n".$this->structure->raw); + } +} diff --git a/plugins/php-imap/Part.php b/plugins/php-imap/Part.php new file mode 100644 index 00000000..1759b8de --- /dev/null +++ b/plugins/php-imap/Part.php @@ -0,0 +1,308 @@ +raw = $raw_part; + $this->header = $header; + $this->part_number = $part_number; + $this->parse(); + } + + /** + * Parse the raw parts + * + * @throws InvalidMessageDateException + */ + protected function parse(): void { + if ($this->header === null) { + $body = $this->findHeaders(); + }else{ + $body = $this->raw; + } + + $this->parseDisposition(); + $this->parseDescription(); + $this->parseEncoding(); + + $this->charset = $this->header->get("charset")->first(); + $this->name = $this->header->get("name"); + $this->filename = $this->header->get("filename"); + + if($this->header->get("id")->exist()) { + $this->id = $this->header->get("id"); + } else if($this->header->get("x_attachment_id")->exist()){ + $this->id = $this->header->get("x_attachment_id"); + } else if($this->header->get("content_id")->exist()){ + $this->id = strtr($this->header->get("content_id"), [ + '<' => '', + '>' => '' + ]); + } + + $content_types = $this->header->get("content_type")->all(); + if(!empty($content_types)){ + $this->subtype = $this->parseSubtype($content_types); + $content_type = $content_types[0]; + $parts = explode(';', $content_type); + $this->content_type = trim($parts[0]); + } + + $this->content = trim(rtrim($body)); + $this->bytes = strlen($this->content); + } + + /** + * Find all available headers and return the leftover body segment + * + * @return string + * @throws InvalidMessageDateException + */ + private function findHeaders(): string { + $body = $this->raw; + while (($pos = strpos($body, "\r\n")) > 0) { + $body = substr($body, $pos + 2); + } + $headers = substr($this->raw, 0, strlen($body) * -1); + $body = substr($body, 0, -2); + + $this->header = new Header($headers); + + return $body; + } + + /** + * Try to parse the subtype if any is present + * @param $content_type + * + * @return ?string + */ + private function parseSubtype($content_type): ?string { + if (is_array($content_type)) { + foreach ($content_type as $part){ + if ((strpos($part, "/")) !== false){ + return $this->parseSubtype($part); + } + } + return null; + } + if (($pos = strpos($content_type, "/")) !== false){ + return substr(explode(";", $content_type)[0], $pos + 1); + } + return null; + } + + /** + * Try to parse the disposition if any is present + */ + private function parseDisposition(): void { + $content_disposition = $this->header->get("content_disposition")->first(); + if($content_disposition) { + $this->ifdisposition = true; + $this->disposition = (is_array($content_disposition)) ? implode(' ', $content_disposition) : explode(";", $content_disposition)[0]; + } + } + + /** + * Try to parse the description if any is present + */ + private function parseDescription(): void { + $content_description = $this->header->get("content_description")->first(); + if($content_description) { + $this->ifdescription = true; + $this->description = $content_description; + } + } + + /** + * Try to parse the encoding if any is present + */ + private function parseEncoding(): void { + $encoding = $this->header->get("content_transfer_encoding")->first(); + if($encoding) { + $this->encoding = match (strtolower($encoding)) { + "quoted-printable" => IMAP::MESSAGE_ENC_QUOTED_PRINTABLE, + "base64" => IMAP::MESSAGE_ENC_BASE64, + "7bit" => IMAP::MESSAGE_ENC_7BIT, + "8bit" => IMAP::MESSAGE_ENC_8BIT, + "binary" => IMAP::MESSAGE_ENC_BINARY, + default => IMAP::MESSAGE_ENC_OTHER, + }; + } + } + + /** + * Check if the current part represents an attachment + * + * @return bool + */ + public function isAttachment(): bool { + $valid_disposition = in_array(strtolower($this->disposition ?? ''), ClientManager::get('options.dispositions')); + + if ($this->type == IMAP::MESSAGE_TYPE_TEXT && ($this->ifdisposition == 0 || empty($this->disposition) || !$valid_disposition)) { + if (($this->subtype == null || in_array((strtolower($this->subtype)), ["plain", "html"])) && $this->filename == null && $this->name == null) { + return false; + } + } + + if ($this->disposition === "inline" && $this->filename == null && $this->name == null && !$this->header->has("content_id")) { + return false; + } + return true; + } + + /** + * Get the part header + * + * @return Header|null + */ + public function getHeader(): ?Header { + return $this->header; + } + +} diff --git a/plugins/php-imap/Query/Query.php b/plugins/php-imap/Query/Query.php new file mode 100644 index 00000000..727e641f --- /dev/null +++ b/plugins/php-imap/Query/Query.php @@ -0,0 +1,1092 @@ +setClient($client); + + $this->sequence = ClientManager::get('options.sequence', IMAP::ST_MSGN); + if (ClientManager::get('options.fetch') === IMAP::FT_PEEK) $this->leaveUnread(); + + if (ClientManager::get('options.fetch_order') === 'desc') { + $this->fetch_order = 'desc'; + } else { + $this->fetch_order = 'asc'; + } + + $this->date_format = ClientManager::get('date_format', 'd M y'); + $this->soft_fail = ClientManager::get('options.soft_fail', false); + + $this->setExtensions($extensions); + $this->query = new Collection(); + $this->boot(); + } + + /** + * Instance boot method for additional functionality + */ + protected function boot(): void { + } + + /** + * Parse a given value + * @param mixed $value + * + * @return string + */ + protected function parse_value(mixed $value): string { + if ($value instanceof Carbon) { + $value = $value->format($this->date_format); + } + + return (string)$value; + } + + /** + * Check if a given date is a valid carbon object and if not try to convert it + * @param mixed $date + * + * @return Carbon + * @throws MessageSearchValidationException + */ + protected function parse_date(mixed $date): Carbon { + if ($date instanceof Carbon) return $date; + + try { + $date = Carbon::parse($date); + } catch (Exception) { + throw new MessageSearchValidationException(); + } + + return $date; + } + + /** + * Get the raw IMAP search query + * + * @return string + */ + public function generate_query(): string { + $query = ''; + $this->query->each(function($statement) use (&$query) { + if (count($statement) == 1) { + $query .= $statement[0]; + } else { + if ($statement[1] === null) { + $query .= $statement[0]; + } else { + if (is_numeric($statement[1])) { + $query .= $statement[0] . ' ' . $statement[1]; + } else { + $query .= $statement[0] . ' "' . $statement[1] . '"'; + } + } + } + $query .= ' '; + + }); + + $this->raw_query = trim($query); + + return $this->raw_query; + } + + /** + * Perform an imap search request + * + * @return Collection + * @throws GetMessagesFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + protected function search(): Collection { + $this->generate_query(); + + try { + $available_messages = $this->client->getConnection()->search([$this->getRawQuery()], $this->sequence)->validatedData(); + return new Collection($available_messages); + } catch (RuntimeException|ConnectionFailedException $e) { + throw new GetMessagesFailedException("failed to fetch messages", 0, $e); + } + } + + /** + * Count all available messages matching the current search criteria + * + * @return int + * @throws AuthFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + public function count(): int { + return $this->search()->count(); + } + + /** + * Fetch a given id collection + * @param Collection $available_messages + * + * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetch(Collection $available_messages): array { + if ($this->fetch_order === 'desc') { + $available_messages = $available_messages->reverse(); + } + + $uids = $available_messages->forPage($this->page, $this->limit)->toArray(); + $extensions = $this->getExtensions(); + if (empty($extensions) === false && method_exists($this->client->getConnection(), "fetch")) { + $extensions = $this->client->getConnection()->fetch($extensions, $uids, null, $this->sequence)->validatedData(); + } + $flags = $this->client->getConnection()->flags($uids, $this->sequence)->validatedData(); + $headers = $this->client->getConnection()->headers($uids, "RFC822", $this->sequence)->validatedData(); + + $contents = []; + if ($this->getFetchBody()) { + $contents = $this->client->getConnection()->content($uids, "RFC822", $this->sequence)->validatedData(); + } + + return [ + "uids" => $uids, + "flags" => $flags, + "headers" => $headers, + "contents" => $contents, + "extensions" => $extensions, + ]; + } + + /** + * Make a new message from given raw components + * @param integer $uid + * @param integer $msglist + * @param string $header + * @param string $content + * @param array $flags + * + * @return Message|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws ResponseException + */ + protected function make(int $uid, int $msglist, string $header, string $content, array $flags): ?Message { + try { + return Message::make($uid, $msglist, $this->getClient(), $header, $content, $flags, $this->getFetchOptions(), $this->sequence); + } catch (RuntimeException|MessageFlagException|InvalidMessageDateException|MessageContentFetchingException $e) { + $this->setError($uid, $e); + } + + $this->handleException($uid); + + return null; + } + + /** + * Get the message key for a given message + * @param string $message_key + * @param integer $msglist + * @param Message $message + * + * @return string + */ + protected function getMessageKey(string $message_key, int $msglist, Message $message): string { + $key = match ($message_key) { + 'number' => $message->getMessageNo(), + 'list' => $msglist, + 'uid' => $message->getUid(), + default => $message->getMessageId(), + }; + return (string)$key; + } + + /** + * Curates a given collection aof messages + * @param Collection $available_messages + * + * @return MessageCollection + * @throws GetMessagesFailedException + */ + public function curate_messages(Collection $available_messages): MessageCollection { + try { + if ($available_messages->count() > 0) { + return $this->populate($available_messages); + } + return MessageCollection::make([]); + } catch (Exception $e) { + throw new GetMessagesFailedException($e->getMessage(), 0, $e); + } + } + + /** + * Populate a given id collection and receive a fully fetched message collection + * @param Collection $available_messages + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws RuntimeException + * @throws ResponseException + */ + protected function populate(Collection $available_messages): MessageCollection { + $messages = MessageCollection::make([]); + + $messages->total($available_messages->count()); + + $message_key = ClientManager::get('options.message_key'); + + $raw_messages = $this->fetch($available_messages); + + $msglist = 0; + foreach ($raw_messages["headers"] as $uid => $header) { + $content = $raw_messages["contents"][$uid] ?? ""; + $flag = $raw_messages["flags"][$uid] ?? []; + $extensions = $raw_messages["extensions"][$uid] ?? []; + + $message = $this->make($uid, $msglist, $header, $content, $flag); + foreach ($extensions as $key => $extension) { + $message->getHeader()->set($key, $extension); + } + if ($message !== null) { + $key = $this->getMessageKey($message_key, $msglist, $message); + $messages->put("$key", $message); + } + $msglist++; + } + + return $messages; + } + + /** + * Fetch the current query and return all found messages + * + * @return MessageCollection + * @throws AuthFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + public function get(): MessageCollection { + return $this->curate_messages($this->search()); + } + + /** + * Fetch the current query as chunked requests + * @param callable $callback + * @param int $chunk_size + * @param int $start_chunk + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws RuntimeException + * @throws ResponseException + */ + public function chunked(callable $callback, int $chunk_size = 10, int $start_chunk = 1): void { + $available_messages = $this->search(); + if (($available_messages_count = $available_messages->count()) > 0) { + $old_limit = $this->limit; + $old_page = $this->page; + + $this->limit = $chunk_size; + $this->page = $start_chunk; + $handled_messages_count = 0; + do { + $messages = $this->populate($available_messages); + $handled_messages_count += $messages->count(); + $callback($messages, $this->page); + $this->page++; + } while ($handled_messages_count < $available_messages_count); + $this->limit = $old_limit; + $this->page = $old_page; + } + } + + /** + * Paginate the current query + * @param int $per_page Results you which to receive per page + * @param null $page The current page you are on (e.g. 0, 1, 2, ...) use `null` to enable auto mode + * @param string $page_name The page name / uri parameter used for the generated links and the auto mode + * + * @return LengthAwarePaginator + * @throws AuthFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + public function paginate(int $per_page = 5, $page = null, string $page_name = 'imap_page'): LengthAwarePaginator { + if ($page === null && isset($_GET[$page_name]) && $_GET[$page_name] > 0) { + $this->page = intval($_GET[$page_name]); + } elseif ($page > 0) { + $this->page = (int)$page; + } + + $this->limit = $per_page; + + return $this->get()->paginate($per_page, $this->page, $page_name, true); + } + + /** + * Get a new Message instance + * @param int $uid + * @param null $msglist + * @param null $sequence + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function getMessage(int $uid, $msglist = null, $sequence = null): Message { + return new Message($uid, $msglist, $this->getClient(), $this->getFetchOptions(), $this->getFetchBody(), $this->getFetchFlags(), $sequence ?: $this->sequence); + } + + /** + * Get a message by its message number + * @param $msgn + * @param null $msglist + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function getMessageByMsgn($msgn, $msglist = null): Message { + return $this->getMessage($msgn, $msglist, IMAP::ST_MSGN); + } + + /** + * Get a message by its uid + * @param $uid + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function getMessageByUid($uid): Message { + return $this->getMessage($uid, null, IMAP::ST_UID); + } + + /** + * Filter all available uids by a given closure and get a curated list of messages + * @param callable $closure + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function filter(callable $closure): MessageCollection { + $connection = $this->getClient()->getConnection(); + + $uids = $connection->getUid()->validatedData(); + $available_messages = new Collection(); + if (is_array($uids)) { + foreach ($uids as $id) { + if ($closure($id)) { + $available_messages->push($id); + } + } + } + + return $this->curate_messages($available_messages); + } + + /** + * Get all messages with an uid greater or equal to a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidGreaterOrEqual(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id >= $uid; + }); + } + + /** + * Get all messages with an uid greater than a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidGreater(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id > $uid; + }); + } + + /** + * Get all messages with an uid lower than a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidLower(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id < $uid; + }); + } + + /** + * Get all messages with an uid lower or equal to a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidLowerOrEqual(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id <= $uid; + }); + } + + /** + * Get all messages with an uid greater than a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidLowerThan(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id < $uid; + }); + } + + /** + * Don't mark messages as read when fetching + * + * @return $this + */ + public function leaveUnread(): Query { + $this->setFetchOptions(IMAP::FT_PEEK); + + return $this; + } + + /** + * Mark all messages as read when fetching + * + * @return $this + */ + public function markAsRead(): Query { + $this->setFetchOptions(IMAP::FT_UID); + + return $this; + } + + /** + * Set the sequence type + * @param int $sequence + * + * @return $this + */ + public function setSequence(int $sequence): Query { + $this->sequence = $sequence; + + return $this; + } + + /** + * Get the sequence type + * + * @return int|string + */ + public function getSequence(): int|string { + return $this->sequence; + } + + /** + * @return Client + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getClient(): Client { + $this->client->checkConnection(); + return $this->client; + } + + /** + * Set the limit and page for the current query + * @param int $limit + * @param int $page + * + * @return $this + */ + public function limit(int $limit, int $page = 1): Query { + if ($page >= 1) $this->page = $page; + $this->limit = $limit; + + return $this; + } + + /** + * Get the current query collection + * + * @return Collection + */ + public function getQuery(): Collection { + return $this->query; + } + + /** + * Set all query parameters + * @param array $query + * + * @return Query + */ + public function setQuery(array $query): Query { + $this->query = new Collection($query); + return $this; + } + + /** + * Get the raw query + * + * @return string + */ + public function getRawQuery(): string { + return $this->raw_query; + } + + /** + * Set the raw query + * @param string $raw_query + * + * @return Query + */ + public function setRawQuery(string $raw_query): Query { + $this->raw_query = $raw_query; + return $this; + } + + /** + * Get all applied extensions + * + * @return string[] + */ + public function getExtensions(): array { + return $this->extensions; + } + + /** + * Set all extensions that should be used + * @param string[] $extensions + * + * @return Query + */ + public function setExtensions(array $extensions): Query { + $this->extensions = $extensions; + if (count($this->extensions) > 0) { + if (in_array("UID", $this->extensions) === false) { + $this->extensions[] = "UID"; + } + } + return $this; + } + + /** + * Set the client instance + * @param Client $client + * + * @return Query + */ + public function setClient(Client $client): Query { + $this->client = $client; + return $this; + } + + /** + * Get the set fetch limit + * + * @return ?int + */ + public function getLimit(): ?int { + return $this->limit; + } + + /** + * Set the fetch limit + * @param int $limit + * + * @return Query + */ + public function setLimit(int $limit): Query { + $this->limit = $limit <= 0 ? null : $limit; + return $this; + } + + /** + * Get the set page + * + * @return int + */ + public function getPage(): int { + return $this->page; + } + + /** + * Set the page + * @param int $page + * + * @return Query + */ + public function setPage(int $page): Query { + $this->page = $page; + return $this; + } + + /** + * Set the fetch option flag + * @param int $fetch_options + * + * @return Query + */ + public function setFetchOptions(int $fetch_options): Query { + $this->fetch_options = $fetch_options; + return $this; + } + + /** + * Set the fetch option flag + * @param int $fetch_options + * + * @return Query + */ + public function fetchOptions(int $fetch_options): Query { + return $this->setFetchOptions($fetch_options); + } + + /** + * Get the fetch option flag + * + * @return ?int + */ + public function getFetchOptions(): ?int { + return $this->fetch_options; + } + + /** + * Get the fetch body flag + * + * @return boolean + */ + public function getFetchBody(): bool { + return $this->fetch_body; + } + + /** + * Set the fetch body flag + * @param boolean $fetch_body + * + * @return Query + */ + public function setFetchBody(bool $fetch_body): Query { + $this->fetch_body = $fetch_body; + return $this; + } + + /** + * Set the fetch body flag + * @param boolean $fetch_body + * + * @return Query + */ + public function fetchBody(bool $fetch_body): Query { + return $this->setFetchBody($fetch_body); + } + + /** + * Get the fetch body flag + * + * @return bool + */ + public function getFetchFlags(): bool { + return $this->fetch_flags; + } + + /** + * Set the fetch flag + * @param bool $fetch_flags + * + * @return Query + */ + public function setFetchFlags(bool $fetch_flags): Query { + $this->fetch_flags = $fetch_flags; + return $this; + } + + /** + * Set the fetch order + * @param string $fetch_order + * + * @return Query + */ + public function setFetchOrder(string $fetch_order): Query { + $fetch_order = strtolower($fetch_order); + + if (in_array($fetch_order, ['asc', 'desc'])) { + $this->fetch_order = $fetch_order; + } + + return $this; + } + + /** + * Set the fetch order + * @param string $fetch_order + * + * @return Query + */ + public function fetchOrder(string $fetch_order): Query { + return $this->setFetchOrder($fetch_order); + } + + /** + * Get the fetch order + * + * @return string + */ + public function getFetchOrder(): string { + return $this->fetch_order; + } + + /** + * Set the fetch order to ascending + * + * @return Query + */ + public function setFetchOrderAsc(): Query { + return $this->setFetchOrder('asc'); + } + + /** + * Set the fetch order to ascending + * + * @return Query + */ + public function fetchOrderAsc(): Query { + return $this->setFetchOrderAsc(); + } + + /** + * Set the fetch order to descending + * + * @return Query + */ + public function setFetchOrderDesc(): Query { + return $this->setFetchOrder('desc'); + } + + /** + * Set the fetch order to descending + * + * @return Query + */ + public function fetchOrderDesc(): Query { + return $this->setFetchOrderDesc(); + } + + /** + * Set soft fail mode + * @var boolean $state + * + * @return Query + */ + public function softFail(bool $state = true): Query { + return $this->setSoftFail($state); + } + + /** + * Set soft fail mode + * + * @var boolean $state + * @return Query + */ + public function setSoftFail(bool $state = true): Query { + $this->soft_fail = $state; + + return $this; + } + + /** + * Get soft fail mode + * + * @return boolean + */ + public function getSoftFail(): bool { + return $this->soft_fail; + } + + /** + * Handle the exception for a given uid + * @param integer $uid + * + * @throws GetMessagesFailedException + */ + protected function handleException(int $uid): void { + if ($this->soft_fail === false && $this->hasError($uid)) { + $error = $this->getError($uid); + throw new GetMessagesFailedException($error->getMessage(), 0, $error); + } + } + + /** + * Add a new error to the error holder + * @param integer $uid + * @param Exception $error + */ + protected function setError(int $uid, Exception $error): void { + $this->errors[$uid] = $error; + } + + /** + * Check if there are any errors / exceptions present + * @var ?integer $uid + * + * @return boolean + */ + public function hasErrors(?int $uid = null): bool { + if ($uid !== null) { + return $this->hasError($uid); + } + return count($this->errors) > 0; + } + + /** + * Check if there is an error / exception present + * @var integer $uid + * + * @return boolean + */ + public function hasError(int $uid): bool { + return isset($this->errors[$uid]); + } + + /** + * Get all available errors / exceptions + * + * @return array + */ + public function errors(): array { + return $this->getErrors(); + } + + /** + * Get all available errors / exceptions + * + * @return array + */ + public function getErrors(): array { + return $this->errors; + } + + /** + * Get a specific error / exception + * @var integer $uid + * + * @return Exception|null + */ + public function error(int $uid): ?Exception { + return $this->getError($uid); + } + + /** + * Get a specific error / exception + * @var integer $uid + * + * @return ?Exception + */ + public function getError(int $uid): ?Exception { + if ($this->hasError($uid)) { + return $this->errors[$uid]; + } + return null; + } +} diff --git a/plugins/php-imap/Query/WhereQuery.php b/plugins/php-imap/Query/WhereQuery.php new file mode 100755 index 00000000..b218e545 --- /dev/null +++ b/plugins/php-imap/Query/WhereQuery.php @@ -0,0 +1,555 @@ +whereNot(); + $name = substr($name, 3); + } + + if (!str_contains(strtolower($name), "where")) { + $method = 'where' . ucfirst($name); + } else { + $method = lcfirst($name); + } + + if (method_exists($this, $method) === true) { + return call_user_func_array([$that, $method], $arguments); + } + + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); + } + + /** + * Validate a given criteria + * @param $criteria + * + * @return string + * @throws InvalidWhereQueryCriteriaException + */ + protected function validate_criteria($criteria): string { + $command = strtoupper($criteria); + if (str_starts_with($command, "CUSTOM ")) { + return substr($criteria, 7); + } + if (in_array($command, $this->available_criteria) === false) { + throw new InvalidWhereQueryCriteriaException("Invalid imap search criteria: $command"); + } + + return $criteria; + } + + /** + * Register search parameters + * @param mixed $criteria + * @param mixed $value + * + * @return $this + * @throws InvalidWhereQueryCriteriaException + * + * Examples: + * $query->from("someone@email.tld")->seen(); + * $query->whereFrom("someone@email.tld")->whereSeen(); + * $query->where([["FROM" => "someone@email.tld"], ["SEEN"]]); + * $query->where(["FROM" => "someone@email.tld"])->where(["SEEN"]); + * $query->where(["FROM" => "someone@email.tld", "SEEN"]); + * $query->where("FROM", "someone@email.tld")->where("SEEN"); + */ + public function where(mixed $criteria, mixed $value = null): WhereQuery { + if (is_array($criteria)) { + foreach ($criteria as $key => $value) { + if (is_numeric($key)) { + $this->where($value); + }else{ + $this->where($key, $value); + } + } + } else { + $this->push_search_criteria($criteria, $value); + } + + return $this; + } + + /** + * Push a given search criteria and value pair to the search query + * @param $criteria string + * @param $value mixed + * + * @throws InvalidWhereQueryCriteriaException + */ + protected function push_search_criteria(string $criteria, mixed $value){ + $criteria = $this->validate_criteria($criteria); + $value = $this->parse_value($value); + + if ($value === '') { + $this->query->push([$criteria]); + } else { + $this->query->push([$criteria, $value]); + } + } + + /** + * @param Closure|null $closure + * + * @return $this + */ + public function orWhere(Closure $closure = null): WhereQuery { + $this->query->push(['OR']); + if ($closure !== null) $closure($this); + + return $this; + } + + /** + * @param Closure|null $closure + * + * @return $this + */ + public function andWhere(Closure $closure = null): WhereQuery { + $this->query->push(['AND']); + if ($closure !== null) $closure($this); + + return $this; + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereAll(): WhereQuery { + return $this->where('ALL'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereAnswered(): WhereQuery { + return $this->where('ANSWERED'); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereBcc(string $value): WhereQuery { + return $this->where('BCC', $value); + } + + /** + * @param mixed $value + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + * @throws MessageSearchValidationException + */ + public function whereBefore(mixed $value): WhereQuery { + $date = $this->parse_date($value); + return $this->where('BEFORE', $date); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereBody(string $value): WhereQuery { + return $this->where('BODY', $value); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereCc(string $value): WhereQuery { + return $this->where('CC', $value); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereDeleted(): WhereQuery { + return $this->where('DELETED'); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereFlagged(string $value): WhereQuery { + return $this->where('FLAGGED', $value); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereFrom(string $value): WhereQuery { + return $this->where('FROM', $value); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereKeyword(string $value): WhereQuery { + return $this->where('KEYWORD', $value); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereNew(): WhereQuery { + return $this->where('NEW'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereNot(): WhereQuery { + return $this->where('NOT'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereOld(): WhereQuery { + return $this->where('OLD'); + } + + /** + * @param mixed $value + * + * @return WhereQuery + * @throws MessageSearchValidationException + * @throws InvalidWhereQueryCriteriaException + */ + public function whereOn(mixed $value): WhereQuery { + $date = $this->parse_date($value); + return $this->where('ON', $date); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereRecent(): WhereQuery { + return $this->where('RECENT'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereSeen(): WhereQuery { + return $this->where('SEEN'); + } + + /** + * @param mixed $value + * + * @return WhereQuery + * @throws MessageSearchValidationException + * @throws InvalidWhereQueryCriteriaException + */ + public function whereSince(mixed $value): WhereQuery { + $date = $this->parse_date($value); + return $this->where('SINCE', $date); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereSubject(string $value): WhereQuery { + return $this->where('SUBJECT', $value); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereText(string $value): WhereQuery { + return $this->where('TEXT', $value); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereTo(string $value): WhereQuery { + return $this->where('TO', $value); + } + + /** + * @param string $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUnkeyword(string $value): WhereQuery { + return $this->where('UNKEYWORD', $value); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUnanswered(): WhereQuery { + return $this->where('UNANSWERED'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUndeleted(): WhereQuery { + return $this->where('UNDELETED'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUnflagged(): WhereQuery { + return $this->where('UNFLAGGED'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUnseen(): WhereQuery { + return $this->where('UNSEEN'); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereNoXSpam(): WhereQuery { + return $this->where("CUSTOM X-Spam-Flag NO"); + } + + /** + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereIsXSpam(): WhereQuery { + return $this->where("CUSTOM X-Spam-Flag YES"); + } + + /** + * Search for a specific header value + * @param $header + * @param $value + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereHeader($header, $value): WhereQuery { + return $this->where("CUSTOM HEADER $header $value"); + } + + /** + * Search for a specific message id + * @param $messageId + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereMessageId($messageId): WhereQuery { + return $this->whereHeader("Message-ID", $messageId); + } + + /** + * Search for a specific message id + * @param $messageId + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereInReplyTo($messageId): WhereQuery { + return $this->whereHeader("In-Reply-To", $messageId); + } + + /** + * @param $country_code + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereLanguage($country_code): WhereQuery { + return $this->where("Content-Language $country_code"); + } + + /** + * Get message be it UID. + * + * @param int|string $uid + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUid(int|string $uid): WhereQuery { + return $this->where('UID', $uid); + } + + /** + * Get messages by their UIDs. + * + * @param array $uids + * + * @return WhereQuery + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUidIn(array $uids): WhereQuery { + $uids = implode(',', $uids); + return $this->where('UID', $uids); + } + + /** + * Apply the callback if the given "value" is truthy. + * copied from @url https://github.com/laravel/framework/blob/8.x/src/Illuminate/Support/Traits/Conditionable.php + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|null + */ + public function when(mixed $value, callable $callback, ?callable $default = null): mixed { + if ($value) { + return $callback($this, $value) ?: $this; + } elseif ($default) { + return $default($this, $value) ?: $this; + } + + return $this; + } + + /** + * Apply the callback if the given "value" is falsy. + * copied from @url https://github.com/laravel/framework/blob/8.x/src/Illuminate/Support/Traits/Conditionable.php + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function unless(mixed $value, callable $callback, ?callable $default = null): mixed { + if (!$value) { + return $callback($this, $value) ?: $this; + } elseif ($default) { + return $default($this, $value) ?: $this; + } + + return $this; + } + + /** + * Get all available search criteria + * + * @return array|string[] + */ + public function getAvailableCriteria(): array { + return $this->available_criteria; + } +} \ No newline at end of file diff --git a/plugins/php-imap/Structure.php b/plugins/php-imap/Structure.php new file mode 100644 index 00000000..745a6234 --- /dev/null +++ b/plugins/php-imap/Structure.php @@ -0,0 +1,164 @@ +raw = $raw_structure; + $this->header = $header; + $this->config = ClientManager::get('options'); + $this->parse(); + } + + /** + * Parse the given raw structure + * + * @throws MessageContentFetchingException + * @throws InvalidMessageDateException + */ + protected function parse(): void { + $this->findContentType(); + $this->parts = $this->find_parts(); + } + + /** + * Determine the message content type + */ + public function findContentType(): void { + $content_type = $this->header->get("content_type")->first(); + if($content_type && stripos($content_type, 'multipart') === 0) { + $this->type = IMAP::MESSAGE_TYPE_MULTIPART; + }else{ + $this->type = IMAP::MESSAGE_TYPE_TEXT; + } + } + + /** + * Find all available headers and return the leftover body segment + * @var string $context + * @var integer $part_number + * + * @return Part[] + * @throws InvalidMessageDateException + */ + private function parsePart(string $context, int $part_number = 0): array { + $body = $context; + while (($pos = strpos($body, "\r\n")) > 0) { + $body = substr($body, $pos + 2); + } + $headers = substr($context, 0, strlen($body) * -1); + $body = substr($body, 0, -2); + + $headers = new Header($headers); + if (($boundary = $headers->getBoundary()) !== null) { + return $this->detectParts($boundary, $body, $part_number); + } + + return [new Part($body, $headers, $part_number)]; + } + + /** + * @param string $boundary + * @param string $context + * @param int $part_number + * + * @return array + * @throws InvalidMessageDateException + */ + private function detectParts(string $boundary, string $context, int $part_number = 0): array { + $base_parts = explode( $boundary, $context); + $final_parts = []; + foreach($base_parts as $ctx) { + $ctx = substr($ctx, 2); + if ($ctx !== "--" && $ctx != "" && $ctx != "\r\n") { + $parts = $this->parsePart($ctx, $part_number); + foreach ($parts as $part) { + $final_parts[] = $part; + $part_number = $part->part_number; + } + $part_number++; + } + } + return $final_parts; + } + + /** + * Find all available parts + * + * @return array + * @throws MessageContentFetchingException + * @throws InvalidMessageDateException + */ + public function find_parts(): array { + if($this->type === IMAP::MESSAGE_TYPE_MULTIPART) { + if (($boundary = $this->header->getBoundary()) === null) { + throw new MessageContentFetchingException("no content found", 0); + } + + return $this->detectParts($boundary, $this->raw); + } + + return [new Part($this->raw, $this->header)]; + } +} diff --git a/plugins/php-imap/Support/AttachmentCollection.php b/plugins/php-imap/Support/AttachmentCollection.php new file mode 100644 index 00000000..9d2af20d --- /dev/null +++ b/plugins/php-imap/Support/AttachmentCollection.php @@ -0,0 +1,26 @@ + + */ +class AttachmentCollection extends PaginatedCollection { + +} \ No newline at end of file diff --git a/plugins/php-imap/Support/FlagCollection.php b/plugins/php-imap/Support/FlagCollection.php new file mode 100644 index 00000000..b8bf352c --- /dev/null +++ b/plugins/php-imap/Support/FlagCollection.php @@ -0,0 +1,25 @@ + + */ +class FlagCollection extends PaginatedCollection { + +} \ No newline at end of file diff --git a/plugins/php-imap/Support/FolderCollection.php b/plugins/php-imap/Support/FolderCollection.php new file mode 100644 index 00000000..212ddb0a --- /dev/null +++ b/plugins/php-imap/Support/FolderCollection.php @@ -0,0 +1,26 @@ + + */ +class FolderCollection extends PaginatedCollection { + +} \ No newline at end of file diff --git a/plugins/php-imap/Support/Masks/AttachmentMask.php b/plugins/php-imap/Support/Masks/AttachmentMask.php new file mode 100644 index 00000000..d79b948a --- /dev/null +++ b/plugins/php-imap/Support/Masks/AttachmentMask.php @@ -0,0 +1,44 @@ +parent->content); + } + + /** + * Get a base64 image src string + * + * @return string|null + */ + public function getImageSrc(): ?string { + return 'data:'.$this->parent->content_type.';base64,'.$this->getContentBase64Encoded(); + } +} \ No newline at end of file diff --git a/plugins/php-imap/Support/Masks/Mask.php b/plugins/php-imap/Support/Masks/Mask.php new file mode 100755 index 00000000..2101f574 --- /dev/null +++ b/plugins/php-imap/Support/Masks/Mask.php @@ -0,0 +1,137 @@ +parent = $parent; + + if(method_exists($this->parent, 'getAttributes')){ + $this->attributes = array_merge($this->attributes, $this->parent->getAttributes()); + } + + $this->boot(); + } + + /** + * Boot method made to be used by any custom mask + */ + protected function boot(): void {} + + /** + * Call dynamic attribute setter and getter methods and inherit the parent calls + * @param string $method + * @param array $arguments + * + * @return mixed + * @throws MethodNotFoundException + */ + public function __call(string $method, array $arguments) { + if(strtolower(substr($method, 0, 3)) === 'get') { + $name = Str::snake(substr($method, 3)); + + if(isset($this->attributes[$name])) { + return $this->attributes[$name]; + } + + }elseif (strtolower(substr($method, 0, 3)) === 'set') { + $name = Str::snake(substr($method, 3)); + + if(isset($this->attributes[$name])) { + $this->attributes[$name] = array_pop($arguments); + + return $this->attributes[$name]; + } + + } + + if(method_exists($this->parent, $method) === true){ + return call_user_func_array([$this->parent, $method], $arguments); + } + + throw new MethodNotFoundException("Method ".self::class.'::'.$method.'() is not supported'); + } + + /** + * Magic setter + * @param $name + * @param $value + * + * @return mixed + */ + public function __set($name, $value) { + $this->attributes[$name] = $value; + + return $this->attributes[$name]; + } + + /** + * Magic getter + * @param $name + * + * @return mixed|null + */ + public function __get($name) { + if(isset($this->attributes[$name])) { + return $this->attributes[$name]; + } + + return null; + } + + /** + * Get the parent instance + * + * @return mixed + */ + public function getParent(): mixed { + return $this->parent; + } + + /** + * Get all available attributes + * + * @return array + */ + public function getAttributes(): array { + return $this->attributes; + } + +} \ No newline at end of file diff --git a/plugins/php-imap/Support/Masks/MessageMask.php b/plugins/php-imap/Support/Masks/MessageMask.php new file mode 100644 index 00000000..4cc3d5c0 --- /dev/null +++ b/plugins/php-imap/Support/Masks/MessageMask.php @@ -0,0 +1,86 @@ +parent->getBodies(); + if (!isset($bodies['html'])) { + return null; + } + + if(is_object($bodies['html']) && property_exists($bodies['html'], 'content')) { + return $bodies['html']->content; + } + return $bodies['html']; + } + + /** + * Get the Message html body filtered by an optional callback + * @param callable|null $callback + * + * @return string|null + */ + public function getCustomHTMLBody(?callable $callback = null): ?string { + $body = $this->getHtmlBody(); + if($body === null) return null; + + if ($callback !== null) { + $aAttachment = $this->parent->getAttachments(); + $aAttachment->each(function($oAttachment) use(&$body, $callback) { + /** @var Attachment $oAttachment */ + if(is_callable($callback)) { + $body = $callback($body, $oAttachment); + }elseif(is_string($callback)) { + call_user_func($callback, [$body, $oAttachment]); + } + }); + } + + return $body; + } + + /** + * Get the Message html body with embedded base64 images + * the resulting $body. + * + * @return string|null + */ + public function getHTMLBodyWithEmbeddedBase64Images(): ?string { + return $this->getCustomHTMLBody(function($body, $oAttachment){ + /** @var Attachment $oAttachment */ + if ($oAttachment->id) { + $body = str_replace('cid:'.$oAttachment->id, 'data:'.$oAttachment->getContentType().';base64, '.base64_encode($oAttachment->getContent()), $body); + } + + return $body; + }); + } +} \ No newline at end of file diff --git a/plugins/php-imap/Support/MessageCollection.php b/plugins/php-imap/Support/MessageCollection.php new file mode 100644 index 00000000..1ca97a70 --- /dev/null +++ b/plugins/php-imap/Support/MessageCollection.php @@ -0,0 +1,26 @@ + + */ +class MessageCollection extends PaginatedCollection { + +} diff --git a/plugins/php-imap/Support/PaginatedCollection.php b/plugins/php-imap/Support/PaginatedCollection.php new file mode 100644 index 00000000..3b3470e9 --- /dev/null +++ b/plugins/php-imap/Support/PaginatedCollection.php @@ -0,0 +1,82 @@ +total ?: $this->count(); + + $results = !$prepaginated && $total ? $this->forPage($page, $per_page)->toArray() : $this->all(); + + return $this->paginator($results, $total, $per_page, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $page_name, + ]); + } + + /** + * Create a new length-aware paginator instance. + * @param array $items + * @param int $total + * @param int $per_page + * @param int|null $current_page + * @param array $options + * + * @return LengthAwarePaginator + */ + protected function paginator(array $items, int $total, int $per_page, ?int $current_page, array $options): LengthAwarePaginator { + return new LengthAwarePaginator($items, $total, $per_page, $current_page, $options); + } + + /** + * Get and set the total amount + * @param null $total + * + * @return int|null + */ + public function total($total = null): ?int { + if($total === null) { + return $this->total; + } + + return $this->total = $total; + } +} diff --git a/plugins/php-imap/Traits/HasEvents.php b/plugins/php-imap/Traits/HasEvents.php new file mode 100644 index 00000000..3b6902ed --- /dev/null +++ b/plugins/php-imap/Traits/HasEvents.php @@ -0,0 +1,77 @@ +events[$section])) { + $this->events[$section][$event] = $class; + } + } + + /** + * Set all events + * @param array $events + */ + public function setEvents(array $events): void { + $this->events = $events; + } + + /** + * Get a specific event callback + * @param string $section + * @param string $event + * + * @return Event|string + * @throws EventNotFoundException + */ + public function getEvent(string $section, string $event): Event|string { + if (isset($this->events[$section])) { + return $this->events[$section][$event]; + } + throw new EventNotFoundException(); + } + + /** + * Get all events + * + * @return array + */ + public function getEvents(): array { + return $this->events; + } + +} \ No newline at end of file diff --git a/plugins/php-imap/VERSION b/plugins/php-imap/VERSION new file mode 100644 index 00000000..c7ba1e87 --- /dev/null +++ b/plugins/php-imap/VERSION @@ -0,0 +1 @@ +5.5.0 \ No newline at end of file diff --git a/plugins/php-imap/config/imap.php b/plugins/php-imap/config/imap.php new file mode 100644 index 00000000..590d27cb --- /dev/null +++ b/plugins/php-imap/config/imap.php @@ -0,0 +1,226 @@ + 'd-M-Y', + + /* + |-------------------------------------------------------------------------- + | Default account + |-------------------------------------------------------------------------- + | + | The default account identifier. It will be used as default for any missing account parameters. + | If however the default account is missing a parameter the package default will be used. + | Set to 'false' [boolean] to disable this functionality. + | + */ + 'default' => 'default', + + /* + |-------------------------------------------------------------------------- + | Available accounts + |-------------------------------------------------------------------------- + | + | Please list all IMAP accounts which you are planning to use within the + | array below. + | + */ + 'accounts' => [ + + 'default' => [// account identifier + 'host' => 'localhost', + 'port' => 993, + 'protocol' => 'imap', //might also use imap, [pop3 or nntp (untested)] + 'encryption' => 'ssl', // Supported: false, 'ssl', 'tls' + 'validate_cert' => true, + 'username' => 'root@example.com', + 'password' => '', + 'authentication' => null, + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + "timeout" => 30, + "extensions" => [] + ], + + /* + 'gmail' => [ // account identifier + 'host' => 'imap.gmail.com', + 'port' => 993, + 'encryption' => 'ssl', + 'validate_cert' => true, + 'username' => 'example@gmail.com', + 'password' => 'PASSWORD', + 'authentication' => 'oauth', + ], + + 'another' => [ // account identifier + 'host' => '', + 'port' => 993, + 'encryption' => false, + 'validate_cert' => true, + 'username' => '', + 'password' => '', + 'authentication' => null, + ] + */ + ], + + /* + |-------------------------------------------------------------------------- + | Available IMAP options + |-------------------------------------------------------------------------- + | + | Available php imap config parameters are listed below + | -Delimiter (optional): + | This option is only used when calling $oClient-> + | You can use any supported char such as ".", "/", (...) + | -Fetch option: + | IMAP::FT_UID - Message marked as read by fetching the body message + | IMAP::FT_PEEK - Fetch the message without setting the "seen" flag + | -Fetch sequence id: + | IMAP::ST_UID - Fetch message components using the message uid + | IMAP::ST_MSGN - Fetch message components using the message number + | -Body download option + | Default TRUE + | -Flag download option + | Default TRUE + | -Soft fail + | Default FALSE - Set to TRUE if you want to ignore certain exception while fetching bulk messages + | -RFC822 + | Default TRUE - Set to FALSE to prevent the usage of \imap_rfc822_parse_headers(). + | See https://github.com/Webklex/php-imap/issues/115 for more information. + | -Debug enable to trace communication traffic + | -UID cache enable the UID cache + | -Fallback date is used if the given message date could not be parsed + | -Boundary regex used to detect message boundaries. If you are having problems with empty messages, missing + | attachments or anything like this. Be advised that it likes to break which causes new problems.. + | -Message key identifier option + | You can choose between the following: + | 'id' - Use the MessageID as array key (default, might cause hickups with yahoo mail) + | 'number' - Use the message number as array key (isn't always unique and can cause some interesting behavior) + | 'list' - Use the message list number as array key (incrementing integer (does not always start at 0 or 1) + | 'uid' - Use the message uid as array key (isn't always unique and can cause some interesting behavior) + | -Fetch order + | 'asc' - Order all messages ascending (probably results in oldest first) + | 'desc' - Order all messages descending (probably results in newest first) + | -Disposition types potentially considered an attachment + | Default ['attachment', 'inline'] + | -Common folders + | Default folder locations and paths assumed if none is provided + | -Open IMAP options: + | DISABLE_AUTHENTICATOR - Disable authentication properties. + | Use 'GSSAPI' if you encounter the following + | error: "Kerberos error: No credentials cache + | file found (try running kinit) (...)" + | or ['GSSAPI','PLAIN'] if you are using outlook mail + | -Decoder options (currently only the message subject and attachment name decoder can be set) + | 'utf-8' - Uses imap_utf8($string) to decode a string + | 'mimeheader' - Uses mb_decode_mimeheader($string) to decode a string + | + */ + 'options' => [ + 'delimiter' => '/', + 'fetch' => \Webklex\PHPIMAP\IMAP::FT_PEEK, + 'sequence' => \Webklex\PHPIMAP\IMAP::ST_UID, + 'fetch_body' => true, + 'fetch_flags' => true, + 'soft_fail' => false, + 'rfc822' => true, + 'debug' => false, + 'uid_cache' => true, + // 'fallback_date' => "01.01.1970 00:00:00", + 'boundary' => '/boundary=(.*?(?=;)|(.*))/i', + 'message_key' => 'list', + 'fetch_order' => 'asc', + 'dispositions' => ['attachment', 'inline'], + 'common_folders' => [ + "root" => "INBOX", + "junk" => "INBOX/Junk", + "draft" => "INBOX/Drafts", + "sent" => "INBOX/Sent", + "trash" => "INBOX/Trash", + ], + 'decoder' => [ + 'message' => 'utf-8', // mimeheader + 'attachment' => 'utf-8' // mimeheader + ], + 'open' => [ + // 'DISABLE_AUTHENTICATOR' => 'GSSAPI' + ] + ], + + /* + |-------------------------------------------------------------------------- + | Available flags + |-------------------------------------------------------------------------- + | + | List all available / supported flags. Set to null to accept all given flags. + */ + 'flags' => ['recent', 'flagged', 'answered', 'deleted', 'seen', 'draft'], + + /* + |-------------------------------------------------------------------------- + | Available events + |-------------------------------------------------------------------------- + | + */ + 'events' => [ + "message" => [ + 'new' => \Webklex\PHPIMAP\Events\MessageNewEvent::class, + 'moved' => \Webklex\PHPIMAP\Events\MessageMovedEvent::class, + 'copied' => \Webklex\PHPIMAP\Events\MessageCopiedEvent::class, + 'deleted' => \Webklex\PHPIMAP\Events\MessageDeletedEvent::class, + 'restored' => \Webklex\PHPIMAP\Events\MessageRestoredEvent::class, + ], + "folder" => [ + 'new' => \Webklex\PHPIMAP\Events\FolderNewEvent::class, + 'moved' => \Webklex\PHPIMAP\Events\FolderMovedEvent::class, + 'deleted' => \Webklex\PHPIMAP\Events\FolderDeletedEvent::class, + ], + "flag" => [ + 'new' => \Webklex\PHPIMAP\Events\FlagNewEvent::class, + 'deleted' => \Webklex\PHPIMAP\Events\FlagDeletedEvent::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Available masking options + |-------------------------------------------------------------------------- + | + | By using your own custom masks you can implement your own methods for + | a better and faster access and less code to write. + | + | Checkout the two examples custom_attachment_mask and custom_message_mask + | for a quick start. + | + | The provided masks below are used as the default masks. + */ + 'masks' => [ + 'message' => \Webklex\PHPIMAP\Support\Masks\MessageMask::class, + 'attachment' => \Webklex\PHPIMAP\Support\Masks\AttachmentMask::class + ] +];