Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F15398265
D4845.id13878.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Size
173 KB
Referenced Files
None
Subscribers
None
D4845.id13878.diff
View Options
diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php
--- a/src/app/Backends/DAV/Vevent.php
+++ b/src/app/Backends/DAV/Vevent.php
@@ -30,6 +30,8 @@
public $lastModified;
public $dtstamp;
+ private $vobject;
+
/**
* Create event object from a DOMElement element
@@ -58,15 +60,15 @@
protected function fromIcal(string $ical): void
{
$options = VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES;
- $vobject = VObject\Reader::read($ical, $options);
+ $this->vobject = VObject\Reader::read($ical, $options);
- if ($vobject->name != 'VCALENDAR') {
+ if ($this->vobject->name != 'VCALENDAR') {
return;
}
$selfType = strtoupper(class_basename(get_class($this)));
- foreach ($vobject->getComponents() as $component) {
+ foreach ($this->vobject->getComponents() as $component) {
if ($component->name == $selfType) {
$this->fromVObject($component);
return;
@@ -278,7 +280,10 @@
*/
public function __toString()
{
- // TODO: This will be needed when we want to create/update objects
- return '';
+ if (!$this->vobject) {
+ //TODO we currently can only serialize a message back that we just read
+ throw new \Exception("Writing from properties is not implemented");
+ }
+ return VObject\Writer::write($this->vobject);
}
}
diff --git a/src/app/Console/Commands/Data/MigrateCommand.php b/src/app/Console/Commands/Data/MigrateCommand.php
--- a/src/app/Console/Commands/Data/MigrateCommand.php
+++ b/src/app/Console/Commands/Data/MigrateCommand.php
@@ -16,6 +16,8 @@
* "ews://$user:$pass@$server?client_id=$client_id&client_secret=$client_secret&tenant_id=$tenant_id" \
* "dav://$dest_user:$dest_pass@$dest_server"
* ```
+ *
+ * Supported account types: ews, dav (davs), imap (tls, ssl, imaps)
*/
class MigrateCommand extends Command
{
@@ -28,6 +30,7 @@
{src : Source account}
{dst : Destination account}
{--type= : Object type(s)}
+ {--sync : Execute migration synchronously}
{--force : Force existing queue removal}';
// {--export-only : Only export data}
// {--import-only : Only import previously exported data}';
@@ -51,6 +54,7 @@
$options = [
'type' => $this->option('type'),
'force' => $this->option('force'),
+ 'sync' => $this->option('sync'),
'stdout' => true,
];
diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php
--- a/src/app/DataMigrator/Account.php
+++ b/src/app/DataMigrator/Account.php
@@ -31,6 +31,9 @@
/** @var array Additional parameters from the input */
public $params;
+ /** @var ?int Port number */
+ public $port;
+
/** @var string Full account definition */
protected $input;
@@ -69,9 +72,15 @@
$this->scheme = strtolower($url['scheme']);
}
+ if (isset($url['port'])) {
+ $this->port = $url['port'];
+ }
+
if (isset($url['host'])) {
$this->host = $url['host'];
- $this->uri = $this->scheme . '://' . $url['host'] . ($url['path'] ?? '');
+ $this->uri = $this->scheme . '://' . $url['host']
+ . ($this->port ? ":{$this->port}" : null)
+ . ($url['path'] ?? '');
}
if (!empty($url['query'])) {
diff --git a/src/app/DataMigrator/DAV.php b/src/app/DataMigrator/DAV.php
--- a/src/app/DataMigrator/DAV.php
+++ b/src/app/DataMigrator/DAV.php
@@ -7,11 +7,17 @@
use App\Backends\DAV\Folder as DAVFolder;
use App\Backends\DAV\Search as DAVSearch;
use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\ExporterInterface;
+use App\DataMigrator\Interface\ImporterInterface;
use App\DataMigrator\Interface\Item;
+use App\DataMigrator\Interface\ItemSet;
use App\Utils;
-class DAV implements Interface\ImporterInterface
+class DAV implements ExporterInterface, ImporterInterface
{
+ /** @const int Max number of items to migrate in one go */
+ protected const CHUNK_SIZE = 25;
+
/** @var DAVClient DAV Backend */
protected $client;
@@ -21,9 +27,6 @@
/** @var Engine Data migrator engine */
protected $engine;
- /** @var array Settings */
- protected $settings;
-
/**
* Object constructor
@@ -34,12 +37,6 @@
$baseUri = rtrim($account->uri, '/');
$baseUri = preg_replace('|^dav|', 'http', $baseUri);
- $this->settings = [
- 'baseUri' => $baseUri,
- 'userName' => $username,
- 'password' => $account->password,
- ];
-
$this->client = new DAVClient($username, $account->password, $baseUri);
$this->engine = $engine;
$this->account = $account;
@@ -50,10 +47,10 @@
*
* @throws \Exception
*/
- public function authenticate()
+ public function authenticate(): void
{
try {
- $result = $this->client->options();
+ $this->client->options();
} catch (\Exception $e) {
throw new \Exception("Invalid DAV credentials or server.");
}
@@ -146,6 +143,87 @@
}
}
+ /**
+ * Fetching an item
+ */
+ public function fetchItem(Item $item): void
+ {
+ // Save the item content to a file
+ $location = $item->folder->location;
+
+ if (!file_exists($location)) {
+ mkdir($location, 0740, true);
+ }
+
+ $location .= '/' . basename($item->id);
+
+ $result = $this->client->getObjects(dirname($item->id), $this->type2DAV($item->folder->type), [$item->id]);
+
+ if ($result === false) {
+ throw new \Exception("Failed to fetch DAV item for {$item->id}");
+ }
+
+ // TODO: Do any content changes, e.g. organizer/attendee email migration
+
+ if (file_put_contents($location, (string) $result[0]) === false) {
+ throw new \Exception("Failed to write to {$location}");
+ }
+
+ $item->filename = $location;
+ }
+
+ /**
+ * Fetch a list of folder items
+ */
+ public function fetchItemList(Folder $folder, $callback, ImporterInterface $importer): void
+ {
+ // Get existing messages' headers from the destination mailbox
+ $existing = $importer->getItems($folder);
+
+ $set = new ItemSet();
+
+ $dav_type = $this->type2DAV($folder->type);
+ $location = $this->getFolderPath($folder);
+ $search = new DAVSearch($dav_type);
+
+ // TODO: We request only properties relevant to incremental migration,
+ // i.e. to find that something exists and its last update time.
+ // Some servers (iRony) do ignore that and return full VCARD/VEVENT/VTODO
+ // content, if there's many objects we'll have a memory limit issue.
+ // Also, this list should be controlled by the exporter.
+ $search->dataProperties = ['UID', 'REV'];
+
+ $result = $this->client->search(
+ $location,
+ $search,
+ function ($item) use (&$set, $folder, $callback) {
+ // TODO: Skip an item that exists and did not change
+ $exists = false;
+
+ $set->items[] = Item::fromArray([
+ 'id' => $item->href,
+ 'folder' => $folder,
+ 'existing' => $exists,
+ ]);
+
+ if (count($set->items) == self::CHUNK_SIZE) {
+ $callback($set);
+ $set = new ItemSet();
+ }
+ }
+ );
+
+ if ($result === false) {
+ throw new \Exception("Failed to get items from a DAV folder {$location}");
+ }
+
+ if (count($set->items)) {
+ $callback($set);
+ }
+
+ // TODO: Delete items that do not exist anymore?
+ }
+
/**
* Get a list of folder items, limited to their essential propeties
* used in incremental migration.
@@ -171,6 +249,7 @@
$items = $this->client->search(
$location,
$search,
+ // @phpstan-ignore-next-line
function ($item) use ($dav_type) {
// Slim down the result to properties we might need
$result = [
@@ -197,6 +276,36 @@
return $items;
}
+ /**
+ * Get folders hierarchy
+ */
+ public function getFolders($types = []): array
+ {
+ $result = [];
+ foreach (['VEVENT', 'VTODO', 'VCARD'] as $component) {
+ $type = $this->typeFromDAV($component);
+
+ // Skip folder types we do not support (need)
+ if (!empty($types) && !in_array($type, $types)) {
+ continue;
+ }
+
+ // TODO: Skip other users folders
+
+ $folders = $this->client->listFolders($component);
+
+ foreach ($folders as $folder) {
+ $result[$folder->href] = Folder::fromArray([
+ 'fullname' => $folder->name,
+ 'href' => $folder->href,
+ 'type' => $type,
+ ]);
+ }
+ }
+
+ return $result;
+ }
+
/**
* Get folder relative URI
*/
@@ -237,4 +346,22 @@
throw new \Exception("Cannot map type '{$type}' to DAV");
}
}
+
+ /**
+ * Map DAV object type into Kolab type
+ */
+ protected static function typeFromDAV(string $type): string
+ {
+ switch ($type) {
+ case DAVClient::TYPE_VEVENT:
+ return Engine::TYPE_EVENT;
+ case DAVClient::TYPE_VTODO:
+ return Engine::TYPE_TASK;
+ case DAVClient::TYPE_VCARD:
+ // TODO what about groups
+ return Engine::TYPE_CONTACT;
+ default:
+ throw new \Exception("Cannot map type '{$type}' from DAV");
+ }
+ }
}
diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php
--- a/src/app/DataMigrator/EWS.php
+++ b/src/app/DataMigrator/EWS.php
@@ -106,7 +106,7 @@
/**
* Authenticate to EWS (initialize the EWS client)
*/
- public function authenticate()
+ public function authenticate(): void
{
if (!empty($this->account->params['client_id'])) {
$this->api = $this->authenticateWithOAuth2(
@@ -346,16 +346,18 @@
/**
* Fetching an item
*/
- public function fetchItem(Item $item): string
+ public function fetchItem(Item $item): void
{
// Job processing - initialize environment
$this->initEnv($this->engine->queue);
if ($driver = EWS\Item::factory($this, $item)) {
- return $driver->fetchItem($item);
+ $item->filename = $driver->fetchItem($item);
}
- throw new \Exception("Failed to fetch an item from EWS");
+ if (empty($item->filename)) {
+ throw new \Exception("Failed to fetch an item from EWS");
+ }
}
/**
@@ -390,14 +392,14 @@
return null;
}
- $existing = $existing[$idx]['href'];
+ $exists = $existing[$idx]['href'];
}
$item = Item::fromArray([
'id' => $id,
'class' => $item->getItemClass(),
'folder' => $folder,
- 'existing' => $existing,
+ 'existing' => $exists,
]);
// TODO: We don't need to instantiate Item at this point, instead
diff --git a/src/app/DataMigrator/Engine.php b/src/app/DataMigrator/Engine.php
--- a/src/app/DataMigrator/Engine.php
+++ b/src/app/DataMigrator/Engine.php
@@ -6,6 +6,7 @@
use App\DataMigrator\Interface\Folder;
use App\DataMigrator\Interface\ImporterInterface;
use App\DataMigrator\Interface\Item;
+use App\DataMigrator\Interface\ItemSet;
use Illuminate\Support\Str;
/**
@@ -51,6 +52,8 @@
// Create a unique identifier for the migration request
$queue_id = md5(strval($source) . strval($destination) . $options['type']);
+ // TODO: When running in 'sync' mode we shouldn't create a queue at all
+
// If queue exists, we'll display the progress only
if ($queue = Queue::find($queue_id)) {
// If queue contains no jobs, assume invalid
@@ -98,6 +101,7 @@
$folders = $this->exporter->getFolders($types);
$count = 0;
+ $async = empty($options['sync']);
foreach ($folders as $folder) {
$this->debug("Processing folder {$folder->fullname}...");
@@ -105,14 +109,24 @@
$folder->queueId = $queue_id;
$folder->location = $location;
- // Dispatch the job (for async execution)
- Jobs\FolderJob::dispatch($folder);
- $count++;
+ if ($async) {
+ // Dispatch the job (for async execution)
+ Jobs\FolderJob::dispatch($folder);
+ $count++;
+ } else {
+ $this->processFolder($folder);
+ }
}
- $this->queue->bumpJobsStarted($count);
+ if ($count) {
+ $this->queue->bumpJobsStarted($count);
+ }
- $this->debug(sprintf('Done. %d %s created in queue: %s.', $count, Str::plural('job', $count), $queue_id));
+ if ($async) {
+ $this->debug(sprintf('Done. %d %s created in queue: %s.', $count, Str::plural('job', $count), $queue_id));
+ } else {
+ $this->debug(sprintf('Done (queue: %s).', $queue_id));
+ }
}
/**
@@ -121,20 +135,35 @@
public function processFolder(Folder $folder): void
{
// Job processing - initialize environment
- $this->envFromQueue($folder->queueId);
+ if (!$this->queue) {
+ $this->envFromQueue($folder->queueId);
+ }
// Create the folder on the destination server
$this->importer->createFolder($folder);
$count = 0;
+ $async = empty($this->options['sync']);
// Fetch items from the source
$this->exporter->fetchItemList(
$folder,
- function (Item $item) use (&$count) {
- // Dispatch the job (for async execution)
- Jobs\ItemJob::dispatch($item);
- $count++;
+ function ($item_or_set) use (&$count, $async) {
+ if ($async) {
+ // Dispatch the job (for async execution)
+ if ($item_or_set instanceof ItemSet) {
+ Jobs\ItemSetJob::dispatch($item_or_set);
+ } else {
+ Jobs\ItemJob::dispatch($item_or_set);
+ }
+ $count++;
+ } else {
+ if ($item_or_set instanceof ItemSet) {
+ $this->processItemSet($item_or_set);
+ } else {
+ $this->processItem($item_or_set);
+ }
+ }
},
$this->importer
);
@@ -143,7 +172,9 @@
$this->queue->bumpJobsStarted($count);
}
- $this->queue->bumpJobsFinished();
+ if ($async) {
+ $this->queue->bumpJobsFinished();
+ }
}
/**
@@ -152,15 +183,48 @@
public function processItem(Item $item): void
{
// Job processing - initialize environment
- $this->envFromQueue($item->folder->queueId);
+ if (!$this->queue) {
+ $this->envFromQueue($item->folder->queueId);
+ }
- if ($filename = $this->exporter->fetchItem($item)) {
- $item->filename = $filename;
+ $this->exporter->fetchItem($item);
+ $this->importer->createItem($item);
+
+ if (!empty($item->filename)) {
+ unlink($item->filename);
+ }
+
+ if (empty($this->options['sync'])) {
+ $this->queue->bumpJobsFinished();
+ }
+ }
+
+ /**
+ * Processing of item-set synchronization
+ */
+ public function processItemSet(ItemSet $set): void
+ {
+ // Job processing - initialize environment
+ if (!$this->queue) {
+ $this->envFromQueue($set->items[0]->folder->queueId);
+ }
+
+ // TODO: Some exporters, e.g. DAV, might optimize fetching multiple items in one go,
+ // we'll need a new API to do that
+
+ foreach ($set->items as $item) {
+ $this->exporter->fetchItem($item);
$this->importer->createItem($item);
- // TODO: remove the file
+
+ if (!empty($item->filename)) {
+ unlink($item->filename);
+ }
}
- $this->queue->bumpJobsFinished();
+ // TODO: We should probably also track number of items migrated
+ if (empty($this->options['sync'])) {
+ $this->queue->bumpJobsFinished();
+ }
}
/**
@@ -250,12 +314,13 @@
case 'davs':
$driver = new DAV($account, $this);
break;
- /*
+
case 'imap':
case 'imaps':
+ case 'tls':
+ case 'ssl':
$driver = new IMAP($account, $this);
break;
- */
default:
throw new \Exception("Failed to init driver for '{$account->scheme}'");
diff --git a/src/app/DataMigrator/IMAP.php b/src/app/DataMigrator/IMAP.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/IMAP.php
@@ -0,0 +1,377 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\ExporterInterface;
+use App\DataMigrator\Interface\ImporterInterface;
+use App\DataMigrator\Interface\Item;
+use App\DataMigrator\Interface\ItemSet;
+
+/**
+ * Data migration from IMAP
+ */
+class IMAP implements ExporterInterface, ImporterInterface
+{
+ /** @const int Max number of items to migrate in one go */
+ protected const CHUNK_SIZE = 100;
+
+ /** @var \rcube_imap_generic Imap backend */
+ protected $imap;
+
+ /** @var Account Account to operate on */
+ protected $account;
+
+ /** @var Engine Data migrator engine */
+ protected $engine;
+
+
+ /**
+ * Object constructor
+ */
+ public function __construct(Account $account, Engine $engine)
+ {
+ $this->account = $account;
+ $this->engine = $engine;
+
+ // TODO: Move this to self::authenticate()?
+ $config = self::getConfig($account->username, $account->password, $account->uri);
+ $this->imap = self::initIMAP($config);
+ }
+
+ /**
+ * Authenticate
+ */
+ public function authenticate(): void
+ {
+ }
+
+ /**
+ * Create a folder.
+ *
+ * @param Folder $folder Folder data
+ *
+ * @throws \Exception on error
+ */
+ public function createFolder(Folder $folder): void
+ {
+ if ($folder->type != 'mail') {
+ throw new \Exception("IMAP does not support folder of type {$folder->type}");
+ }
+
+ if ($folder->fullname == 'INBOX') {
+ // INBOX always exists
+ return;
+ }
+
+ if (!$this->imap->createFolder($folder->fullname)) {
+ \Log::warning("Failed to create the folder: {$this->imap->error}");
+
+ if (str_contains($this->imap->error, "Mailbox already exists")) {
+ // Not an error
+ } else {
+ throw new \Exception("Failed to create an IMAP folder {$folder->fullname}");
+ }
+ }
+ }
+
+ /**
+ * Create an item in a folder.
+ *
+ * @param Item $item Item to import
+ *
+ * @throws \Exception
+ */
+ public function createItem(Item $item): void
+ {
+ $mailbox = $item->folder->fullname;
+
+ // TODO: When updating an email we have to just update flags
+
+ if ($item->filename) {
+ $result = $this->imap->appendFromFile(
+ $mailbox, $item->filename, null, $item->data['flags'], $item->data['internaldate'], true
+ );
+
+ if ($result === false) {
+ throw new \Exception("Failed to append IMAP message into {$mailbox}");
+ }
+ }
+ }
+
+ /**
+ * Fetching an item
+ */
+ public function fetchItem(Item $item): void
+ {
+ [$uid, $messageId] = explode(':', $item->id, 2);
+
+ $mailbox = $item->folder->fullname;
+
+ // Get message flags
+ $header = $this->imap->fetchHeader($mailbox, (int) $uid, true, false, ['FLAGS']);
+
+ if ($header === false) {
+ throw new \Exception("Failed to get IMAP message headers for {$mailbox}/{$uid}");
+ }
+
+ // Remove flags that we can't append (e.g. RECENT)
+ $flags = $this->filterImapFlags(array_keys($header->flags));
+
+ // TODO: If message already exists in the destination account we should update flags
+ // and be done with it. On the other hand for Drafts it's not unusual to get completely
+ // different body for the same Message-ID. Same can happen not only in Drafts, I suppose.
+
+ // Save the message content to a file
+ $location = $item->folder->location;
+
+ if (!file_exists($location)) {
+ mkdir($location, 0740, true);
+ }
+
+ // TODO: What if parent folder not yet exists?
+ $location .= '/' . $uid . '.eml';
+
+ // TODO: We should consider streaming the message, it should be possible
+ // with append() and handlePartBody(), but I don't know if anyone tried that.
+
+ $fp = fopen($location, 'w');
+
+ if (!$fp) {
+ throw new \Exception("Failed to write to {$location}");
+ }
+
+ $result = $this->imap->handlePartBody($mailbox, $uid, true, '', null, null, $fp);
+
+ if ($result === false) {
+ fclose($fp);
+ throw new \Exception("Failed to fetch IMAP message for {$mailbox}/{$uid}");
+ }
+
+ $item->filename = $location;
+ $item->data = [
+ 'flags' => $flags,
+ 'internaldate' => $header->internaldate,
+ ];
+
+ fclose($fp);
+ }
+
+ /**
+ * Fetch a list of folder items
+ */
+ public function fetchItemList(Folder $folder, $callback, ImporterInterface $importer): void
+ {
+ // Get existing messages' headers from the destination mailbox
+ $existing = $importer->getItems($folder);
+
+ $mailbox = $folder->fullname;
+
+ // TODO: We should probably first use SEARCH/SORT to skip messages marked as \Deleted
+ // TODO: fetchHeaders() fetches too many headers, we should slim-down, here we need
+ // only UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM MESSAGE-ID)]
+ $messages = $this->imap->fetchHeaders($mailbox, '1:*', true, false, ['Message-Id']);
+
+ if ($messages === false) {
+ throw new \Exception("Failed to get all IMAP message headers for {$mailbox}");
+ }
+
+ if (empty($messages)) {
+ \Log::debug("Nothing to migrate for {$mailbox}");
+ return;
+ }
+
+ $set = new ItemSet();
+
+ foreach ($messages as $message) {
+ // TODO: If Message-Id header does not exist create it based on internaldate/From/Date
+
+ // Skip message that exists and did not change
+ $exists = false;
+ if (isset($existing[$message->messageID])) {
+ // TODO: Compare flags (compare message size, internaldate?)
+ continue;
+ }
+
+ $set->items[] = Item::fromArray([
+ 'id' => $message->uid . ':' . $message->messageID,
+ 'folder' => $folder,
+ 'existing' => $exists,
+ ]);
+
+ if (count($set->items) == self::CHUNK_SIZE) {
+ $callback($set);
+ $set = new ItemSet();
+ }
+ }
+
+ if (count($set->items)) {
+ $callback($set);
+ }
+
+ // TODO: Delete messages that do not exist anymore?
+ }
+
+ /**
+ * Get folders hierarchy
+ */
+ public function getFolders($types = []): array
+ {
+ $folders = $this->imap->listMailboxes('', '');
+
+ if ($folders === false) {
+ throw new \Exception("Failed to get list of IMAP folders");
+ }
+
+ $result = [];
+
+ foreach ($folders as $folder) {
+ if ($this->shouldSkip($folder)) {
+ \Log::debug("Skipping folder {$folder}.");
+ continue;
+ }
+
+ $result[] = Folder::fromArray([
+ 'fullname' => $folder,
+ 'type' => 'mail'
+ ]);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get a list of folder items, limited to their essential propeties
+ * used in incremental migration to skip unchanged items.
+ */
+ public function getItems(Folder $folder): array
+ {
+ $mailbox = $folder->fullname;
+
+ // TODO: We should probably first use SEARCH/SORT to skip messages marked as \Deleted
+ // TODO: fetchHeaders() fetches too many headers, we should slim-down, here we need
+ // only UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM MESSAGE-ID)]
+ $messages = $this->imap->fetchHeaders($mailbox, '1:*', true, false, ['Message-Id']);
+
+ if ($messages === false) {
+ throw new \Exception("Failed to get IMAP message headers in {$mailbox}");
+ }
+
+ $result = [];
+
+ foreach ($messages as $message) {
+ // Remove flags that we can't append (e.g. RECENT)
+ $flags = $this->filterImapFlags(array_keys($message->flags));
+
+ // TODO: Generate message ID if the header does not exist
+ $result[$message->messageID] = [
+ 'uid' => $message->uid,
+ 'flags' => $flags,
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Initialize IMAP connection and authenticate the user
+ */
+ private static function initIMAP(array $config, string $login_as = null): \rcube_imap_generic
+ {
+ $imap = new \rcube_imap_generic();
+
+ if (\config('app.debug')) {
+ $imap->setDebug(true, 'App\Backends\IMAP::logDebug');
+ }
+
+ if ($login_as) {
+ $config['options']['auth_cid'] = $config['user'];
+ $config['options']['auth_pw'] = $config['password'];
+ $config['options']['auth_type'] = 'PLAIN';
+ $config['user'] = $login_as;
+ }
+
+ $imap->connect($config['host'], $config['user'], $config['password'], $config['options']);
+
+ if (!$imap->connected()) {
+ $message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error);
+
+ \Log::error($message);
+
+ throw new \Exception("Connection to IMAP failed");
+ }
+
+ return $imap;
+ }
+
+ /**
+ * Get IMAP configuration
+ */
+ private static function getConfig($user, $password, $uri): array
+ {
+ $uri = \parse_url($uri);
+ $default_port = 143;
+ $ssl_mode = null;
+
+ if (isset($uri['scheme'])) {
+ if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) {
+ $default_port = 993;
+ $ssl_mode = 'ssl';
+ } elseif ($uri['scheme'] === 'tls') {
+ $ssl_mode = 'tls';
+ }
+ }
+
+ $config = [
+ 'host' => $uri['host'],
+ 'user' => $user,
+ 'password' => $password,
+ 'options' => [
+ 'port' => !empty($uri['port']) ? $uri['port'] : $default_port,
+ 'ssl_mode' => $ssl_mode,
+ 'socket_options' => [
+ 'ssl' => [
+ // TODO: These configuration options make sense for "local" Kolab IMAP,
+ // but when connecting to external one we might want to just disable
+ // cert validation, or make it optional via Account URI parameters
+ 'verify_peer' => \config('imap.verify_peer'),
+ 'verify_peer_name' => \config('imap.verify_peer'),
+ 'verify_host' => \config('imap.verify_host')
+ ],
+ ],
+ ],
+ ];
+
+ return $config;
+ }
+
+ /**
+ * Limit IMAP flags to these that can be migrated
+ */
+ private function filterImapFlags($flags)
+ {
+ // TODO: Support custom flags migration
+
+ return array_filter(
+ $flags,
+ function ($flag) {
+ return in_array($flag, $this->imap->flags);
+ }
+ );
+ }
+
+ /**
+ * Check if the folder should not be migrated
+ */
+ private function shouldSkip($folder): bool
+ {
+ // TODO: This should probably use NAMESPACE information
+ // TODO: This should also skip other user folders
+
+ if (preg_match("/Shared Folders\/.*/", $folder)) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/app/DataMigrator/Interface/ExporterInterface.php b/src/app/DataMigrator/Interface/ExporterInterface.php
--- a/src/app/DataMigrator/Interface/ExporterInterface.php
+++ b/src/app/DataMigrator/Interface/ExporterInterface.php
@@ -32,5 +32,5 @@
/**
* Fetching an item
*/
- public function fetchItem(Item $item): string;
+ public function fetchItem(Item $item): void;
}
diff --git a/src/app/DataMigrator/Interface/Folder.php b/src/app/DataMigrator/Interface/Folder.php
--- a/src/app/DataMigrator/Interface/Folder.php
+++ b/src/app/DataMigrator/Interface/Folder.php
@@ -34,7 +34,7 @@
public $queueId;
- public static function fromArray(array $data = [])
+ public static function fromArray(array $data = []): Folder
{
$obj = new self();
diff --git a/src/app/DataMigrator/Interface/Item.php b/src/app/DataMigrator/Interface/Item.php
--- a/src/app/DataMigrator/Interface/Item.php
+++ b/src/app/DataMigrator/Interface/Item.php
@@ -22,8 +22,11 @@
/** @var ?string Exported object location in the local storage */
public $filename;
+ /** @var array Extra data to migrate (like email flags, internaldate, etc.) */
+ public $data = [];
- public static function fromArray(array $data = [])
+
+ public static function fromArray(array $data = []): Item
{
$obj = new self();
diff --git a/src/app/DataMigrator/Interface/ItemSet.php b/src/app/DataMigrator/Interface/ItemSet.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/ItemSet.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+/**
+ * Data object representing a set of data items
+ */
+class ItemSet
+{
+ /** @var array<Item> Items list */
+ public $items = [];
+
+ // TODO: Every item has a $folder property, this makes the set
+ // needlesly big when serialized. We should probably store $folder
+ // once with the set and remove it from an item on serialize
+ // and back in unserialize.
+
+ /**
+ * Create an ItemSet instance
+ */
+ public static function set(array $items = []): ItemSet
+ {
+ $obj = new self();
+ $obj->items = $items;
+
+ return $obj;
+ }
+}
diff --git a/src/app/DataMigrator/Jobs/ItemSetJob.php b/src/app/DataMigrator/Jobs/ItemSetJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Jobs/ItemSetJob.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\DataMigrator\Jobs;
+
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\ItemSet;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class ItemSetJob implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 3;
+
+ /** @var ItemSet Job data */
+ protected $set;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param ItemSet $set Set of Items to process
+ *
+ * @return void
+ */
+ public function __construct(ItemSet $set)
+ {
+ $this->set = $set;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $migrator = new Engine();
+ $migrator->processItemSet($this->set);
+ }
+
+ /**
+ * The job failed to process.
+ *
+ * @param \Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $exception)
+ {
+ // TODO: Count failed jobs in the queue
+ // I'm not sure how to do this after the final failure (after X tries)
+ // In other words how do we know all jobs in a queue finished (successfully or not)
+ // Probably we have to set $tries = 1
+ }
+}
diff --git a/src/bootstrap/app.php b/src/bootstrap/app.php
--- a/src/bootstrap/app.php
+++ b/src/bootstrap/app.php
@@ -1,5 +1,28 @@
<?php
+// Stuff from Roundcube Framework bootstrap required by rcube_* classes
+define('RCUBE_CHARSET', 'UTF-8');
+
+/**
+ * Get first element from an array
+ *
+ * @param array $array Input array
+ *
+ * @return mixed First element if found, Null otherwise
+ */
+function array_first($array)
+{
+ // @phpstan-ignore-next-line
+ if (is_array($array) && !empty($array)) {
+ reset($array);
+ foreach ($array as $element) {
+ return $element;
+ }
+ }
+
+ return null;
+}
+
/*
|--------------------------------------------------------------------------
| Create The Application
diff --git a/src/include/rcube_charset.php b/src/include/rcube_charset.php
new file mode 100644
--- /dev/null
+++ b/src/include/rcube_charset.php
@@ -0,0 +1,570 @@
+<?php
+
+/**
+ +-----------------------------------------------------------------------+
+ | This file is part of the Roundcube Webmail client |
+ | |
+ | Copyright (C) The Roundcube Dev Team |
+ | Copyright (C) Kolab Systems AG |
+ | Copyright (C) 2000 Edmund Grimley Evans <edmundo@rano.org> |
+ | |
+ | Licensed under the GNU General Public License version 3 or |
+ | any later version with exceptions for skins & plugins. |
+ | See the README file for a full license statement. |
+ | |
+ | PURPOSE: |
+ | Provide charset conversion functionality |
+ +-----------------------------------------------------------------------+
+ | Author: Thomas Bruederli <roundcube@gmail.com> |
+ | Author: Aleksander Machniak <alec@alec.pl> |
+ | Author: Edmund Grimley Evans <edmundo@rano.org> |
+ +-----------------------------------------------------------------------+
+*/
+
+/**
+ * Character sets conversion functionality
+ *
+ * @package Framework
+ * @subpackage Core
+ */
+class rcube_charset
+{
+ /**
+ * Character set aliases (some of them from HTML5 spec.)
+ *
+ * @var array
+ */
+ static public $aliases = [
+ 'USASCII' => 'WINDOWS-1252',
+ 'ANSIX31101983' => 'WINDOWS-1252',
+ 'ANSIX341968' => 'WINDOWS-1252',
+ 'UNKNOWN8BIT' => 'ISO-8859-15',
+ 'UNKNOWN' => 'ISO-8859-15',
+ 'USERDEFINED' => 'ISO-8859-15',
+ 'KSC56011987' => 'EUC-KR',
+ 'GB2312' => 'GBK',
+ 'GB231280' => 'GBK',
+ 'UNICODE' => 'UTF-8',
+ 'UTF7IMAP' => 'UTF7-IMAP',
+ 'TIS620' => 'WINDOWS-874',
+ 'ISO88599' => 'WINDOWS-1254',
+ 'ISO885911' => 'WINDOWS-874',
+ 'MACROMAN' => 'MACINTOSH',
+ '77' => 'MAC',
+ '128' => 'SHIFT-JIS',
+ '129' => 'CP949',
+ '130' => 'CP1361',
+ '134' => 'GBK',
+ '136' => 'BIG5',
+ '161' => 'WINDOWS-1253',
+ '162' => 'WINDOWS-1254',
+ '163' => 'WINDOWS-1258',
+ '177' => 'WINDOWS-1255',
+ '178' => 'WINDOWS-1256',
+ '186' => 'WINDOWS-1257',
+ '204' => 'WINDOWS-1251',
+ '222' => 'WINDOWS-874',
+ '238' => 'WINDOWS-1250',
+ 'MS950' => 'CP950',
+ 'WINDOWS949' => 'UHC',
+ 'WINDOWS1257' => 'ISO-8859-13',
+ 'ISO2022JP' => 'ISO-2022-JP-MS',
+ ];
+
+ /**
+ * Windows codepages
+ *
+ * @var array
+ */
+ static public $windows_codepages = [
+ 37 => 'IBM037', // IBM EBCDIC US-Canada
+ 437 => 'IBM437', // OEM United States
+ 500 => 'IBM500', // IBM EBCDIC International
+ 708 => 'ASMO-708', // Arabic (ASMO 708)
+ 720 => 'DOS-720', // Arabic (Transparent ASMO); Arabic (DOS)
+ 737 => 'IBM737', // OEM Greek (formerly 437G); Greek (DOS)
+ 775 => 'IBM775', // OEM Baltic; Baltic (DOS)
+ 850 => 'IBM850', // OEM Multilingual Latin 1; Western European (DOS)
+ 852 => 'IBM852', // OEM Latin 2; Central European (DOS)
+ 855 => 'IBM855', // OEM Cyrillic (primarily Russian)
+ 857 => 'IBM857', // OEM Turkish; Turkish (DOS)
+ 858 => 'IBM00858', // OEM Multilingual Latin 1 + Euro symbol
+ 860 => 'IBM860', // OEM Portuguese; Portuguese (DOS)
+ 861 => 'IBM861', // OEM Icelandic; Icelandic (DOS)
+ 862 => 'DOS-862', // OEM Hebrew; Hebrew (DOS)
+ 863 => 'IBM863', // OEM French Canadian; French Canadian (DOS)
+ 864 => 'IBM864', // OEM Arabic; Arabic (864)
+ 865 => 'IBM865', // OEM Nordic; Nordic (DOS)
+ 866 => 'cp866', // OEM Russian; Cyrillic (DOS)
+ 869 => 'IBM869', // OEM Modern Greek; Greek, Modern (DOS)
+ 870 => 'IBM870', // IBM EBCDIC Multilingual/ROECE (Latin 2); IBM EBCDIC Multilingual Latin 2
+ 874 => 'windows-874', // ANSI/OEM Thai (ISO 8859-11); Thai (Windows)
+ 875 => 'cp875', // IBM EBCDIC Greek Modern
+ 932 => 'shift_jis', // ANSI/OEM Japanese; Japanese (Shift-JIS)
+ 936 => 'gb2312', // ANSI/OEM Simplified Chinese (PRC, Singapore); Chinese Simplified (GB2312)
+ 950 => 'big5', // ANSI/OEM Traditional Chinese (Taiwan; Hong Kong SAR, PRC); Chinese Traditional (Big5)
+ 1026 => 'IBM1026', // IBM EBCDIC Turkish (Latin 5)
+ 1047 => 'IBM01047', // IBM EBCDIC Latin 1/Open System
+ 1140 => 'IBM01140', // IBM EBCDIC US-Canada (037 + Euro symbol); IBM EBCDIC (US-Canada-Euro)
+ 1141 => 'IBM01141', // IBM EBCDIC Germany (20273 + Euro symbol); IBM EBCDIC (Germany-Euro)
+ 1142 => 'IBM01142', // IBM EBCDIC Denmark-Norway (20277 + Euro symbol); IBM EBCDIC (Denmark-Norway-Euro)
+ 1143 => 'IBM01143', // IBM EBCDIC Finland-Sweden (20278 + Euro symbol); IBM EBCDIC (Finland-Sweden-Euro)
+ 1144 => 'IBM01144', // IBM EBCDIC Italy (20280 + Euro symbol); IBM EBCDIC (Italy-Euro)
+ 1145 => 'IBM01145', // IBM EBCDIC Latin America-Spain (20284 + Euro symbol); IBM EBCDIC (Spain-Euro)
+ 1146 => 'IBM01146', // IBM EBCDIC United Kingdom (20285 + Euro symbol); IBM EBCDIC (UK-Euro)
+ 1147 => 'IBM01147', // IBM EBCDIC France (20297 + Euro symbol); IBM EBCDIC (France-Euro)
+ 1148 => 'IBM01148', // IBM EBCDIC International (500 + Euro symbol); IBM EBCDIC (International-Euro)
+ 1149 => 'IBM01149', // IBM EBCDIC Icelandic (20871 + Euro symbol); IBM EBCDIC (Icelandic-Euro)
+ 1200 => 'UTF-16', // Unicode UTF-16, little endian byte order (BMP of ISO 10646); available only to managed applications
+ 1201 => 'UTF-16BE', // Unicode UTF-16, big endian byte order; available only to managed applications
+ 1250 => 'windows-1250', // ANSI Central European; Central European (Windows)
+ 1251 => 'windows-1251', // ANSI Cyrillic; Cyrillic (Windows)
+ 1252 => 'windows-1252', // ANSI Latin 1; Western European (Windows)
+ 1253 => 'windows-1253', // ANSI Greek; Greek (Windows)
+ 1254 => 'windows-1254', // ANSI Turkish; Turkish (Windows)
+ 1255 => 'windows-1255', // ANSI Hebrew; Hebrew (Windows)
+ 1256 => 'windows-1256', // ANSI Arabic; Arabic (Windows)
+ 1257 => 'windows-1257', // ANSI Baltic; Baltic (Windows)
+ 1258 => 'windows-1258', // ANSI/OEM Vietnamese; Vietnamese (Windows)
+ 10000 => 'macintosh', // MAC Roman; Western European (Mac)
+ 12000 => 'UTF-32', // Unicode UTF-32, little endian byte order; available only to managed applications
+ 12001 => 'UTF-32BE', // Unicode UTF-32, big endian byte order; available only to managed applications
+ 20127 => 'US-ASCII', // US-ASCII (7-bit)
+ 20273 => 'IBM273', // IBM EBCDIC Germany
+ 20277 => 'IBM277', // IBM EBCDIC Denmark-Norway
+ 20278 => 'IBM278', // IBM EBCDIC Finland-Sweden
+ 20280 => 'IBM280', // IBM EBCDIC Italy
+ 20284 => 'IBM284', // IBM EBCDIC Latin America-Spain
+ 20285 => 'IBM285', // IBM EBCDIC United Kingdom
+ 20290 => 'IBM290', // IBM EBCDIC Japanese Katakana Extended
+ 20297 => 'IBM297', // IBM EBCDIC France
+ 20420 => 'IBM420', // IBM EBCDIC Arabic
+ 20423 => 'IBM423', // IBM EBCDIC Greek
+ 20424 => 'IBM424', // IBM EBCDIC Hebrew
+ 20838 => 'IBM-Thai', // IBM EBCDIC Thai
+ 20866 => 'koi8-r', // Russian (KOI8-R); Cyrillic (KOI8-R)
+ 20871 => 'IBM871', // IBM EBCDIC Icelandic
+ 20880 => 'IBM880', // IBM EBCDIC Cyrillic Russian
+ 20905 => 'IBM905', // IBM EBCDIC Turkish
+ 20924 => 'IBM00924', // IBM EBCDIC Latin 1/Open System (1047 + Euro symbol)
+ 20932 => 'EUC-JP', // Japanese (JIS 0208-1990 and 0212-1990)
+ 20936 => 'cp20936', // Simplified Chinese (GB2312); Chinese Simplified (GB2312-80)
+ 20949 => 'cp20949', // Korean Wansung
+ 21025 => 'cp1025', // IBM EBCDIC Cyrillic Serbian-Bulgarian
+ 21866 => 'koi8-u', // Ukrainian (KOI8-U); Cyrillic (KOI8-U)
+ 28591 => 'iso-8859-1', // ISO 8859-1 Latin 1; Western European (ISO)
+ 28592 => 'iso-8859-2', // ISO 8859-2 Central European; Central European (ISO)
+ 28593 => 'iso-8859-3', // ISO 8859-3 Latin 3
+ 28594 => 'iso-8859-4', // ISO 8859-4 Baltic
+ 28595 => 'iso-8859-5', // ISO 8859-5 Cyrillic
+ 28596 => 'iso-8859-6', // ISO 8859-6 Arabic
+ 28597 => 'iso-8859-7', // ISO 8859-7 Greek
+ 28598 => 'iso-8859-8', // ISO 8859-8 Hebrew; Hebrew (ISO-Visual)
+ 28599 => 'iso-8859-9', // ISO 8859-9 Turkish
+ 28603 => 'iso-8859-13', // ISO 8859-13 Estonian
+ 28605 => 'iso-8859-15', // ISO 8859-15 Latin 9
+ 38598 => 'iso-8859-8-i', // ISO 8859-8 Hebrew; Hebrew (ISO-Logical)
+ 50220 => 'iso-2022-jp', // ISO 2022 Japanese with no halfwidth Katakana; Japanese (JIS)
+ 50221 => 'csISO2022JP', // ISO 2022 Japanese with halfwidth Katakana; Japanese (JIS-Allow 1 byte Kana)
+ 50222 => 'iso-2022-jp', // ISO 2022 Japanese JIS X 0201-1989; Japanese (JIS-Allow 1 byte Kana - SO/SI)
+ 50225 => 'iso-2022-kr', // ISO 2022 Korean
+ 51932 => 'EUC-JP', // EUC Japanese
+ 51936 => 'EUC-CN', // EUC Simplified Chinese; Chinese Simplified (EUC)
+ 51949 => 'EUC-KR', // EUC Korean
+ 52936 => 'hz-gb-2312', // HZ-GB2312 Simplified Chinese; Chinese Simplified (HZ)
+ 54936 => 'GB18030', // Windows XP and later: GB18030 Simplified Chinese (4 byte); Chinese Simplified (GB18030)
+ 65000 => 'UTF-7',
+ 65001 => 'UTF-8',
+ ];
+
+ /**
+ * Validate character set identifier.
+ *
+ * @param string $input Character set identifier
+ *
+ * @return bool True if valid, False if not valid
+ */
+ public static function is_valid($input)
+ {
+ return is_string($input) && preg_match('|^[a-zA-Z0-9_./:#-]{2,32}$|', $input) > 0;
+ }
+
+ /**
+ * Parse and validate charset name string.
+ * Sometimes charset string is malformed, there are also charset aliases,
+ * but we need strict names for charset conversion (specially utf8 class)
+ *
+ * @param string $input Input charset name
+ *
+ * @return string The validated charset name
+ */
+ public static function parse_charset($input)
+ {
+ static $charsets = [];
+
+ $charset = strtoupper($input);
+
+ if (isset($charsets[$input])) {
+ return $charsets[$input];
+ }
+
+ $charset = preg_replace([
+ '/^[^0-9A-Z]+/', // e.g. _ISO-8859-JP$SIO
+ '/\$.*$/', // e.g. _ISO-8859-JP$SIO
+ '/UNICODE-1-1-*/', // RFC1641/1642
+ '/^X-/', // X- prefix (e.g. X-ROMAN8 => ROMAN8)
+ '/\*.*$/' // lang code according to RFC 2231.5
+ ], '', $charset);
+
+ if ($charset == 'BINARY') {
+ return $charsets[$input] = null;
+ }
+
+ // allow A-Z and 0-9 only
+ $str = preg_replace('/[^A-Z0-9]/', '', $charset);
+
+ $result = $charset;
+
+ if (isset(self::$aliases[$str])) {
+ $result = self::$aliases[$str];
+ }
+ // UTF
+ else if (preg_match('/U[A-Z][A-Z](7|8|16|32)(BE|LE)*/', $str, $m)) {
+ $result = 'UTF-' . $m[1] . (!empty($m[2]) ? $m[2] : '');
+ }
+ // ISO-8859
+ else if (preg_match('/ISO8859([0-9]{0,2})/', $str, $m)) {
+ $iso = 'ISO-8859-' . ($m[1] ?: 1);
+ // some clients sends windows-1252 text as latin1,
+ // it is safe to use windows-1252 for all latin1
+ $result = $iso == 'ISO-8859-1' ? 'WINDOWS-1252' : $iso;
+ }
+ // handle broken charset names e.g. WINDOWS-1250HTTP-EQUIVCONTENT-TYPE
+ else if (preg_match('/(WIN|WINDOWS)([0-9]+)/', $str, $m)) {
+ $result = 'WINDOWS-' . $m[2];
+ }
+ // LATIN
+ else if (preg_match('/LATIN(.*)/', $str, $m)) {
+ $aliases = ['2' => 2, '3' => 3, '4' => 4, '5' => 9, '6' => 10,
+ '7' => 13, '8' => 14, '9' => 15, '10' => 16,
+ 'ARABIC' => 6, 'CYRILLIC' => 5, 'GREEK' => 7, 'GREEK1' => 7, 'HEBREW' => 8
+ ];
+
+ // some clients sends windows-1252 text as latin1,
+ // it is safe to use windows-1252 for all latin1
+ if ($m[1] == 1) {
+ $result = 'WINDOWS-1252';
+ }
+ // we need ISO labels
+ else if (!empty($aliases[$m[1]])) {
+ $result = 'ISO-8859-'.$aliases[$m[1]];
+ }
+ }
+
+ $charsets[$input] = $result;
+
+ return $result;
+ }
+
+ /**
+ * Convert a string from one charset to another.
+ *
+ * @param string $str Input string
+ * @param string $from Suspected charset of the input string
+ * @param string $to Target charset to convert to; defaults to RCUBE_CHARSET
+ *
+ * @return string Converted string
+ */
+ public static function convert($str, $from, $to = null)
+ {
+ static $iconv_options;
+
+ $to = empty($to) ? RCUBE_CHARSET : self::parse_charset($to);
+ $from = self::parse_charset($from);
+
+ // It is a common case when UTF-16 charset is used with US-ASCII content (#1488654)
+ // In that case we can just skip the conversion (use UTF-8)
+ if ($from == 'UTF-16' && !preg_match('/[^\x00-\x7F]/', $str)) {
+ $from = 'UTF-8';
+ }
+
+ if ($from == $to || empty($str) || empty($from)) {
+ return $str;
+ }
+
+ $out = false;
+ $error_handler = function() { throw new \Exception(); };
+
+ // Ignore invalid characters
+ $mbstring_sc = mb_substitute_character();
+ mb_substitute_character('none');
+
+ // If mbstring reports an illegal character in input via E_WARNING.
+ // FIXME: Is this really true with substitute character 'none'?
+ // A warning is thrown in PHP<8 also on unsupported encoding, in PHP>=8 ValueError
+ // is thrown instead (therefore we catch Throwable below)
+ set_error_handler($error_handler, E_WARNING);
+
+ try {
+ $out = mb_convert_encoding($str, $to, $from);
+ }
+ catch (Throwable $e) {
+ $out = false;
+ }
+ catch (Exception $e) {
+ $out = false;
+ }
+
+ restore_error_handler();
+ mb_substitute_character($mbstring_sc);
+
+ if ($out !== false) {
+ return $out;
+ }
+
+ if ($iconv_options === null) {
+ if (function_exists('iconv')) {
+ // ignore characters not available in output charset
+ $iconv_options = '//IGNORE';
+ if (iconv('', $iconv_options, '') === false) {
+ // iconv implementation does not support options
+ $iconv_options = '';
+ }
+ }
+ else {
+ $iconv_options = false;
+ }
+ }
+
+ // Fallback to iconv module, it is slower, but supports much more charsets than mbstring
+ if ($iconv_options !== false && $from != 'UTF7-IMAP' && $to != 'UTF7-IMAP'
+ && $from !== 'ISO-2022-JP'
+ ) {
+ // If iconv reports an illegal character in input it means that input string
+ // has been truncated. It's reported as E_NOTICE.
+ // PHP8 will also throw E_WARNING on unsupported encoding.
+ set_error_handler($error_handler, E_NOTICE | E_WARNING);
+
+ try {
+ $out = iconv($from, $to . $iconv_options, $str);
+ }
+ catch (Throwable $e) {
+ $out = false;
+ }
+ catch (Exception $e) {
+ $out = false;
+ }
+
+ restore_error_handler();
+
+ if ($out !== false) {
+ return $out;
+ }
+ }
+
+ // return the original string
+ return $str;
+ }
+
+ /**
+ * Converts string from standard UTF-7 (RFC 2152) to UTF-8.
+ *
+ * @param string $str Input string (UTF-7)
+ *
+ * @return string Converted string (UTF-8)
+ * @deprecated use self::convert()
+ */
+ public static function utf7_to_utf8($str)
+ {
+ return self::convert($str, 'UTF-7', 'UTF-8');
+ }
+
+ /**
+ * Converts string from UTF-16 to UTF-8 (helper for utf-7 to utf-8 conversion)
+ *
+ * @param string $str Input string
+ *
+ * @return string The converted string
+ * @deprecated use self::convert()
+ */
+ public static function utf16_to_utf8($str)
+ {
+ return self::convert($str, 'UTF-16BE', 'UTF-8');
+ }
+
+ /**
+ * Convert the data ($str) from RFC 2060's UTF-7 to UTF-8.
+ * If input data is invalid, return the original input string.
+ * RFC 2060 obviously intends the encoding to be unique (see
+ * point 5 in section 5.1.3), so we reject any non-canonical
+ * form, such as &ACY- (instead of &-) or &AMA-&AMA- (instead
+ * of &AMAAwA-).
+ *
+ * @param string $str Input string (UTF7-IMAP)
+ *
+ * @return string Output string (UTF-8)
+ * @deprecated use self::convert()
+ */
+ public static function utf7imap_to_utf8($str)
+ {
+ return self::convert($str, 'UTF7-IMAP', 'UTF-8');
+ }
+
+ /**
+ * Convert the data ($str) from UTF-8 to RFC 2060's UTF-7.
+ * Unicode characters above U+FFFF are replaced by U+FFFE.
+ * If input data is invalid, return an empty string.
+ *
+ * @param string $str Input string (UTF-8)
+ *
+ * @return string Output string (UTF7-IMAP)
+ * @deprecated use self::convert()
+ */
+ public static function utf8_to_utf7imap($str)
+ {
+ return self::convert($str, 'UTF-8', 'UTF7-IMAP');
+ }
+
+ /**
+ * A method to guess character set of a string.
+ *
+ * @param string $string String
+ * @param string $failover Default result for failover
+ * @param string $language User language
+ *
+ * @return string Charset name
+ */
+ public static function detect($string, $failover = null, $language = null)
+ {
+ if (substr($string, 0, 4) == "\0\0\xFE\xFF") return 'UTF-32BE'; // Big Endian
+ if (substr($string, 0, 4) == "\xFF\xFE\0\0") return 'UTF-32LE'; // Little Endian
+ if (substr($string, 0, 2) == "\xFE\xFF") return 'UTF-16BE'; // Big Endian
+ if (substr($string, 0, 2) == "\xFF\xFE") return 'UTF-16LE'; // Little Endian
+ if (substr($string, 0, 3) == "\xEF\xBB\xBF") return 'UTF-8';
+
+ // heuristics
+ if (strlen($string) >= 4) {
+ if ($string[0] == "\0" && $string[1] == "\0" && $string[2] == "\0" && $string[3] != "\0") return 'UTF-32BE';
+ if ($string[0] != "\0" && $string[1] == "\0" && $string[2] == "\0" && $string[3] == "\0") return 'UTF-32LE';
+ if ($string[0] == "\0" && $string[1] != "\0" && $string[2] == "\0" && $string[3] != "\0") return 'UTF-16BE';
+ if ($string[0] != "\0" && $string[1] == "\0" && $string[2] != "\0" && $string[3] == "\0") return 'UTF-16LE';
+ }
+
+ if (empty($language)) {
+ $rcube = rcube::get_instance();
+ $language = $rcube->get_user_language();
+ }
+
+ // Prioritize charsets according to current language (#1485669)
+ $prio = null;
+ switch ($language) {
+ case 'ja_JP':
+ $prio = ['ISO-2022-JP', 'JIS', 'UTF-8', 'EUC-JP', 'eucJP-win', 'SJIS', 'SJIS-win'];
+ break;
+
+ case 'zh_CN':
+ case 'zh_TW':
+ $prio = ['UTF-8', 'BIG-5', 'GB2312', 'EUC-TW'];
+ break;
+
+ case 'ko_KR':
+ $prio = ['UTF-8', 'EUC-KR', 'ISO-2022-KR'];
+ break;
+
+ case 'ru_RU':
+ $prio = ['UTF-8', 'WINDOWS-1251', 'KOI8-R'];
+ break;
+
+ case 'tr_TR':
+ $prio = ['UTF-8', 'ISO-8859-9', 'WINDOWS-1254'];
+ break;
+ }
+
+ // mb_detect_encoding() is not reliable for some charsets (#1490135)
+ // use mb_check_encoding() to make charset priority lists really working
+ if (!empty($prio) && function_exists('mb_check_encoding')) {
+ foreach ($prio as $encoding) {
+ if (mb_check_encoding($string, $encoding)) {
+ return $encoding;
+ }
+ }
+ }
+
+ if (function_exists('mb_detect_encoding')) {
+ if (empty($prio)) {
+ $prio = ['UTF-8', 'SJIS', 'GB2312',
+ 'ISO-8859-1', 'ISO-8859-2', 'ISO-8859-3', 'ISO-8859-4',
+ 'ISO-8859-5', 'ISO-8859-6', 'ISO-8859-7', 'ISO-8859-8', 'ISO-8859-9',
+ 'ISO-8859-10', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16',
+ 'WINDOWS-1252', 'WINDOWS-1251', 'EUC-JP', 'EUC-TW', 'KOI8-R', 'BIG-5',
+ 'ISO-2022-KR', 'ISO-2022-JP',
+ ];
+ }
+
+ $encodings = array_unique(array_merge($prio, mb_list_encodings()));
+
+ if ($encoding = mb_detect_encoding($string, $encodings)) {
+ return $encoding;
+ }
+ }
+
+ // No match, check for UTF-8
+ // from http://w3.org/International/questions/qa-forms-utf-8.html
+ if (preg_match('/\A(
+ [\x09\x0A\x0D\x20-\x7E]
+ | [\xC2-\xDF][\x80-\xBF]
+ | \xE0[\xA0-\xBF][\x80-\xBF]
+ | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}
+ | \xED[\x80-\x9F][\x80-\xBF]
+ | \xF0[\x90-\xBF][\x80-\xBF]{2}
+ | [\xF1-\xF3][\x80-\xBF]{3}
+ | \xF4[\x80-\x8F][\x80-\xBF]{2}
+ )*\z/xs', substr($string, 0, 2048))
+ ) {
+ return 'UTF-8';
+ }
+
+ return $failover;
+ }
+
+ /**
+ * Removes non-unicode characters from input.
+ * If the input is an array, both values and keys will be cleaned up.
+ *
+ * @param mixed $input String or array.
+ *
+ * @return mixed String or array
+ */
+ public static function clean($input)
+ {
+ // handle input of type array
+ if (is_array($input)) {
+ foreach (array_keys($input) as $key) {
+ $k = is_string($key) ? self::clean($key) : $key;
+ $v = self::clean($input[$key]);
+
+ if ($k !== $key) {
+ unset($input[$key]);
+ if (!array_key_exists($k, $input)) {
+ $input[$k] = $v;
+ }
+ }
+ else {
+ $input[$k] = $v;
+ }
+ }
+ return $input;
+ }
+
+ if (!is_string($input) || $input == '') {
+ return $input;
+ }
+
+ $msch = mb_substitute_character();
+ mb_substitute_character('none');
+ $res = mb_convert_encoding($input, 'UTF-8', 'UTF-8');
+ mb_substitute_character($msch);
+
+ return $res;
+ }
+}
diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php
--- a/src/include/rcube_imap_generic.php
+++ b/src/include/rcube_imap_generic.php
@@ -2933,7 +2933,7 @@
$chunk = $this->decodeContent($chunk, $mode, $bytes <= 0, $prev);
if ($file) {
- if (fwrite($file, $chunk) === false) {
+ if (($result = fwrite($file, $chunk)) === false) {
break;
}
} elseif ($print) {
@@ -2948,7 +2948,7 @@
if ($result !== false) {
if ($file) {
- return fwrite($file, $result);
+ return is_string($result) ? fwrite($file, $result) !== false : true;
} elseif ($print) {
echo $result;
return true;
diff --git a/src/include/rcube_message_header.php b/src/include/rcube_message_header.php
new file mode 100644
--- /dev/null
+++ b/src/include/rcube_message_header.php
@@ -0,0 +1,338 @@
+<?php
+
+/**
+ +-----------------------------------------------------------------------+
+ | This file is part of the Roundcube Webmail client |
+ | |
+ | Copyright (C) The Roundcube Dev Team |
+ | Copyright (C) Kolab Systems AG |
+ | |
+ | Licensed under the GNU General Public License version 3 or |
+ | any later version with exceptions for skins & plugins. |
+ | See the README file for a full license statement. |
+ | |
+ | PURPOSE: |
+ | E-mail message headers representation |
+ +-----------------------------------------------------------------------+
+ | Author: Aleksander Machniak <alec@alec.pl> |
+ +-----------------------------------------------------------------------+
+*/
+
+/**
+ * Struct representing an e-mail message header
+ *
+ * @package Framework
+ * @subpackage Storage
+ */
+class rcube_message_header
+{
+ /**
+ * Message sequence number
+ *
+ * @var int
+ */
+ public $id;
+
+ /**
+ * Message unique identifier
+ *
+ * @var int
+ */
+ public $uid;
+
+ /**
+ * Message subject
+ *
+ * @var string
+ */
+ public $subject;
+
+ /**
+ * Message sender (From)
+ *
+ * @var string
+ */
+ public $from;
+
+ /**
+ * Message recipient (To)
+ *
+ * @var string
+ */
+ public $to;
+
+ /**
+ * Message additional recipients (Cc)
+ *
+ * @var string
+ */
+ public $cc;
+
+ /**
+ * Message Reply-To header
+ *
+ * @var string
+ */
+ public $replyto;
+
+ /**
+ * Message In-Reply-To header
+ *
+ * @var string
+ */
+ public $in_reply_to;
+
+ /**
+ * Message date (Date)
+ *
+ * @var string
+ */
+ public $date;
+
+ /**
+ * Message identifier (Message-ID)
+ *
+ * @var string
+ */
+ public $messageID;
+
+ /**
+ * Message size
+ *
+ * @var int
+ */
+ public $size;
+
+ /**
+ * Message encoding
+ *
+ * @var string
+ */
+ public $encoding;
+
+ /**
+ * Message charset
+ *
+ * @var string
+ */
+ public $charset;
+
+ /**
+ * Message Content-type
+ *
+ * @var string
+ */
+ public $ctype;
+
+ /**
+ * Message timestamp (based on message date)
+ *
+ * @var int
+ */
+ public $timestamp;
+
+ /**
+ * IMAP bodystructure string
+ *
+ * @var string
+ */
+ public $bodystructure;
+
+ /**
+ * IMAP internal date
+ *
+ * @var string
+ */
+ public $internaldate;
+
+ /**
+ * Message References header
+ *
+ * @var string
+ */
+ public $references;
+
+ /**
+ * Message priority (X-Priority)
+ *
+ * @var int
+ */
+ public $priority;
+
+ /**
+ * Message receipt recipient
+ *
+ * @var string
+ */
+ public $mdn_to;
+
+ /**
+ * IMAP folder this message is stored in
+ *
+ * @var string
+ */
+ public $folder;
+
+ /**
+ * Other message headers
+ *
+ * @var array
+ */
+ public $others = [];
+
+ /**
+ * Message flags
+ *
+ * @var array
+ */
+ public $flags = [];
+
+ /**
+ * Header name to rcube_message_header object property map
+ *
+ * @var array
+ */
+ private $obj_headers = [
+ 'date' => 'date',
+ 'from' => 'from',
+ 'to' => 'to',
+ 'subject' => 'subject',
+ 'reply-to' => 'replyto',
+ 'cc' => 'cc',
+ 'bcc' => 'bcc',
+ 'mbox' => 'folder',
+ 'folder' => 'folder',
+ 'content-transfer-encoding' => 'encoding',
+ 'in-reply-to' => 'in_reply_to',
+ 'content-type' => 'ctype',
+ 'charset' => 'charset',
+ 'references' => 'references',
+ 'disposition-notification-to' => 'mdn_to',
+ 'x-confirm-reading-to' => 'mdn_to',
+ 'message-id' => 'messageID',
+ 'x-priority' => 'priority',
+ ];
+
+ /**
+ * Returns header value
+ *
+ * @param string $name Header name
+ * @param bool $decode Decode the header content
+ *
+ * @param string|null Header content
+ */
+ public function get($name, $decode = true)
+ {
+ $name = strtolower($name);
+ $value = null;
+
+ if (isset($this->obj_headers[$name]) && isset($this->{$this->obj_headers[$name]})) {
+ $value = $this->{$this->obj_headers[$name]};
+ }
+ else if (isset($this->others[$name])) {
+ $value = $this->others[$name];
+ }
+
+ if ($decode && $value !== null) {
+ if (is_array($value)) {
+ foreach ($value as $key => $val) {
+ $val = rcube_mime::decode_header($val, $this->charset);
+ $value[$key] = rcube_charset::clean($val);
+ }
+ }
+ else {
+ $value = rcube_mime::decode_header($value, $this->charset);
+ $value = rcube_charset::clean($value);
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Sets header value
+ *
+ * @param string $name Header name
+ * @param string $value Header content
+ */
+ public function set($name, $value)
+ {
+ $name = strtolower($name);
+
+ if (isset($this->obj_headers[$name])) {
+ $this->{$this->obj_headers[$name]} = $value;
+ }
+ else {
+ $this->others[$name] = $value;
+ }
+ }
+
+ /**
+ * Factory method to instantiate headers from a data array
+ *
+ * @param array $arr Hash array with header values
+ *
+ * @return rcube_message_header instance filled with headers values
+ */
+ public static function from_array($arr)
+ {
+ $obj = new rcube_message_header;
+ foreach ($arr as $k => $v) {
+ $obj->set($k, $v);
+ }
+
+ return $obj;
+ }
+}
+
+
+/**
+ * Class for sorting an array of rcube_message_header objects in a predetermined order.
+ *
+ * @package Framework
+ * @subpackage Storage
+ */
+class rcube_message_header_sorter
+{
+ /** @var array Message UIDs */
+ private $uids = [];
+
+
+ /**
+ * Set the predetermined sort order.
+ *
+ * @param array $index Numerically indexed array of IMAP UIDs
+ */
+ function set_index($index)
+ {
+ $index = array_flip($index);
+
+ $this->uids = $index;
+ }
+
+ /**
+ * Sort the array of header objects
+ *
+ * @param array $headers Array of rcube_message_header objects indexed by UID
+ */
+ function sort_headers(&$headers)
+ {
+ uksort($headers, [$this, "compare_uids"]);
+ }
+
+ /**
+ * Sort method called by uksort()
+ *
+ * @param int $a Array key (UID)
+ * @param int $b Array key (UID)
+ */
+ function compare_uids($a, $b)
+ {
+ // then find each sequence number in my ordered list
+ $posa = isset($this->uids[$a]) ? intval($this->uids[$a]) : -1;
+ $posb = isset($this->uids[$b]) ? intval($this->uids[$b]) : -1;
+
+ // return the relative position as the comparison value
+ return $posa - $posb;
+ }
+}
diff --git a/src/include/rcube_mime.php b/src/include/rcube_mime.php
new file mode 100644
--- /dev/null
+++ b/src/include/rcube_mime.php
@@ -0,0 +1,992 @@
+<?php
+
+/**
+ +-----------------------------------------------------------------------+
+ | This file is part of the Roundcube Webmail client |
+ | |
+ | Copyright (C) The Roundcube Dev Team |
+ | Copyright (C) Kolab Systems AG |
+ | |
+ | Licensed under the GNU General Public License version 3 or |
+ | any later version with exceptions for skins & plugins. |
+ | See the README file for a full license statement. |
+ | |
+ | PURPOSE: |
+ | MIME message parsing utilities |
+ +-----------------------------------------------------------------------+
+ | Author: Thomas Bruederli <roundcube@gmail.com> |
+ | Author: Aleksander Machniak <alec@alec.pl> |
+ +-----------------------------------------------------------------------+
+*/
+
+/**
+ * Class for parsing MIME messages
+ *
+ * @package Framework
+ * @subpackage Storage
+ */
+class rcube_mime
+{
+ private static $default_charset;
+
+
+ /**
+ * Object constructor.
+ */
+ function __construct($default_charset = null)
+ {
+ self::$default_charset = $default_charset;
+ }
+
+ /**
+ * Returns message/object character set name
+ *
+ * @return string Character set name
+ */
+ public static function get_charset()
+ {
+ if (self::$default_charset) {
+ return self::$default_charset;
+ }
+
+ if ($charset = rcube::get_instance()->config->get('default_charset')) {
+ return $charset;
+ }
+
+ return RCUBE_CHARSET;
+ }
+
+ /**
+ * Parse the given raw message source and return a structure
+ * of rcube_message_part objects.
+ *
+ * It makes use of the rcube_mime_decode library
+ *
+ * @param string $raw_body The message source
+ *
+ * @return object rcube_message_part The message structure
+ */
+ public static function parse_message($raw_body)
+ {
+ $conf = [
+ 'include_bodies' => true,
+ 'decode_bodies' => true,
+ 'decode_headers' => false,
+ 'default_charset' => self::get_charset(),
+ ];
+
+ $mime = new rcube_mime_decode($conf);
+
+ return $mime->decode($raw_body);
+ }
+
+ /**
+ * Split an address list into a structured array list
+ *
+ * @param string|array $input Input string (or list of strings)
+ * @param int $max List only this number of addresses
+ * @param bool $decode Decode address strings
+ * @param string $fallback Fallback charset if none specified
+ * @param bool $addronly Return flat array with e-mail addresses only
+ *
+ * @return array Indexed list of addresses
+ */
+ static function decode_address_list($input, $max = null, $decode = true, $fallback = null, $addronly = false)
+ {
+ // A common case when the same header is used many times in a mail message
+ if (is_array($input)) {
+ $input = implode(', ', $input);
+ }
+
+ $a = self::parse_address_list($input, $decode, $fallback);
+ $out = [];
+ $j = 0;
+
+ // Special chars as defined by RFC 822 need to in quoted string (or escaped).
+ $special_chars = '[\(\)\<\>\\\.\[\]@,;:"]';
+
+ if (!is_array($a)) {
+ return $out;
+ }
+
+ foreach ($a as $val) {
+ $j++;
+ $address = trim($val['address']);
+
+ if ($addronly) {
+ $out[$j] = $address;
+ }
+ else {
+ $name = trim($val['name']);
+ $string = '';
+
+ if ($name && $address && $name != $address) {
+ $string = sprintf('%s <%s>', preg_match("/$special_chars/", $name) ? '"'.addcslashes($name, '"').'"' : $name, $address);
+ }
+ else if ($address) {
+ $string = $address;
+ }
+ else if ($name) {
+ $string = $name;
+ }
+
+ $out[$j] = ['name' => $name, 'mailto' => $address, 'string' => $string];
+ }
+
+ if ($max && $j == $max) {
+ break;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Decode a message header value
+ *
+ * @param string $input Header value
+ * @param string $fallback Fallback charset if none specified
+ *
+ * @return string Decoded string
+ */
+ public static function decode_header($input, $fallback = null)
+ {
+ $str = self::decode_mime_string((string)$input, $fallback);
+
+ return $str;
+ }
+
+ /**
+ * Decode a mime-encoded string to internal charset
+ *
+ * @param string $input Header value
+ * @param string $fallback Fallback charset if none specified
+ *
+ * @return string Decoded string
+ */
+ public static function decode_mime_string($input, $fallback = null)
+ {
+ $default_charset = $fallback ?: self::get_charset();
+
+ // rfc: all line breaks or other characters not found
+ // in the Base64 Alphabet must be ignored by decoding software
+ // delete all blanks between MIME-lines, differently we can
+ // receive unnecessary blanks and broken utf-8 symbols
+ $input = preg_replace("/\?=\s+=\?/", '?==?', $input);
+
+ // encoded-word regexp
+ $re = '/=\?([^?]+)\?([BbQq])\?([^\n]*?)\?=/';
+
+ // Find all RFC2047's encoded words
+ if (preg_match_all($re, $input, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
+ // Initialize variables
+ $tmp = [];
+ $out = '';
+ $start = 0;
+
+ foreach ($matches as $idx => $m) {
+ $pos = $m[0][1];
+ $charset = $m[1][0];
+ $encoding = $m[2][0];
+ $text = $m[3][0];
+ $length = strlen($m[0][0]);
+
+ // Append everything that is before the text to be decoded
+ if ($start != $pos) {
+ $substr = substr($input, $start, $pos-$start);
+ $out .= rcube_charset::convert($substr, $default_charset);
+ $start = $pos;
+ }
+ $start += $length;
+
+ // Per RFC2047, each string part "MUST represent an integral number
+ // of characters . A multi-octet character may not be split across
+ // adjacent encoded-words." However, some mailers break this, so we
+ // try to handle characters spanned across parts anyway by iterating
+ // through and aggregating sequential encoded parts with the same
+ // character set and encoding, then perform the decoding on the
+ // aggregation as a whole.
+
+ $tmp[] = $text;
+ if (!empty($matches[$idx+1]) && ($next_match = $matches[$idx+1])) {
+ if ($next_match[0][1] == $start
+ && $next_match[1][0] == $charset
+ && $next_match[2][0] == $encoding
+ ) {
+ continue;
+ }
+ }
+
+ $count = count($tmp);
+ $text = '';
+
+ // Decode and join encoded-word's chunks
+ if ($encoding == 'B' || $encoding == 'b') {
+ $rest = '';
+ // base64 must be decoded a segment at a time.
+ // However, there are broken implementations that continue
+ // in the following word, we'll handle that (#6048)
+ for ($i=0; $i<$count; $i++) {
+ $chunk = $rest . $tmp[$i];
+ $length = strlen($chunk);
+ if ($length % 4) {
+ $length = floor($length / 4) * 4;
+ $rest = substr($chunk, $length);
+ $chunk = substr($chunk, 0, $length);
+ }
+
+ $text .= base64_decode($chunk);
+ }
+ }
+ else { // if ($encoding == 'Q' || $encoding == 'q') {
+ // quoted printable can be combined and processed at once
+ for ($i=0; $i<$count; $i++) {
+ $text .= $tmp[$i];
+ }
+
+ $text = str_replace('_', ' ', $text);
+ $text = quoted_printable_decode($text);
+ }
+
+ $out .= rcube_charset::convert($text, $charset);
+ $tmp = [];
+ }
+
+ // add the last part of the input string
+ if ($start != strlen($input)) {
+ $out .= rcube_charset::convert(substr($input, $start), $default_charset);
+ }
+
+ // return the results
+ return $out;
+ }
+
+ // no encoding information, use fallback
+ return rcube_charset::convert($input, $default_charset);
+ }
+
+ /**
+ * Decode a mime part
+ *
+ * @param string $input Input string
+ * @param string $encoding Part encoding
+ *
+ * @return string Decoded string
+ */
+ public static function decode($input, $encoding = '7bit')
+ {
+ switch (strtolower($encoding)) {
+ case 'quoted-printable':
+ return quoted_printable_decode($input);
+ case 'base64':
+ return base64_decode($input);
+ case 'x-uuencode':
+ case 'x-uue':
+ case 'uue':
+ case 'uuencode':
+ return convert_uudecode($input);
+ case '7bit':
+ default:
+ return $input;
+ }
+ }
+
+ /**
+ * Split RFC822 header string into an associative array
+ */
+ public static function parse_headers($headers)
+ {
+ $result = [];
+ $headers = preg_replace('/\r?\n(\t| )+/', ' ', $headers);
+ $lines = explode("\n", $headers);
+ $count = count($lines);
+
+ for ($i=0; $i<$count; $i++) {
+ if ($p = strpos($lines[$i], ': ')) {
+ $field = strtolower(substr($lines[$i], 0, $p));
+ $value = trim(substr($lines[$i], $p+1));
+ if (!empty($value)) {
+ $result[$field] = $value;
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * E-mail address list parser
+ */
+ private static function parse_address_list($str, $decode = true, $fallback = null)
+ {
+ // remove any newlines and carriage returns before
+ $str = $str === null ? null : preg_replace('/\r?\n(\s|\t)?/', ' ', $str);
+
+ // extract list items, remove comments
+ $str = self::explode_header_string(',;', $str, true);
+
+ // simplified regexp, supporting quoted local part
+ $email_rx = '([^\s:]+|("\s*(?:[^"\f\n\r\t\v\b\s]+\s*)+"))@\S+';
+
+ $result = [];
+
+ foreach ($str as $key => $val) {
+ $name = '';
+ $address = '';
+ $val = trim($val);
+
+ // First token might be a group name, ignore it
+ $tokens = self::explode_header_string(' ', $val);
+ if (isset($tokens[0]) && $tokens[0][strlen($tokens[0])-1] == ':') {
+ $val = substr($val, strlen($tokens[0]));
+ }
+
+ if (preg_match('/(.*)<('.$email_rx.')$/', $val, $m)) {
+ // Note: There are cases like "Test<test@domain.tld" with no closing bracket,
+ // therefor we do not include it in the regexp above, but we have to
+ // remove it later, because $email_rx will catch it (#8164)
+ $address = rtrim($m[2], '>');
+ $name = trim($m[1]);
+ }
+ else if (preg_match('/^('.$email_rx.')$/', $val, $m)) {
+ $address = $m[1];
+ $name = '';
+ }
+ // special case (#1489092)
+ else if (preg_match('/(\s*<MAILER-DAEMON>)$/', $val, $m)) {
+ $address = 'MAILER-DAEMON';
+ $name = substr($val, 0, -strlen($m[1]));
+ }
+ else if (preg_match('/('.$email_rx.')/', $val, $m)) {
+ $name = $m[1];
+ }
+ else {
+ $name = $val;
+ }
+
+ // unquote and/or decode name
+ if ($name) {
+ // An unquoted name ending with colon is a address group name, ignore it
+ if ($name[strlen($name)-1] == ':') {
+ $name = '';
+ }
+
+ if (strlen($name) > 1 && $name[0] == '"' && $name[strlen($name)-1] == '"') {
+ $name = substr($name, 1, -1);
+ $name = stripslashes($name);
+ }
+
+ if ($decode) {
+ $name = self::decode_header($name, $fallback);
+ // some clients encode addressee name with quotes around it
+ if (strlen($name) > 1 && $name[0] == '"' && $name[strlen($name)-1] == '"') {
+ $name = substr($name, 1, -1);
+ }
+ }
+ }
+
+ if (!$address && $name) {
+ $address = $name;
+ $name = '';
+ }
+
+ if ($address) {
+ $address = self::fix_email($address);
+ $result[$key] = ['name' => $name, 'address' => $address];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Explodes header (e.g. address-list) string into array of strings
+ * using specified separator characters with proper handling
+ * of quoted-strings and comments (RFC2822)
+ *
+ * @param string $separator String containing separator characters
+ * @param string $str Header string
+ * @param bool $remove_comments Enable to remove comments
+ *
+ * @return array Header items
+ */
+ public static function explode_header_string($separator, $str, $remove_comments = false)
+ {
+ $length = strlen($str);
+ $result = [];
+ $quoted = false;
+ $comment = 0;
+ $out = '';
+
+ for ($i=0; $i<$length; $i++) {
+ // we're inside a quoted string
+ if ($quoted) {
+ if ($str[$i] == '"') {
+ $quoted = false;
+ }
+ else if ($str[$i] == "\\") {
+ if ($comment <= 0) {
+ $out .= "\\";
+ }
+ $i++;
+ }
+ }
+ // we are inside a comment string
+ else if ($comment > 0) {
+ if ($str[$i] == ')') {
+ $comment--;
+ }
+ else if ($str[$i] == '(') {
+ $comment++;
+ }
+ else if ($str[$i] == "\\") {
+ $i++;
+ }
+ continue;
+ }
+ // separator, add to result array
+ else if (strpos($separator, $str[$i]) !== false) {
+ if ($out) {
+ $result[] = $out;
+ }
+ $out = '';
+ continue;
+ }
+ // start of quoted string
+ else if ($str[$i] == '"') {
+ $quoted = true;
+ }
+ // start of comment
+ else if ($remove_comments && $str[$i] == '(') {
+ $comment++;
+ }
+
+ if ($comment <= 0) {
+ $out .= $str[$i];
+ }
+ }
+
+ if ($out && $comment <= 0) {
+ $result[] = $out;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Interpret a format=flowed message body according to RFC 2646
+ *
+ * @param string $text Raw body formatted as flowed text
+ * @param string $mark Mark each flowed line with specified character
+ * @param bool $delsp Remove the trailing space of each flowed line
+ *
+ * @return string Interpreted text with unwrapped lines and stuffed space removed
+ */
+ public static function unfold_flowed($text, $mark = null, $delsp = false)
+ {
+ $text = preg_split('/\r?\n/', $text);
+ $last = -1;
+ $q_level = 0;
+ $marks = [];
+
+ foreach ($text as $idx => $line) {
+ if ($q = strspn($line, '>')) {
+ // remove quote chars
+ $line = substr($line, $q);
+ // remove (optional) space-staffing
+ if (isset($line[0]) && $line[0] === ' ') {
+ $line = substr($line, 1);
+ }
+
+ // The same paragraph (We join current line with the previous one) when:
+ // - the same level of quoting
+ // - previous line was flowed
+ // - previous line contains more than only one single space (and quote char(s))
+ if ($q == $q_level
+ && isset($text[$last]) && $text[$last][strlen($text[$last])-1] == ' '
+ && !preg_match('/^>+ {0,1}$/', $text[$last])
+ ) {
+ if ($delsp) {
+ $text[$last] = substr($text[$last], 0, -1);
+ }
+ $text[$last] .= $line;
+ unset($text[$idx]);
+
+ if ($mark) {
+ $marks[$last] = true;
+ }
+ }
+ else {
+ $last = $idx;
+ }
+ }
+ else {
+ if ($line == '-- ') {
+ $last = $idx;
+ }
+ else {
+ // remove space-stuffing
+ if (isset($line[0]) && $line[0] === ' ') {
+ $line = substr($line, 1);
+ }
+
+ $last_len = isset($text[$last]) ? strlen($text[$last]) : 0;
+
+ if (
+ $last_len && $line && !$q_level && $text[$last] != '-- '
+ && isset($text[$last][$last_len-1]) && $text[$last][$last_len-1] == ' '
+ ) {
+ if ($delsp) {
+ $text[$last] = substr($text[$last], 0, -1);
+ }
+ $text[$last] .= $line;
+ unset($text[$idx]);
+
+ if ($mark) {
+ $marks[$last] = true;
+ }
+ }
+ else {
+ $text[$idx] = $line;
+ $last = $idx;
+ }
+ }
+ }
+ $q_level = $q;
+ }
+
+ if (!empty($marks)) {
+ foreach (array_keys($marks) as $mk) {
+ $text[$mk] = $mark . $text[$mk];
+ }
+ }
+
+ return implode("\r\n", $text);
+ }
+
+ /**
+ * Wrap the given text to comply with RFC 2646
+ *
+ * @param string $text Text to wrap
+ * @param int $length Length
+ * @param string $charset Character encoding of $text
+ *
+ * @return string Wrapped text
+ */
+ public static function format_flowed($text, $length = 72, $charset = null)
+ {
+ $text = preg_split('/\r?\n/', $text);
+
+ foreach ($text as $idx => $line) {
+ if ($line != '-- ') {
+ if ($level = strspn($line, '>')) {
+ // remove quote chars
+ $line = substr($line, $level);
+ // remove (optional) space-staffing and spaces before the line end
+ $line = rtrim($line, ' ');
+ if (isset($line[0]) && $line[0] === ' ') {
+ $line = substr($line, 1);
+ }
+
+ $prefix = str_repeat('>', $level) . ' ';
+ $line = $prefix . self::wordwrap($line, $length - $level - 2, " \r\n$prefix", false, $charset);
+ }
+ else if ($line) {
+ $line = self::wordwrap(rtrim($line), $length - 2, " \r\n", false, $charset);
+ // space-stuffing
+ $line = preg_replace('/(^|\r\n)(From| |>)/', '\\1 \\2', $line);
+ }
+
+ $text[$idx] = $line;
+ }
+ }
+
+ return implode("\r\n", $text);
+ }
+
+ /**
+ * Improved wordwrap function with multibyte support.
+ * The code is based on Zend_Text_MultiByte::wordWrap().
+ *
+ * @param string $string Text to wrap
+ * @param int $width Line width
+ * @param string $break Line separator
+ * @param bool $cut Enable to cut word
+ * @param string $charset Charset of $string
+ * @param bool $wrap_quoted When enabled quoted lines will not be wrapped
+ *
+ * @return string Text
+ */
+ public static function wordwrap($string, $width = 75, $break = "\n", $cut = false, $charset = null, $wrap_quoted = true)
+ {
+ // Note: Never try to use iconv instead of mbstring functions here
+ // Iconv's substr/strlen are 100x slower (#1489113)
+
+ if ($charset && $charset != RCUBE_CHARSET) {
+ $charset = rcube_charset::parse_charset($charset);
+ mb_internal_encoding($charset);
+ }
+
+ // Convert \r\n to \n, this is our line-separator
+ $string = str_replace("\r\n", "\n", $string);
+ $separator = "\n"; // must be 1 character length
+ $result = [];
+
+ while (($stringLength = mb_strlen($string)) > 0) {
+ $breakPos = mb_strpos($string, $separator, 0);
+
+ // quoted line (do not wrap)
+ if ($wrap_quoted && $string[0] == '>') {
+ if ($breakPos === $stringLength - 1 || $breakPos === false) {
+ $subString = $string;
+ $cutLength = null;
+ }
+ else {
+ $subString = mb_substr($string, 0, $breakPos);
+ $cutLength = $breakPos + 1;
+ }
+ }
+ // next line found and current line is shorter than the limit
+ else if ($breakPos !== false && $breakPos < $width) {
+ if ($breakPos === $stringLength - 1) {
+ $subString = $string;
+ $cutLength = null;
+ }
+ else {
+ $subString = mb_substr($string, 0, $breakPos);
+ $cutLength = $breakPos + 1;
+ }
+ }
+ else {
+ $subString = mb_substr($string, 0, $width);
+
+ // last line
+ if ($breakPos === false && $subString === $string) {
+ $cutLength = null;
+ }
+ else {
+ $nextChar = mb_substr($string, $width, 1);
+
+ if ($nextChar === ' ' || $nextChar === $separator) {
+ $afterNextChar = mb_substr($string, $width + 1, 1);
+
+ // Note: mb_substr() does never return False
+ if ($afterNextChar === false || $afterNextChar === '') {
+ $subString .= $nextChar;
+ }
+
+ $cutLength = mb_strlen($subString) + 1;
+ }
+ else {
+ $spacePos = mb_strrpos($subString, ' ', 0);
+
+ if ($spacePos !== false) {
+ $subString = mb_substr($subString, 0, $spacePos);
+ $cutLength = $spacePos + 1;
+ }
+ else if ($cut === false) {
+ $spacePos = mb_strpos($string, ' ', 0);
+
+ if ($spacePos !== false && ($breakPos === false || $spacePos < $breakPos)) {
+ $subString = mb_substr($string, 0, $spacePos);
+ $cutLength = $spacePos + 1;
+ }
+ else if ($breakPos === false) {
+ $subString = $string;
+ $cutLength = null;
+ }
+ else {
+ $subString = mb_substr($string, 0, $breakPos);
+ $cutLength = $breakPos + 1;
+ }
+ }
+ else {
+ $cutLength = $width;
+ }
+ }
+ }
+ }
+
+ $result[] = $subString;
+
+ if ($cutLength !== null) {
+ $string = mb_substr($string, $cutLength, ($stringLength - $cutLength));
+ }
+ else {
+ break;
+ }
+ }
+
+ if ($charset && $charset != RCUBE_CHARSET) {
+ mb_internal_encoding(RCUBE_CHARSET);
+ }
+
+ return implode($break, $result);
+ }
+
+ /**
+ * A method to guess the mime_type of an attachment.
+ *
+ * @param string $path Path to the file or file contents
+ * @param string $name File name (with suffix)
+ * @param string $failover Mime type supplied for failover
+ * @param bool $is_stream Set to True if $path contains file contents
+ * @param bool $skip_suffix Set to True if the config/mimetypes.php map should be ignored
+ *
+ * @return string
+ * @author Till Klampaeckel <till@php.net>
+ * @see http://de2.php.net/manual/en/ref.fileinfo.php
+ * @see http://de2.php.net/mime_content_type
+ */
+ public static function file_content_type($path, $name, $failover = 'application/octet-stream', $is_stream = false, $skip_suffix = false)
+ {
+ $mime_type = null;
+ $config = rcube::get_instance()->config;
+
+ // Detect mimetype using filename extension
+ if (!$skip_suffix) {
+ $mime_type = self::file_ext_type($name);
+ }
+
+ // try fileinfo extension if available
+ if (!$mime_type && function_exists('finfo_open')) {
+ $mime_magic = $config->get('mime_magic');
+ // null as a 2nd argument should be the same as no argument
+ // this however is not true on all systems/versions
+ if ($mime_magic) {
+ $finfo = finfo_open(FILEINFO_MIME, $mime_magic);
+ }
+ else {
+ $finfo = finfo_open(FILEINFO_MIME);
+ }
+
+ if ($finfo) {
+ $func = $is_stream ? 'finfo_buffer' : 'finfo_file';
+ $mime_type = $func($finfo, $path, FILEINFO_MIME_TYPE);
+ finfo_close($finfo);
+ }
+ }
+
+ // try PHP's mime_content_type
+ if (!$mime_type && !$is_stream && function_exists('mime_content_type')) {
+ $mime_type = @mime_content_type($path);
+ }
+
+ // fall back to user-submitted string
+ if (!$mime_type) {
+ $mime_type = $failover;
+ }
+
+ return $mime_type;
+ }
+
+ /**
+ * File type detection based on file name only.
+ *
+ * @param string $filename Path to the file or file contents
+ *
+ * @return string|null Mimetype label
+ */
+ public static function file_ext_type($filename)
+ {
+ static $mime_ext = [];
+
+ if (empty($mime_ext)) {
+ foreach (rcube::get_instance()->config->resolve_paths('mimetypes.php') as $fpath) {
+ $mime_ext = array_merge($mime_ext, (array) @include($fpath));
+ }
+ }
+
+ // use file name suffix with hard-coded mime-type map
+ if (!empty($mime_ext) && $filename) {
+ $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+ if ($ext && !empty($mime_ext[$ext])) {
+ return $mime_ext[$ext];
+ }
+ }
+ }
+
+ /**
+ * Get mimetype => file extension mapping
+ *
+ * @param string Mime-Type to get extensions for
+ *
+ * @return array List of extensions matching the given mimetype or a hash array
+ * with ext -> mimetype mappings if $mimetype is not given
+ */
+ public static function get_mime_extensions($mimetype = null)
+ {
+ static $mime_types, $mime_extensions;
+
+ // return cached data
+ if (is_array($mime_types)) {
+ return $mimetype ? (isset($mime_types[$mimetype]) ? $mime_types[$mimetype] : []) : $mime_extensions;
+ }
+
+ // load mapping file
+ $file_paths = [];
+
+ if ($mime_types = rcube::get_instance()->config->get('mime_types')) {
+ $file_paths[] = $mime_types;
+ }
+
+ // try common locations
+ if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
+ $file_paths[] = 'C:/xampp/apache/conf/mime.types.';
+ }
+ else {
+ $file_paths[] = '/etc/mime.types';
+ $file_paths[] = '/etc/httpd/mime.types';
+ $file_paths[] = '/etc/httpd2/mime.types';
+ $file_paths[] = '/etc/apache/mime.types';
+ $file_paths[] = '/etc/apache2/mime.types';
+ $file_paths[] = '/etc/nginx/mime.types';
+ $file_paths[] = '/usr/local/etc/httpd/conf/mime.types';
+ $file_paths[] = '/usr/local/etc/apache/conf/mime.types';
+ $file_paths[] = '/usr/local/etc/apache24/mime.types';
+ }
+
+ $mime_types = [];
+ $mime_extensions = [];
+ $lines = [];
+ $regex = "/([\w\+\-\.\/]+)\s+([\w\s]+)/i";
+
+ foreach ($file_paths as $fp) {
+ if (@is_readable($fp)) {
+ $lines = file($fp, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+ break;
+ }
+ }
+
+ foreach ($lines as $line) {
+ // skip comments or mime types w/o any extensions
+ if ($line[0] == '#' || !preg_match($regex, $line, $matches)) {
+ continue;
+ }
+
+ $mime = $matches[1];
+
+ foreach (explode(' ', $matches[2]) as $ext) {
+ $ext = trim($ext);
+ $mime_types[$mime][] = $ext;
+ $mime_extensions[$ext] = $mime;
+ }
+ }
+
+ // fallback to some well-known types most important for daily emails
+ if (empty($mime_types)) {
+ foreach (rcube::get_instance()->config->resolve_paths('mimetypes.php') as $fpath) {
+ $mime_extensions = array_merge($mime_extensions, (array) @include($fpath));
+ }
+
+ foreach ($mime_extensions as $ext => $mime) {
+ $mime_types[$mime][] = $ext;
+ }
+ }
+
+ // Add some known aliases that aren't included by some mime.types (#1488891)
+ // the order is important here so standard extensions have higher prio
+ $aliases = [
+ 'image/gif' => ['gif'],
+ 'image/png' => ['png'],
+ 'image/x-png' => ['png'],
+ 'image/jpeg' => ['jpg', 'jpeg', 'jpe'],
+ 'image/jpg' => ['jpg', 'jpeg', 'jpe'],
+ 'image/pjpeg' => ['jpg', 'jpeg', 'jpe'],
+ 'image/tiff' => ['tif'],
+ 'image/bmp' => ['bmp'],
+ 'image/x-ms-bmp' => ['bmp'],
+ 'message/rfc822' => ['eml'],
+ 'text/x-mail' => ['eml'],
+ ];
+
+ foreach ($aliases as $mime => $exts) {
+ if (isset($mime_types[$mime])) {
+ $mime_types[$mime] = array_unique(array_merge((array) $mime_types[$mime], $exts));
+ }
+ else {
+ $mime_types[$mime] = $exts;
+ }
+
+ foreach ($exts as $ext) {
+ if (!isset($mime_extensions[$ext])) {
+ $mime_extensions[$ext] = $mime;
+ }
+ }
+ }
+
+ if ($mimetype) {
+ return !empty($mime_types[$mimetype]) ? $mime_types[$mimetype] : [];
+ }
+
+ return $mime_extensions;
+ }
+
+ /**
+ * Detect image type of the given binary data by checking magic numbers.
+ *
+ * @param string $data Binary file content
+ *
+ * @return string Detected mime-type or jpeg as fallback
+ */
+ public static function image_content_type($data)
+ {
+ $type = 'jpeg';
+ if (preg_match('/^\x89\x50\x4E\x47/', $data)) $type = 'png';
+ else if (preg_match('/^\x47\x49\x46\x38/', $data)) $type = 'gif';
+ else if (preg_match('/^\x00\x00\x01\x00/', $data)) $type = 'ico';
+ // else if (preg_match('/^\xFF\xD8\xFF\xE0/', $data)) $type = 'jpeg';
+
+ return 'image/' . $type;
+ }
+
+ /**
+ * Try to fix invalid email addresses
+ */
+ public static function fix_email($email)
+ {
+ $parts = rcube_utils::explode_quoted_string('@', $email);
+
+ foreach ($parts as $idx => $part) {
+ // remove redundant quoting (#1490040)
+ if ($part[0] == '"' && preg_match('/^"([a-zA-Z0-9._+=-]+)"$/', $part, $m)) {
+ $parts[$idx] = $m[1];
+ }
+ }
+
+ return implode('@', $parts);
+ }
+
+ /**
+ * Fix mimetype name.
+ *
+ * @param string $type Mimetype
+ *
+ * @return string Mimetype
+ */
+ public static function fix_mimetype($type)
+ {
+ $type = strtolower(trim($type));
+ $aliases = [
+ 'image/x-ms-bmp' => 'image/bmp', // #4771
+ 'pdf' => 'application/pdf', // #6816
+ ];
+
+ if (!empty($aliases[$type])) {
+ return $aliases[$type];
+ }
+
+ // Some versions of Outlook create garbage Content-Type:
+ // application/pdf.A520491B_3BF7_494D_8855_7FAC2C6C0608
+ if (preg_match('/^application\/pdf.+/', $type)) {
+ return 'application/pdf';
+ }
+
+ // treat image/pjpeg (image/pjpg, image/jpg) as image/jpeg (#4196)
+ if (preg_match('/^image\/p?jpe?g$/', $type)) {
+ return 'image/jpeg';
+ }
+
+ return $type;
+ }
+}
diff --git a/src/include/rcube_result_index.php b/src/include/rcube_result_index.php
new file mode 100644
--- /dev/null
+++ b/src/include/rcube_result_index.php
@@ -0,0 +1,463 @@
+<?php
+
+/**
+ +-----------------------------------------------------------------------+
+ | This file is part of the Roundcube Webmail client |
+ | |
+ | Copyright (C) The Roundcube Dev Team |
+ | Copyright (C) Kolab Systems AG |
+ | |
+ | Licensed under the GNU General Public License version 3 or |
+ | any later version with exceptions for skins & plugins. |
+ | See the README file for a full license statement. |
+ | |
+ | PURPOSE: |
+ | SORT/SEARCH/ESEARCH response handler |
+ +-----------------------------------------------------------------------+
+ | Author: Thomas Bruederli <roundcube@gmail.com> |
+ | Author: Aleksander Machniak <alec@alec.pl> |
+ +-----------------------------------------------------------------------+
+*/
+
+/**
+ * Get first element from an array
+ *
+ * @param array $array Input array
+ *
+ * @return mixed First element if found, Null otherwise
+ */
+function array_first($array)
+{
+ if (is_array($array)) {
+ reset($array);
+ foreach ($array as $element) {
+ return $element;
+ }
+ }
+}
+
+/**
+ * Class for accessing IMAP's SORT/SEARCH/ESEARCH result
+ *
+ * @package Framework
+ * @subpackage Storage
+ */
+class rcube_result_index
+{
+ public $incomplete = false;
+
+ protected $raw_data;
+ protected $mailbox;
+ protected $meta = [];
+ protected $params = [];
+ protected $order = 'ASC';
+
+ const SEPARATOR_ELEMENT = ' ';
+
+
+ /**
+ * Object constructor.
+ */
+ public function __construct($mailbox = null, $data = null, $order = null)
+ {
+ $this->mailbox = $mailbox;
+ $this->order = $order == 'DESC' ? 'DESC' : 'ASC';
+ $this->init($data);
+ }
+
+ /**
+ * Initializes object with SORT command response
+ *
+ * @param string $data IMAP response string
+ */
+ public function init($data = null)
+ {
+ $this->meta = [];
+
+ $data = explode('*', (string)$data);
+
+ // ...skip unilateral untagged server responses
+ for ($i=0, $len=count($data); $i<$len; $i++) {
+ $data_item = &$data[$i];
+ if (preg_match('/^ SORT/i', $data_item)) {
+ // valid response, initialize raw_data for is_error()
+ $this->raw_data = '';
+ $data_item = substr($data_item, 5);
+ break;
+ }
+ else if (preg_match('/^ (E?SEARCH)/i', $data_item, $m)) {
+ // valid response, initialize raw_data for is_error()
+ $this->raw_data = '';
+ $data_item = substr($data_item, strlen($m[0]));
+
+ if (strtoupper($m[1]) == 'ESEARCH') {
+ $data_item = trim($data_item);
+ // remove MODSEQ response
+ if (preg_match('/\(MODSEQ ([0-9]+)\)$/i', $data_item, $m)) {
+ $data_item = substr($data_item, 0, -strlen($m[0]));
+ $this->params['MODSEQ'] = $m[1];
+ }
+ // remove TAG response part
+ if (preg_match('/^\(TAG ["a-z0-9]+\)\s*/i', $data_item, $m)) {
+ $data_item = substr($data_item, strlen($m[0]));
+ }
+ // remove UID
+ $data_item = preg_replace('/^UID\s*/i', '', $data_item);
+
+ // ESEARCH parameters
+ while (preg_match('/^([a-z]+) ([0-9:,]+)\s*/i', $data_item, $m)) {
+ $param = strtoupper($m[1]);
+ $value = $m[2];
+
+ $this->params[$param] = $value;
+ $data_item = substr($data_item, strlen($m[0]));
+
+ if (in_array($param, ['COUNT', 'MIN', 'MAX'])) {
+ $this->meta[strtolower($param)] = (int) $value;
+ }
+ }
+
+// @TODO: Implement compression using compressMessageSet() in __sleep() and __wakeup() ?
+// @TODO: work with compressed result?!
+ if (isset($this->params['ALL'])) {
+ $data_item = implode(self::SEPARATOR_ELEMENT,
+ rcube_imap_generic::uncompressMessageSet($this->params['ALL']));
+ }
+ }
+
+ break;
+ }
+
+ unset($data[$i]);
+ }
+
+ $data = array_filter($data);
+
+ if (empty($data)) {
+ return;
+ }
+
+ $data = array_first($data);
+ $data = trim($data);
+ $data = preg_replace('/[\r\n]/', '', $data);
+ $data = preg_replace('/\s+/', ' ', $data);
+
+ $this->raw_data = $data;
+ }
+
+ /**
+ * Checks the result from IMAP command
+ *
+ * @return bool True if the result is an error, False otherwise
+ */
+ public function is_error()
+ {
+ return $this->raw_data === null;
+ }
+
+ /**
+ * Checks if the result is empty
+ *
+ * @return bool True if the result is empty, False otherwise
+ */
+ public function is_empty()
+ {
+ return empty($this->raw_data)
+ && empty($this->meta['max']) && empty($this->meta['min']) && empty($this->meta['count']);
+ }
+
+ /**
+ * Returns number of elements in the result
+ *
+ * @return int Number of elements
+ */
+ public function count()
+ {
+ if (isset($this->meta['count'])) {
+ return $this->meta['count'];
+ }
+
+ if (empty($this->raw_data)) {
+ $this->meta['count'] = 0;
+ $this->meta['length'] = 0;
+ }
+ else {
+ $this->meta['count'] = 1 + substr_count($this->raw_data, self::SEPARATOR_ELEMENT);
+ }
+
+ return $this->meta['count'];
+ }
+
+ /**
+ * Returns number of elements in the result.
+ * Alias for count() for compatibility with rcube_result_thread
+ *
+ * @return int Number of elements
+ */
+ public function count_messages()
+ {
+ return $this->count();
+ }
+
+ /**
+ * Returns maximal message identifier in the result
+ *
+ * @return int|null Maximal message identifier
+ */
+ public function max()
+ {
+ if ($this->is_empty()) {
+ return null;
+ }
+
+ if (!isset($this->meta['max'])) {
+ $this->meta['max'] = null;
+ $all = $this->get();
+ if (!empty($all)) {
+ $this->meta['max'] = (int) max($all);
+ }
+ }
+
+ return $this->meta['max'];
+ }
+
+ /**
+ * Returns minimal message identifier in the result
+ *
+ * @return int|null Minimal message identifier
+ */
+ public function min()
+ {
+ if ($this->is_empty()) {
+ return null;
+ }
+
+ if (!isset($this->meta['min'])) {
+ $this->meta['min'] = null;
+ $all = $this->get();
+ if (!empty($all)) {
+ $this->meta['min'] = (int) min($all);
+ }
+ }
+
+ return $this->meta['min'];
+ }
+
+ /**
+ * Slices data set.
+ *
+ * @param int $offset Offset (as for PHP's array_slice())
+ * @param int $length Number of elements (as for PHP's array_slice())
+ */
+ public function slice($offset, $length)
+ {
+ $data = $this->get();
+ $data = array_slice($data, $offset, $length);
+
+ $this->meta = [];
+ $this->meta['count'] = count($data);
+ $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data);
+ }
+
+ /**
+ * Filters data set. Removes elements not listed in $ids list.
+ *
+ * @param array $ids List of IDs to remove.
+ */
+ public function filter($ids = [])
+ {
+ $data = $this->get();
+ $data = array_intersect($data, $ids);
+
+ $this->meta = [];
+ $this->meta['count'] = count($data);
+ $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data);
+ }
+
+ /**
+ * Reverts order of elements in the result
+ */
+ public function revert()
+ {
+ $this->order = $this->order == 'ASC' ? 'DESC' : 'ASC';
+
+ if (empty($this->raw_data)) {
+ return;
+ }
+
+ $data = $this->get();
+ $data = array_reverse($data);
+ $this->raw_data = implode(self::SEPARATOR_ELEMENT, $data);
+
+ $this->meta['pos'] = [];
+ }
+
+ /**
+ * Check if the given message ID exists in the object
+ *
+ * @param int $msgid Message ID
+ * @param bool $get_index When enabled element's index will be returned.
+ * Elements are indexed starting with 0
+ *
+ * @return mixed False if message ID doesn't exist, True if exists or
+ * index of the element if $get_index=true
+ */
+ public function exists($msgid, $get_index = false)
+ {
+ if (empty($this->raw_data)) {
+ return false;
+ }
+
+ $msgid = (int) $msgid;
+ $begin = implode('|', ['^', preg_quote(self::SEPARATOR_ELEMENT, '/')]);
+ $end = implode('|', ['$', preg_quote(self::SEPARATOR_ELEMENT, '/')]);
+
+ if (preg_match("/($begin)$msgid($end)/", $this->raw_data, $m,
+ $get_index ? PREG_OFFSET_CAPTURE : null)
+ ) {
+ if ($get_index) {
+ $idx = 0;
+ if (!empty($m[0][1])) {
+ $idx = 1 + substr_count($this->raw_data, self::SEPARATOR_ELEMENT, 0, $m[0][1]);
+ }
+ // cache position of this element, so we can use it in get_element()
+ $this->meta['pos'][$idx] = (int)$m[0][1];
+
+ return $idx;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return all messages in the result.
+ *
+ * @return array List of message IDs
+ */
+ public function get()
+ {
+ if (empty($this->raw_data)) {
+ return [];
+ }
+
+ return explode(self::SEPARATOR_ELEMENT, $this->raw_data);
+ }
+
+ /**
+ * Return all messages in the result.
+ *
+ * @return array List of message IDs
+ */
+ public function get_compressed()
+ {
+ if (empty($this->raw_data)) {
+ return '';
+ }
+
+ return rcube_imap_generic::compressMessageSet($this->get());
+ }
+
+ /**
+ * Return result element at specified index
+ *
+ * @param int|string $index Element's index or "FIRST" or "LAST"
+ *
+ * @return int|null Element value
+ */
+ public function get_element($index)
+ {
+ if (empty($this->raw_data)) {
+ return null;
+ }
+
+ $count = $this->count();
+
+ // first element
+ if ($index === 0 || $index === '0' || $index === 'FIRST') {
+ $pos = strpos($this->raw_data, self::SEPARATOR_ELEMENT);
+ if ($pos === false) {
+ $result = (int) $this->raw_data;
+ }
+ else {
+ $result = (int) substr($this->raw_data, 0, $pos);
+ }
+
+ return $result;
+ }
+
+ // last element
+ if ($index === 'LAST' || $index == $count-1) {
+ $pos = strrpos($this->raw_data, self::SEPARATOR_ELEMENT);
+ if ($pos === false) {
+ $result = (int) $this->raw_data;
+ }
+ else {
+ $result = (int) substr($this->raw_data, $pos);
+ }
+
+ return $result;
+ }
+
+ // do we know the position of the element or the neighbour of it?
+ if (!empty($this->meta['pos'])) {
+ if (isset($this->meta['pos'][$index])) {
+ $pos = $this->meta['pos'][$index];
+ }
+ else if (isset($this->meta['pos'][$index-1])) {
+ $pos = strpos($this->raw_data, self::SEPARATOR_ELEMENT,
+ $this->meta['pos'][$index-1] + 1);
+ }
+ else if (isset($this->meta['pos'][$index+1])) {
+ $pos = strrpos($this->raw_data, self::SEPARATOR_ELEMENT,
+ $this->meta['pos'][$index+1] - $this->length() - 1);
+ }
+
+ if (isset($pos) && preg_match('/([0-9]+)/', $this->raw_data, $m, null, $pos)) {
+ return (int) $m[1];
+ }
+ }
+
+ // Finally use less effective method
+ $data = explode(self::SEPARATOR_ELEMENT, $this->raw_data);
+
+ return (int) $data[$index];
+ }
+
+ /**
+ * Returns response parameters, e.g. ESEARCH's MIN/MAX/COUNT/ALL/MODSEQ
+ * or internal data e.g. MAILBOX, ORDER
+ *
+ * @param string $param Parameter name
+ *
+ * @return array|string Response parameters or parameter value
+ */
+ public function get_parameters($param=null)
+ {
+ $params = $this->params;
+ $params['MAILBOX'] = $this->mailbox;
+ $params['ORDER'] = $this->order;
+
+ if ($param !== null) {
+ return $params[$param];
+ }
+
+ return $params;
+ }
+
+ /**
+ * Returns length of internal data representation
+ *
+ * @return int Data length
+ */
+ protected function length()
+ {
+ if (!isset($this->meta['length'])) {
+ $this->meta['length'] = strlen($this->raw_data);
+ }
+
+ return $this->meta['length'];
+ }
+}
diff --git a/src/include/rcube_utils.php b/src/include/rcube_utils.php
new file mode 100644
--- /dev/null
+++ b/src/include/rcube_utils.php
@@ -0,0 +1,1715 @@
+<?php
+
+/**
+ +-----------------------------------------------------------------------+
+ | This file is part of the Roundcube Webmail client |
+ | |
+ | Copyright (C) The Roundcube Dev Team |
+ | Copyright (C) Kolab Systems AG |
+ | |
+ | Licensed under the GNU General Public License version 3 or |
+ | any later version with exceptions for skins & plugins. |
+ | See the README file for a full license statement. |
+ | |
+ | PURPOSE: |
+ | Utility class providing common functions |
+ +-----------------------------------------------------------------------+
+ | Author: Thomas Bruederli <roundcube@gmail.com> |
+ | Author: Aleksander Machniak <alec@alec.pl> |
+ +-----------------------------------------------------------------------+
+*/
+
+/**
+ * Utility class providing common functions
+ *
+ * @package Framework
+ * @subpackage Utils
+ */
+class rcube_utils
+{
+ // define constants for input reading
+ const INPUT_GET = 1;
+ const INPUT_POST = 2;
+ const INPUT_COOKIE = 4;
+ const INPUT_GP = 3; // GET + POST
+ const INPUT_GPC = 7; // GET + POST + COOKIE
+
+
+ /**
+ * A wrapper for PHP's explode() that does not throw a warning
+ * when the separator does not exist in the string
+ *
+ * @param string $separator Separator string
+ * @param string $string The string to explode
+ *
+ * @return array Exploded string. Still an array if there's no separator in the string
+ */
+ public static function explode($separator, $string)
+ {
+ if (strpos($string, $separator) !== false) {
+ return explode($separator, $string);
+ }
+
+ return [$string, null];
+ }
+
+ /**
+ * Helper method to set a cookie with the current path and host settings
+ *
+ * @param string $name Cookie name
+ * @param string $value Cookie value
+ * @param int $exp Expiration time
+ * @param bool $http_only HTTP Only
+ */
+ public static function setcookie($name, $value, $exp = 0, $http_only = true)
+ {
+ if (headers_sent()) {
+ return;
+ }
+
+ $attrib = session_get_cookie_params();
+ $attrib['expires'] = $exp;
+ $attrib['secure'] = $attrib['secure'] || self::https_check();
+ $attrib['httponly'] = $http_only;
+
+ // session_get_cookie_params() return includes 'lifetime' but setcookie() does not use it, instead it uses 'expires'
+ unset($attrib['lifetime']);
+
+ if (version_compare(PHP_VERSION, '7.3.0', '>=')) {
+ // An alternative signature for setcookie supporting an options array added in PHP 7.3.0
+ setcookie($name, $value, $attrib);
+ }
+ else {
+ setcookie($name, $value, $attrib['expires'], $attrib['path'], $attrib['domain'], $attrib['secure'], $attrib['httponly']);
+ }
+ }
+
+ /**
+ * E-mail address validation.
+ *
+ * @param string $email Email address
+ * @param bool $dns_check True to check dns
+ *
+ * @return bool True on success, False if address is invalid
+ */
+ public static function check_email($email, $dns_check = true)
+ {
+ // Check for invalid (control) characters
+ if (preg_match('/\p{Cc}/u', $email)) {
+ return false;
+ }
+
+ // Check for length limit specified by RFC 5321 (#1486453)
+ if (strlen($email) > 254) {
+ return false;
+ }
+
+ $pos = strrpos($email, '@');
+ if (!$pos) {
+ return false;
+ }
+
+ $domain_part = substr($email, $pos + 1);
+ $local_part = substr($email, 0, $pos);
+
+ // quoted-string, make sure all backslashes and quotes are
+ // escaped
+ if (substr($local_part, 0, 1) == '"') {
+ $local_quoted = preg_replace('/\\\\(\\\\|\")/','', substr($local_part, 1, -1));
+ if (preg_match('/\\\\|"/', $local_quoted)) {
+ return false;
+ }
+ }
+ // dot-atom portion, make sure there's no prohibited characters
+ else if (preg_match('/(^\.|\.\.|\.$)/', $local_part)
+ || preg_match('/[\\ ",:;<>@]/', $local_part)
+ ) {
+ return false;
+ }
+
+ // Validate domain part
+ if (preg_match('/^\[((IPv6:[0-9a-f:.]+)|([0-9.]+))\]$/i', $domain_part, $matches)) {
+ return self::check_ip(preg_replace('/^IPv6:/i', '', $matches[1])); // valid IPv4 or IPv6 address
+ }
+ else {
+ // If not an IP address
+ $domain_array = explode('.', $domain_part);
+ // Not enough parts to be a valid domain
+ if (count($domain_array) < 2) {
+ return false;
+ }
+
+ foreach ($domain_array as $part) {
+ if (!preg_match('/^((xn--)?([A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9])|([A-Za-z0-9]))$/', $part)) {
+ return false;
+ }
+ }
+
+ // last domain part (allow extended TLD)
+ $last_part = array_pop($domain_array);
+ if (strpos($last_part, 'xn--') !== 0
+ && (preg_match('/[^a-zA-Z0-9]/', $last_part) || preg_match('/^[0-9]+$/', $last_part))
+ ) {
+ return false;
+ }
+
+ $rcube = rcube::get_instance();
+
+ if (!$dns_check || !function_exists('checkdnsrr') || !$rcube->config->get('email_dns_check')) {
+ return true;
+ }
+
+ // Check DNS record(s)
+ // Note: We can't use ANY (#6581)
+ foreach (['A', 'MX', 'CNAME', 'AAAA'] as $type) {
+ if (checkdnsrr($domain_part, $type)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Validates IPv4 or IPv6 address
+ *
+ * @param string $ip IP address in v4 or v6 format
+ *
+ * @return bool True if the address is valid
+ */
+ public static function check_ip($ip)
+ {
+ return filter_var($ip, FILTER_VALIDATE_IP) !== false;
+ }
+
+ /**
+ * Replacing specials characters to a specific encoding type
+ *
+ * @param string $str Input string
+ * @param string $enctype Encoding type: text|html|xml|js|url
+ * @param string $mode Replace mode for tags: show|remove|strict
+ * @param bool $newlines Convert newlines
+ *
+ * @return string The quoted string
+ */
+ public static function rep_specialchars_output($str, $enctype = '', $mode = '', $newlines = true)
+ {
+ static $html_encode_arr = false;
+ static $js_rep_table = false;
+ static $xml_rep_table = false;
+
+ if (!is_string($str)) {
+ $str = strval($str);
+ }
+
+ // encode for HTML output
+ if ($enctype == 'html') {
+ if (!$html_encode_arr) {
+ $html_encode_arr = get_html_translation_table(HTML_SPECIALCHARS);
+ unset($html_encode_arr['?']);
+ }
+
+ $encode_arr = $html_encode_arr;
+
+ if ($mode == 'remove') {
+ $str = strip_tags($str);
+ }
+ else if ($mode != 'strict') {
+ // don't replace quotes and html tags
+ $ltpos = strpos($str, '<');
+ if ($ltpos !== false && strpos($str, '>', $ltpos) !== false) {
+ unset($encode_arr['"']);
+ unset($encode_arr['<']);
+ unset($encode_arr['>']);
+ unset($encode_arr['&']);
+ }
+ }
+
+ $out = strtr($str, $encode_arr);
+
+ return $newlines ? nl2br($out) : $out;
+ }
+
+ // if the replace tables for XML and JS are not yet defined
+ if ($js_rep_table === false) {
+ $js_rep_table = $xml_rep_table = [];
+ $xml_rep_table['&'] = '&';
+
+ // can be increased to support more charsets
+ for ($c=160; $c<256; $c++) {
+ $xml_rep_table[chr($c)] = "&#$c;";
+ }
+
+ $xml_rep_table['"'] = '"';
+ $js_rep_table['"'] = '\\"';
+ $js_rep_table["'"] = "\\'";
+ $js_rep_table["\\"] = "\\\\";
+ // Unicode line and paragraph separators (#1486310)
+ $js_rep_table[chr(hexdec('E2')).chr(hexdec('80')).chr(hexdec('A8'))] = '
';
+ $js_rep_table[chr(hexdec('E2')).chr(hexdec('80')).chr(hexdec('A9'))] = '
';
+ }
+
+ // encode for javascript use
+ if ($enctype == 'js') {
+ return preg_replace(["/\r?\n/", "/\r/", '/<\\//'], ['\n', '\n', '<\\/'], strtr($str, $js_rep_table));
+ }
+
+ // encode for plaintext
+ if ($enctype == 'text') {
+ return str_replace("\r\n", "\n", $mode == 'remove' ? strip_tags($str) : $str);
+ }
+
+ if ($enctype == 'url') {
+ return rawurlencode($str);
+ }
+
+ // encode for XML
+ if ($enctype == 'xml') {
+ return strtr($str, $xml_rep_table);
+ }
+
+ // no encoding given -> return original string
+ return $str;
+ }
+
+ /**
+ * Read input value and make sure it is a string.
+ *
+ * @param string $fname Field name to read
+ * @param int $source Source to get value from (see self::INPUT_*)
+ * @param bool $allow_html Allow HTML tags in field value
+ * @param string $charset Charset to convert into
+ *
+ * @return string Request parameter value
+ * @see self::get_input_value()
+ */
+ public static function get_input_string($fname, $source, $allow_html = false, $charset = null)
+ {
+ $value = self::get_input_value($fname, $source, $allow_html, $charset);
+
+ return is_string($value) ? $value : '';
+ }
+
+ /**
+ * Read request parameter value and convert it for internal use
+ * Performs stripslashes() and charset conversion if necessary
+ *
+ * @param string $fname Field name to read
+ * @param int $source Source to get value from (see self::INPUT_*)
+ * @param bool $allow_html Allow HTML tags in field value
+ * @param string $charset Charset to convert into
+ *
+ * @return string|array|null Request parameter value or NULL if not set
+ */
+ public static function get_input_value($fname, $source, $allow_html = false, $charset = null)
+ {
+ $value = null;
+
+ if (($source & self::INPUT_GET) && isset($_GET[$fname])) {
+ $value = $_GET[$fname];
+ }
+
+ if (($source & self::INPUT_POST) && isset($_POST[$fname])) {
+ $value = $_POST[$fname];
+ }
+
+ if (($source & self::INPUT_COOKIE) && isset($_COOKIE[$fname])) {
+ $value = $_COOKIE[$fname];
+ }
+
+ return self::parse_input_value($value, $allow_html, $charset);
+ }
+
+ /**
+ * Parse/validate input value. See self::get_input_value()
+ * Performs stripslashes() and charset conversion if necessary
+ *
+ * @param string $value Input value
+ * @param bool $allow_html Allow HTML tags in field value
+ * @param string $charset Charset to convert into
+ *
+ * @return string Parsed value
+ */
+ public static function parse_input_value($value, $allow_html = false, $charset = null)
+ {
+ if (empty($value)) {
+ return $value;
+ }
+
+ if (is_array($value)) {
+ foreach ($value as $idx => $val) {
+ $value[$idx] = self::parse_input_value($val, $allow_html, $charset);
+ }
+
+ return $value;
+ }
+
+ // remove HTML tags if not allowed
+ if (!$allow_html) {
+ $value = strip_tags($value);
+ }
+
+ $rcube = rcube::get_instance();
+ $output_charset = is_object($rcube->output) ? $rcube->output->get_charset() : null;
+
+ // remove invalid characters (#1488124)
+ if ($output_charset == 'UTF-8') {
+ $value = rcube_charset::clean($value);
+ }
+
+ // convert to internal charset
+ if ($charset && $output_charset) {
+ $value = rcube_charset::convert($value, $output_charset, $charset);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Convert array of request parameters (prefixed with _)
+ * to a regular array with non-prefixed keys.
+ *
+ * @param int $mode Source to get value from (GPC)
+ * @param string $ignore PCRE expression to skip parameters by name
+ * @param bool $allow_html Allow HTML tags in field value
+ *
+ * @return array Hash array with all request parameters
+ */
+ public static function request2param($mode = null, $ignore = 'task|action', $allow_html = false)
+ {
+ $out = [];
+ $src = $mode == self::INPUT_GET ? $_GET : ($mode == self::INPUT_POST ? $_POST : $_REQUEST);
+
+ foreach (array_keys($src) as $key) {
+ $fname = $key[0] == '_' ? substr($key, 1) : $key;
+ if ($ignore && !preg_match('/^(' . $ignore . ')$/', $fname)) {
+ $out[$fname] = self::get_input_value($key, $mode, $allow_html);
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Convert the given string into a valid HTML identifier
+ * Same functionality as done in app.js with rcube_webmail.html_identifier()
+ *
+ * @param string $str String input
+ * @param bool $encode Use base64 encoding
+ *
+ * @param string Valid HTML identifier
+ */
+ public static function html_identifier($str, $encode = false)
+ {
+ if ($encode) {
+ return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
+ }
+
+ return asciiwords($str, true, '_');
+ }
+
+ /**
+ * Replace all css definitions with #container [def]
+ * and remove css-inlined scripting, make position style safe
+ *
+ * @param string $source CSS source code
+ * @param string $container_id Container ID to use as prefix
+ * @param bool $allow_remote Allow remote content
+ * @param string $prefix Prefix to be added to id/class identifier
+ *
+ * @return string Modified CSS source
+ */
+ public static function mod_css_styles($source, $container_id, $allow_remote = false, $prefix = '')
+ {
+ $last_pos = 0;
+ $replacements = new rcube_string_replacer;
+
+ // ignore the whole block if evil styles are detected
+ $source = self::xss_entity_decode($source);
+ $stripped = preg_replace('/[^a-z\(:;]/i', '', $source);
+ $evilexpr = 'expression|behavior|javascript:|import[^a]' . (!$allow_remote ? '|url\((?!data:image)' : '');
+
+ if (preg_match("/$evilexpr/i", $stripped)) {
+ return '/* evil! */';
+ }
+
+ $strict_url_regexp = '!url\s*\(\s*["\']?(https?:)//[a-z0-9/._+-]+["\']?\s*\)!Uims';
+
+ // remove html comments
+ $source = preg_replace('/(^\s*<\!--)|(-->\s*$)/m', '', $source);
+
+ // cut out all contents between { and }
+ while (($pos = strpos($source, '{', $last_pos)) && ($pos2 = strpos($source, '}', $pos))) {
+ $nested = strpos($source, '{', $pos+1);
+ if ($nested && $nested < $pos2) { // when dealing with nested blocks (e.g. @media), take the inner one
+ $pos = $nested;
+ }
+ $length = $pos2 - $pos - 1;
+ $styles = substr($source, $pos+1, $length);
+ $output = '';
+
+ // check every css rule in the style block...
+ foreach (self::parse_css_block($styles) as $rule) {
+ // Remove 'page' attributes (#7604)
+ if ($rule[0] == 'page') {
+ continue;
+ }
+
+ // Convert position:fixed to position:absolute (#5264)
+ if ($rule[0] == 'position' && strcasecmp($rule[1], 'fixed') === 0) {
+ $rule[1] = 'absolute';
+ }
+ else if ($allow_remote) {
+ $stripped = preg_replace('/[^a-z\(:;]/i', '', $rule[1]);
+
+ // allow data:image and strict url() values only
+ if (
+ stripos($stripped, 'url(') !== false
+ && stripos($stripped, 'url(data:image') === false
+ && !preg_match($strict_url_regexp, $rule[1])
+ ) {
+ $rule[1] = '/* evil! */';
+ }
+ }
+
+ $output .= sprintf(" %s: %s;", $rule[0] , $rule[1]);
+ }
+
+ $key = $replacements->add($output . ' ');
+ $repl = $replacements->get_replacement($key);
+ $source = substr_replace($source, $repl, $pos+1, $length);
+ $last_pos = $pos2 - ($length - strlen($repl));
+ }
+
+ // add #container to each tag selector and prefix to id/class identifiers
+ if ($container_id || $prefix) {
+ // Exclude rcube_string_replacer pattern matches, this is needed
+ // for cases like @media { body { position: fixed; } } (#5811)
+ $excl = '(?!' . substr($replacements->pattern, 1, -1) . ')';
+ $regexp = '/(^\s*|,\s*|\}\s*|\{\s*)(' . $excl . ':?[a-z0-9\._#\*\[][a-z0-9\._:\(\)#=~ \[\]"\|\>\+\$\^-]*)/im';
+ $callback = function($matches) use ($container_id, $prefix) {
+ $replace = $matches[2];
+
+ if (stripos($replace, ':root') === 0) {
+ $replace = substr($replace, 5);
+ }
+
+ if ($prefix) {
+ $replace = str_replace(['.', '#'], [".$prefix", "#$prefix"], $replace);
+ }
+
+ if ($container_id) {
+ $replace = "#$container_id " . $replace;
+ }
+
+ // Remove redundant spaces (for simpler testing)
+ $replace = preg_replace('/\s+/', ' ', $replace);
+
+ return str_replace($matches[2], $replace, $matches[0]);
+ };
+
+ $source = preg_replace_callback($regexp, $callback, $source);
+ }
+
+ // replace body definition because we also stripped off the <body> tag
+ if ($container_id) {
+ $regexp = '/#' . preg_quote($container_id, '/') . '\s+body/i';
+ $source = preg_replace($regexp, "#$container_id", $source);
+ }
+
+ // put block contents back in
+ $source = $replacements->resolve($source);
+
+ return $source;
+ }
+
+ /**
+ * Explode css style. Property names will be lower-cased and trimmed.
+ * Values will be trimmed. Invalid entries will be skipped.
+ *
+ * @param string $style CSS style
+ *
+ * @return array List of CSS rule pairs, e.g. [['color', 'red'], ['top', '0']]
+ */
+ public static function parse_css_block($style)
+ {
+ $pos = 0;
+
+ // first remove comments
+ while (($pos = strpos($style, '/*', $pos)) !== false) {
+ $end = strpos($style, '*/', $pos+2);
+
+ if ($end === false) {
+ $style = substr($style, 0, $pos);
+ }
+ else {
+ $style = substr_replace($style, '', $pos, $end - $pos + 2);
+ }
+ }
+
+ // Replace new lines with spaces
+ $style = preg_replace('/[\r\n]+/', ' ', $style);
+
+ $style = trim($style);
+ $length = strlen($style);
+ $result = [];
+ $pos = 0;
+
+ while ($pos < $length && ($colon_pos = strpos($style, ':', $pos))) {
+ // Property name
+ $name = strtolower(trim(substr($style, $pos, $colon_pos - $pos)));
+
+ // get the property value
+ $q = $s = false;
+ for ($i = $colon_pos + 1; $i < $length; $i++) {
+ if (($style[$i] == "\"" || $style[$i] == "'") && ($i == 0 || $style[$i-1] != "\\")) {
+ if ($q == $style[$i]) {
+ $q = false;
+ }
+ else if ($q === false) {
+ $q = $style[$i];
+ }
+ }
+ else if ($style[$i] == "(" && !$q && ($i == 0 || $style[$i-1] != "\\")) {
+ $q = "(";
+ }
+ else if ($style[$i] == ")" && $q == "(" && $style[$i-1] != "\\") {
+ $q = false;
+ }
+
+ if ($q === false && (($s = $style[$i] == ';') || $i == $length - 1)) {
+ break;
+ }
+ }
+
+ $value_length = $i - $colon_pos - ($s ? 1 : 0);
+ $value = trim(substr($style, $colon_pos + 1, $value_length));
+
+ if (strlen($name) && !preg_match('/[^a-z-]/', $name) && strlen($value) && $value !== ';') {
+ $result[] = [$name, $value];
+ }
+
+ $pos = $i + 1;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Generate CSS classes from mimetype and filename extension
+ *
+ * @param string $mimetype Mimetype
+ * @param string $filename Filename
+ *
+ * @return string CSS classes separated by space
+ */
+ public static function file2class($mimetype, $filename)
+ {
+ $mimetype = strtolower($mimetype);
+ $filename = strtolower($filename);
+
+ list($primary, $secondary) = rcube_utils::explode('/', $mimetype);
+
+ $classes = [$primary ?: 'unknown'];
+
+ if (!empty($secondary)) {
+ $classes[] = $secondary;
+ }
+
+ if (preg_match('/\.([a-z0-9]+)$/', $filename, $m)) {
+ if (!in_array($m[1], $classes)) {
+ $classes[] = $m[1];
+ }
+ }
+
+ return implode(' ', $classes);
+ }
+
+ /**
+ * Decode escaped entities used by known XSS exploits.
+ * See http://downloads.securityfocus.com/vulnerabilities/exploits/26800.eml for examples
+ *
+ * @param string $content CSS content to decode
+ *
+ * @return string Decoded string
+ */
+ public static function xss_entity_decode($content)
+ {
+ $callback = function($matches) { return chr(hexdec($matches[1])); };
+
+ $out = html_entity_decode(html_entity_decode($content));
+ $out = trim(preg_replace('/(^<!--|-->$)/', '', trim($out)));
+ $out = preg_replace_callback('/\\\([0-9a-f]{2,6})\s*/i', $callback, $out);
+ $out = preg_replace('/\\\([^0-9a-f])/i', '\\1', $out);
+ $out = preg_replace('#/\*.*\*/#Ums', '', $out);
+ $out = strip_tags($out);
+
+ return $out;
+ }
+
+ /**
+ * Check if we can process not exceeding memory_limit
+ *
+ * @param int $need Required amount of memory
+ *
+ * @return bool True if memory won't be exceeded, False otherwise
+ */
+ public static function mem_check($need)
+ {
+ $mem_limit = parse_bytes(ini_get('memory_limit'));
+ $memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB
+
+ return $mem_limit > 0 && $memory + $need > $mem_limit ? false : true;
+ }
+
+ /**
+ * Check if working in SSL mode
+ *
+ * @param int $port HTTPS port number
+ * @param bool $use_https Enables 'use_https' option checking
+ *
+ * @return bool True in SSL mode, False otherwise
+ */
+ public static function https_check($port = null, $use_https = true)
+ {
+ if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) != 'off') {
+ return true;
+ }
+
+ if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
+ && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https'
+ && in_array($_SERVER['REMOTE_ADDR'], (array) rcube::get_instance()->config->get('proxy_whitelist', []))
+ ) {
+ return true;
+ }
+
+ if ($port && isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == $port) {
+ return true;
+ }
+
+ if ($use_https && rcube::get_instance()->config->get('use_https')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Replaces hostname variables.
+ *
+ * @param string $name Hostname
+ * @param string $host Optional IMAP hostname
+ *
+ * @return string Hostname
+ */
+ public static function parse_host($name, $host = '')
+ {
+ if (!is_string($name)) {
+ return $name;
+ }
+
+ // %n - host
+ $n = self::server_name();
+ // %t - host name without first part, e.g. %n=mail.domain.tld, %t=domain.tld
+ // If %n=domain.tld then %t=domain.tld as well (remains valid)
+ $t = preg_replace('/^[^.]+\.(?![^.]+$)/', '', $n);
+ // %d - domain name without first part (up to domain.tld)
+ $d = preg_replace('/^[^.]+\.(?![^.]+$)/', '', self::server_name('HTTP_HOST'));
+ // %h - IMAP host
+ $h = !empty($_SESSION['storage_host']) ? $_SESSION['storage_host'] : $host;
+ // %z - IMAP domain without first part, e.g. %h=imap.domain.tld, %z=domain.tld
+ // If %h=domain.tld then %z=domain.tld as well (remains valid)
+ $z = preg_replace('/^[^.]+\.(?![^.]+$)/', '', $h);
+ // %s - domain name after the '@' from e-mail address provided at login screen.
+ // Returns FALSE if an invalid email is provided
+ $s = '';
+ if (strpos($name, '%s') !== false) {
+ $user_email = self::idn_to_ascii(self::get_input_value('_user', self::INPUT_POST));
+ $matches = preg_match('/(.*)@([a-z0-9\.\-\[\]\:]+)/i', $user_email, $s);
+ if ($matches < 1 || filter_var($s[1]."@".$s[2], FILTER_VALIDATE_EMAIL) === false) {
+ return false;
+ }
+ $s = $s[2];
+ }
+
+ return str_replace(['%n', '%t', '%d', '%h', '%z', '%s'], [$n, $t, $d, $h, $z, $s], $name);
+ }
+
+ /**
+ * Returns the server name after checking it against trusted hostname patterns.
+ *
+ * Returns 'localhost' and logs a warning when the hostname is not trusted.
+ *
+ * @param string $type The $_SERVER key, e.g. 'HTTP_HOST', Default: 'SERVER_NAME'.
+ * @param bool $strip_port Strip port from the host name
+ *
+ * @return string Server name
+ */
+ public static function server_name($type = null, $strip_port = true)
+ {
+ if (!$type) {
+ $type = 'SERVER_NAME';
+ }
+
+ $name = isset($_SERVER[$type]) ? $_SERVER[$type] : null;
+ $rcube = rcube::get_instance();
+ $patterns = (array) $rcube->config->get('trusted_host_patterns');
+
+ if (!empty($name)) {
+ if ($strip_port) {
+ $name = preg_replace('/:\d+$/', '', $name);
+ }
+
+ if (empty($patterns)) {
+ return $name;
+ }
+
+ foreach ($patterns as $pattern) {
+ // the pattern might be a regular expression or just a host/domain name
+ if (preg_match('/[^a-zA-Z0-9.:-]/', $pattern)) {
+ if (preg_match("/$pattern/", $name)) {
+ return $name;
+ }
+ }
+ else if (strtolower($name) === strtolower($pattern)) {
+ return $name;
+ }
+ }
+
+ $rcube->raise_error([
+ 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Specified host is not trusted. Using 'localhost'."
+ ]
+ , true, false
+ );
+ }
+
+ return 'localhost';
+ }
+
+ /**
+ * Returns remote IP address and forwarded addresses if found
+ *
+ * @return string Remote IP address(es)
+ */
+ public static function remote_ip()
+ {
+ $address = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
+
+ // append the NGINX X-Real-IP header, if set
+ if (!empty($_SERVER['HTTP_X_REAL_IP']) && $_SERVER['HTTP_X_REAL_IP'] != $address) {
+ $remote_ip[] = 'X-Real-IP: ' . $_SERVER['HTTP_X_REAL_IP'];
+ }
+
+ // append the X-Forwarded-For header, if set
+ if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+ $remote_ip[] = 'X-Forwarded-For: ' . $_SERVER['HTTP_X_FORWARDED_FOR'];
+ }
+
+ if (!empty($remote_ip)) {
+ $address .= ' (' . implode(',', $remote_ip) . ')';
+ }
+
+ return $address;
+ }
+
+ /**
+ * Returns the real remote IP address
+ *
+ * @return string Remote IP address
+ */
+ public static function remote_addr()
+ {
+ // Check if any of the headers are set first to improve performance
+ if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) || !empty($_SERVER['HTTP_X_REAL_IP'])) {
+ $proxy_whitelist = (array) rcube::get_instance()->config->get('proxy_whitelist', []);
+ if (in_array($_SERVER['REMOTE_ADDR'], $proxy_whitelist)) {
+ if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+ foreach (array_reverse(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])) as $forwarded_ip) {
+ $forwarded_ip = trim($forwarded_ip);
+ if (!in_array($forwarded_ip, $proxy_whitelist)) {
+ return $forwarded_ip;
+ }
+ }
+ }
+
+ if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
+ return $_SERVER['HTTP_X_REAL_IP'];
+ }
+ }
+ }
+
+ if (!empty($_SERVER['REMOTE_ADDR'])) {
+ return $_SERVER['REMOTE_ADDR'];
+ }
+
+ return '';
+ }
+
+ /**
+ * Read a specific HTTP request header.
+ *
+ * @param string $name Header name
+ *
+ * @return string|null Header value or null if not available
+ */
+ public static function request_header($name)
+ {
+ if (function_exists('apache_request_headers')) {
+ $headers = apache_request_headers();
+ $key = strtoupper($name);
+ }
+ else {
+ $headers = $_SERVER;
+ $key = 'HTTP_' . strtoupper(strtr($name, '-', '_'));
+ }
+
+ if (!empty($headers)) {
+ $headers = array_change_key_case($headers, CASE_UPPER);
+
+ return isset($headers[$key]) ? $headers[$key] : null;
+ }
+ }
+
+ /**
+ * Explode quoted string
+ *
+ * @param string $delimiter Delimiter expression string for preg_match()
+ * @param string $string Input string
+ *
+ * @return array String items
+ */
+ public static function explode_quoted_string($delimiter, $string)
+ {
+ $result = [];
+ $strlen = strlen($string);
+
+ for ($q=$p=$i=0; $i < $strlen; $i++) {
+ if ($string[$i] == "\"" && (!isset($string[$i-1]) || $string[$i-1] != "\\")) {
+ $q = $q ? false : true;
+ }
+ else if (!$q && preg_match("/$delimiter/", $string[$i])) {
+ $result[] = substr($string, $p, $i - $p);
+ $p = $i + 1;
+ }
+ }
+
+ $result[] = (string) substr($string, $p);
+
+ return $result;
+ }
+
+ /**
+ * Improved equivalent to strtotime()
+ *
+ * @param string $date Date string
+ * @param DateTimeZone $timezone Timezone to use for DateTime object
+ *
+ * @return int Unix timestamp
+ */
+ public static function strtotime($date, $timezone = null)
+ {
+ $date = self::clean_datestr($date);
+ $tzname = $timezone ? ' ' . $timezone->getName() : '';
+
+ // unix timestamp
+ if (is_numeric($date)) {
+ return (int) $date;
+ }
+
+ // It can be very slow when provided string is not a date and very long
+ if (strlen($date) > 128) {
+ $date = substr($date, 0, 128);
+ }
+
+ // if date parsing fails, we have a date in non-rfc format.
+ // remove token from the end and try again
+ while (($ts = @strtotime($date . $tzname)) === false || $ts < 0) {
+ if (($pos = strrpos($date, ' ')) === false) {
+ break;
+ }
+
+ $date = rtrim(substr($date, 0, $pos));
+ }
+
+ return (int) $ts;
+ }
+
+ /**
+ * Date parsing function that turns the given value into a DateTime object
+ *
+ * @param string $date Date string
+ * @param DateTimeZone $timezone Timezone to use for DateTime object
+ *
+ * @return DateTime|false DateTime object or False on failure
+ */
+ public static function anytodatetime($date, $timezone = null)
+ {
+ if ($date instanceof DateTime) {
+ return $date;
+ }
+
+ $dt = false;
+ $date = self::clean_datestr($date);
+
+ // try to parse string with DateTime first
+ if (!empty($date)) {
+ try {
+ $_date = preg_match('/^[0-9]+$/', $date) ? "@$date" : $date;
+ $dt = $timezone ? new DateTime($_date, $timezone) : new DateTime($_date);
+ }
+ catch (Exception $e) {
+ // ignore
+ }
+ }
+
+ // try our advanced strtotime() method
+ if (!$dt && ($timestamp = self::strtotime($date, $timezone))) {
+ try {
+ $dt = new DateTime("@".$timestamp);
+ if ($timezone) {
+ $dt->setTimezone($timezone);
+ }
+ }
+ catch (Exception $e) {
+ // ignore
+ }
+ }
+
+ return $dt;
+ }
+
+ /**
+ * Clean up date string for strtotime() input
+ *
+ * @param string $date Date string
+ *
+ * @return string Date string
+ */
+ public static function clean_datestr($date)
+ {
+ $date = trim($date);
+
+ // check for MS Outlook vCard date format YYYYMMDD
+ if (preg_match('/^([12][90]\d\d)([01]\d)([0123]\d)$/', $date, $m)) {
+ return sprintf('%04d-%02d-%02d 00:00:00', intval($m[1]), intval($m[2]), intval($m[3]));
+ }
+
+ // Clean malformed data
+ $date = preg_replace(
+ [
+ '/\(.*\)/', // remove RFC comments
+ '/GMT\s*([+-][0-9]+)/', // support non-standard "GMTXXXX" literal
+ '/[^a-z0-9\x20\x09:\/\.+-]/i', // remove any invalid characters
+ '/\s*(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s*/i', // remove weekday names
+ ],
+ [
+ '',
+ '\\1',
+ '',
+ '',
+ ],
+ $date
+ );
+
+ $date = trim($date);
+
+ // try to fix dd/mm vs. mm/dd discrepancy, we can't do more here
+ if (preg_match('/^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{4})(\s.*)?$/', $date, $m)) {
+ $mdy = $m[2] > 12 && $m[1] <= 12;
+ $day = $mdy ? $m[2] : $m[1];
+ $month = $mdy ? $m[1] : $m[2];
+ $date = sprintf('%04d-%02d-%02d%s', $m[3], $month, $day, isset($m[4]) ? $m[4]: ' 00:00:00');
+ }
+ // I've found that YYYY.MM.DD is recognized wrong, so here's a fix
+ else if (preg_match('/^(\d{4})\.(\d{1,2})\.(\d{1,2})(\s.*)?$/', $date, $m)) {
+ $date = sprintf('%04d-%02d-%02d%s', $m[1], $m[2], $m[3], isset($m[4]) ? $m[4]: ' 00:00:00');
+ }
+
+ return $date;
+ }
+
+ /**
+ * Turns the given date-only string in defined format into YYYY-MM-DD format.
+ *
+ * Supported formats: 'Y/m/d', 'Y.m.d', 'd-m-Y', 'd/m/Y', 'd.m.Y', 'j.n.Y'
+ *
+ * @param string $date Date string
+ * @param string $format Input date format
+ *
+ * @return string Date string in YYYY-MM-DD format, or the original string
+ * if format is not supported
+ */
+ public static function format_datestr($date, $format)
+ {
+ $format_items = preg_split('/[.-\/\\\\]/', $format);
+ $date_items = preg_split('/[.-\/\\\\]/', $date);
+ $iso_format = '%04d-%02d-%02d';
+
+ if (count($format_items) == 3 && count($date_items) == 3) {
+ if ($format_items[0] == 'Y') {
+ $date = sprintf($iso_format, $date_items[0], $date_items[1], $date_items[2]);
+ }
+ else if (strpos('dj', $format_items[0]) !== false) {
+ $date = sprintf($iso_format, $date_items[2], $date_items[1], $date_items[0]);
+ }
+ else if (strpos('mn', $format_items[0]) !== false) {
+ $date = sprintf($iso_format, $date_items[2], $date_items[0], $date_items[1]);
+ }
+ }
+
+ return $date;
+ }
+
+ /**
+ * Wrapper for idn_to_ascii with support for e-mail address.
+ *
+ * Warning: Domain names may be lowercase'd.
+ * Warning: An empty string may be returned on invalid domain.
+ *
+ * @param string $str Decoded e-mail address
+ *
+ * @return string Encoded e-mail address
+ */
+ public static function idn_to_ascii($str)
+ {
+ return self::idn_convert($str, true);
+ }
+
+ /**
+ * Wrapper for idn_to_utf8 with support for e-mail address
+ *
+ * @param string $str Decoded e-mail address
+ *
+ * @return string Encoded e-mail address
+ */
+ public static function idn_to_utf8($str)
+ {
+ return self::idn_convert($str, false);
+ }
+
+ /**
+ * Convert a string to ascii or utf8 (using IDNA standard)
+ *
+ * @param string $input Decoded e-mail address
+ * @param boolean $is_utf Convert by idn_to_ascii if true and idn_to_utf8 if false
+ *
+ * @return string Encoded e-mail address
+ */
+ public static function idn_convert($input, $is_utf = false)
+ {
+ if ($at = strpos($input, '@')) {
+ $user = substr($input, 0, $at);
+ $domain = substr($input, $at + 1);
+ }
+ else {
+ $user = '';
+ $domain = $input;
+ }
+
+ // Note that in PHP 7.2/7.3 calling idn_to_* functions with default arguments
+ // throws a warning, so we have to set the variant explicitly (#6075)
+ $variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : null;
+ $options = 0;
+
+ // Because php-intl extension lowercases domains and return false
+ // on invalid input (#6224), we skip conversion when not needed
+
+ if ($is_utf) {
+ if (preg_match('/[^\x20-\x7E]/', $domain)) {
+ $options = defined('IDNA_NONTRANSITIONAL_TO_ASCII') ? IDNA_NONTRANSITIONAL_TO_ASCII : 0;
+ $domain = idn_to_ascii($domain, $options, $variant);
+ }
+ }
+ else if (preg_match('/(^|\.)xn--/i', $domain)) {
+ $options = defined('IDNA_NONTRANSITIONAL_TO_UNICODE') ? IDNA_NONTRANSITIONAL_TO_UNICODE : 0;
+ $domain = idn_to_utf8($domain, $options, $variant);
+ }
+
+ if ($domain === false) {
+ return '';
+ }
+
+ return $at ? $user . '@' . $domain : $domain;
+ }
+
+ /**
+ * Split the given string into word tokens
+ *
+ * @param string $str Input to tokenize
+ * @param int $minlen Minimum length of a single token
+ *
+ * @return array List of tokens
+ */
+ public static function tokenize_string($str, $minlen = 2)
+ {
+ $expr = ['/[\s;,"\'\/+-]+/ui', '/(\d)[-.\s]+(\d)/u'];
+ $repl = [' ', '\\1\\2'];
+
+ if ($minlen > 1) {
+ $minlen--;
+ $expr[] = "/(^|\s+)\w{1,$minlen}(\s+|$)/u";
+ $repl[] = ' ';
+ }
+
+ return array_filter(explode(" ", preg_replace($expr, $repl, $str)));
+ }
+
+ /**
+ * Normalize the given string for fulltext search.
+ * Currently only optimized for ISO-8859-1 and ISO-8859-2 characters; to be extended
+ *
+ * @param string $str Input string (UTF-8)
+ * @param bool $as_array True to return list of words as array
+ * @param int $minlen Minimum length of tokens
+ *
+ * @return string|array Normalized string or a list of normalized tokens
+ */
+ public static function normalize_string($str, $as_array = false, $minlen = 2)
+ {
+ // replace 4-byte unicode characters with '?' character,
+ // these are not supported in default utf-8 charset on mysql,
+ // the chance we'd need them in searching is very low
+ $str = preg_replace('/('
+ . '\xF0[\x90-\xBF][\x80-\xBF]{2}'
+ . '|[\xF1-\xF3][\x80-\xBF]{3}'
+ . '|\xF4[\x80-\x8F][\x80-\xBF]{2}'
+ . ')/', '?', $str);
+
+ // split by words
+ $arr = self::tokenize_string($str, $minlen);
+
+ // detect character set
+ if (rcube_charset::convert(rcube_charset::convert($str, 'UTF-8', 'ISO-8859-1'), 'ISO-8859-1', 'UTF-8') == $str) {
+ // ISO-8859-1 (or ASCII)
+ preg_match_all('/./u', 'äâàåáãæçéêëèïîìíñöôòøõóüûùúýÿ', $keys);
+ preg_match_all('/./', 'aaaaaaaceeeeiiiinoooooouuuuyy', $values);
+
+ $mapping = array_combine($keys[0], $values[0]);
+ $mapping = array_merge($mapping, ['ß' => 'ss', 'ae' => 'a', 'oe' => 'o', 'ue' => 'u']);
+ }
+ else if (rcube_charset::convert(rcube_charset::convert($str, 'UTF-8', 'ISO-8859-2'), 'ISO-8859-2', 'UTF-8') == $str) {
+ // ISO-8859-2
+ preg_match_all('/./u', 'ąáâäćçčéęëěíîłľĺńňóôöŕřśšşťţůúűüźžżý', $keys);
+ preg_match_all('/./', 'aaaaccceeeeiilllnnooorrsssttuuuuzzzy', $values);
+
+ $mapping = array_combine($keys[0], $values[0]);
+ $mapping = array_merge($mapping, ['ß' => 'ss', 'ae' => 'a', 'oe' => 'o', 'ue' => 'u']);
+ }
+
+ foreach ($arr as $i => $part) {
+ $part = mb_strtolower($part);
+
+ if (!empty($mapping)) {
+ $part = strtr($part, $mapping);
+ }
+
+ $arr[$i] = $part;
+ }
+
+ return $as_array ? $arr : implode(' ', $arr);
+ }
+
+ /**
+ * Compare two strings for matching words (order not relevant)
+ *
+ * @param string $haystack Haystack
+ * @param string $needle Needle
+ *
+ * @return bool True if match, False otherwise
+ */
+ public static function words_match($haystack, $needle)
+ {
+ $a_needle = self::tokenize_string($needle, 1);
+ $_haystack = implode(' ', self::tokenize_string($haystack, 1));
+ $valid = strlen($_haystack) > 0;
+ $hits = 0;
+
+ foreach ($a_needle as $w) {
+ if ($valid) {
+ if (stripos($_haystack, $w) !== false) {
+ $hits++;
+ }
+ }
+ else if (stripos($haystack, $w) !== false) {
+ $hits++;
+ }
+ }
+
+ return $hits >= count($a_needle);
+ }
+
+ /**
+ * Parse commandline arguments into a hash array
+ *
+ * @param array $aliases Argument alias names
+ *
+ * @return array Argument values hash
+ */
+ public static function get_opt($aliases = [])
+ {
+ $args = [];
+ $bool = [];
+
+ // find boolean (no value) options
+ foreach ($aliases as $key => $alias) {
+ if ($pos = strpos($alias, ':')) {
+ $aliases[$key] = substr($alias, 0, $pos);
+ $bool[] = $key;
+ $bool[] = $aliases[$key];
+ }
+ }
+
+ for ($i=1; $i < count($_SERVER['argv']); $i++) {
+ $arg = $_SERVER['argv'][$i];
+ $value = true;
+ $key = null;
+
+ if ($arg[0] == '-') {
+ $key = preg_replace('/^-+/', '', $arg);
+ $sp = strpos($arg, '=');
+
+ if ($sp > 0) {
+ $key = substr($key, 0, $sp - 2);
+ $value = substr($arg, $sp+1);
+ }
+ else if (in_array($key, $bool)) {
+ $value = true;
+ }
+ else if (
+ isset($_SERVER['argv'][$i + 1])
+ && strlen($_SERVER['argv'][$i + 1])
+ && $_SERVER['argv'][$i + 1][0] != '-'
+ ) {
+ $value = $_SERVER['argv'][++$i];
+ }
+
+ $args[$key] = is_string($value) ? preg_replace(['/^["\']/', '/["\']$/'], '', $value) : $value;
+ }
+ else {
+ $args[] = $arg;
+ }
+
+ if (!empty($aliases[$key])) {
+ $alias = $aliases[$key];
+ $args[$alias] = $args[$key];
+ }
+ }
+
+ return $args;
+ }
+
+ /**
+ * Safe password prompt for command line
+ * from http://blogs.sitepoint.com/2009/05/01/interactive-cli-password-prompt-in-php/
+ *
+ * @param string $prompt Prompt text
+ *
+ * @return string Password
+ */
+ public static function prompt_silent($prompt = "Password:")
+ {
+ if (preg_match('/^win/i', PHP_OS)) {
+ $vbscript = sys_get_temp_dir() . 'prompt_password.vbs';
+ $vbcontent = 'wscript.echo(InputBox("' . addslashes($prompt) . '", "", "password here"))';
+ file_put_contents($vbscript, $vbcontent);
+
+ $command = "cscript //nologo " . escapeshellarg($vbscript);
+ $password = rtrim(shell_exec($command));
+ unlink($vbscript);
+
+ return $password;
+ }
+
+ $command = "/usr/bin/env bash -c 'echo OK'";
+
+ if (rtrim(shell_exec($command)) !== 'OK') {
+ echo $prompt;
+ $pass = trim(fgets(STDIN));
+ echo chr(8)."\r" . $prompt . str_repeat("*", strlen($pass))."\n";
+
+ return $pass;
+ }
+
+ $command = "/usr/bin/env bash -c 'read -s -p \"" . addslashes($prompt) . "\" mypassword && echo \$mypassword'";
+ $password = rtrim(shell_exec($command));
+ echo "\n";
+
+ return $password;
+ }
+
+ /**
+ * Find out if the string content means true or false
+ *
+ * @param string $str Input value
+ *
+ * @return bool Boolean value
+ */
+ public static function get_boolean($str)
+ {
+ $str = strtolower($str);
+
+ return !in_array($str, ['false', '0', 'no', 'off', 'nein', ''], true);
+ }
+
+ /**
+ * OS-dependent absolute path detection
+ *
+ * @param string $path File path
+ *
+ * @return bool True if the path is absolute, False otherwise
+ */
+ public static function is_absolute_path($path)
+ {
+ if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
+ return (bool) preg_match('!^[a-z]:[\\\\/]!i', $path);
+ }
+
+ return isset($path[0]) && $path[0] == '/';
+ }
+
+ /**
+ * Resolve relative URL
+ *
+ * @param string $url Relative URL
+ *
+ * @return string Absolute URL
+ */
+ public static function resolve_url($url)
+ {
+ // prepend protocol://hostname:port
+ if (!preg_match('|^https?://|', $url)) {
+ $schema = 'http';
+ $default_port = 80;
+
+ if (self::https_check()) {
+ $schema = 'https';
+ $default_port = 443;
+ }
+
+ $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : null;
+ $port = isset($_SERVER['SERVER_PORT']) ? $_SERVER['SERVER_PORT'] : null;
+
+ $prefix = $schema . '://' . preg_replace('/:\d+$/', '', $host);
+ if ($port != $default_port && $port != 80) {
+ $prefix .= ':' . $port;
+ }
+
+ $url = $prefix . ($url[0] == '/' ? '' : '/') . $url;
+ }
+
+ return $url;
+ }
+
+ /**
+ * Generate a random string
+ *
+ * @param int $length String length
+ * @param bool $raw Return RAW data instead of ascii
+ *
+ * @return string The generated random string
+ */
+ public static function random_bytes($length, $raw = false)
+ {
+ $hextab = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ $tabsize = strlen($hextab);
+
+ // Use PHP7 true random generator
+ if ($raw && function_exists('random_bytes')) {
+ return random_bytes($length);
+ }
+
+ if (!$raw && function_exists('random_int')) {
+ $result = '';
+ while ($length-- > 0) {
+ $result .= $hextab[random_int(0, $tabsize - 1)];
+ }
+
+ return $result;
+ }
+
+ $random = openssl_random_pseudo_bytes($length);
+
+ if ($random === false && $length > 0) {
+ throw new Exception("Failed to get random bytes");
+ }
+
+ if (!$raw) {
+ for ($x = 0; $x < $length; $x++) {
+ $random[$x] = $hextab[ord($random[$x]) % $tabsize];
+ }
+ }
+
+ return $random;
+ }
+
+ /**
+ * Convert binary data into readable form (containing a-zA-Z0-9 characters)
+ *
+ * @param string $input Binary input
+ *
+ * @return string Readable output (Base62)
+ * @deprecated since 1.3.1
+ */
+ public static function bin2ascii($input)
+ {
+ $hextab = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ $result = '';
+
+ for ($x = 0; $x < strlen($input); $x++) {
+ $result .= $hextab[ord($input[$x]) % 62];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Format current date according to specified format.
+ * This method supports microseconds (u).
+ *
+ * @param string $format Date format (default: 'd-M-Y H:i:s O')
+ *
+ * @return string Formatted date
+ */
+ public static function date_format($format = null)
+ {
+ if (empty($format)) {
+ $format = 'd-M-Y H:i:s O';
+ }
+
+ if (strpos($format, 'u') !== false) {
+ $dt = number_format(microtime(true), 6, '.', '');
+
+ try {
+ $date = date_create_from_format('U.u', $dt);
+ $date->setTimeZone(new DateTimeZone(date_default_timezone_get()));
+
+ return $date->format($format);
+ }
+ catch (Exception $e) {
+ // ignore, fallback to date()
+ }
+ }
+
+ return date($format);
+ }
+
+ /**
+ * Parses socket options and returns options for specified hostname.
+ *
+ * @param array &$options Configured socket options
+ * @param string $host Hostname
+ */
+ public static function parse_socket_options(&$options, $host = null)
+ {
+ if (empty($host) || empty($options)) {
+ return;
+ }
+
+ // get rid of schema and port from the hostname
+ $host_url = parse_url($host);
+ if (isset($host_url['host'])) {
+ $host = $host_url['host'];
+ }
+
+ // find per-host options
+ if ($host && array_key_exists($host, $options)) {
+ $options = $options[$host];
+ }
+ }
+
+ /**
+ * Get maximum upload size
+ *
+ * @return int Maximum size in bytes
+ */
+ public static function max_upload_size()
+ {
+ // find max filesize value
+ $max_filesize = parse_bytes(ini_get('upload_max_filesize'));
+ $max_postsize = parse_bytes(ini_get('post_max_size'));
+
+ if ($max_postsize && $max_postsize < $max_filesize) {
+ $max_filesize = $max_postsize;
+ }
+
+ return $max_filesize;
+ }
+
+ /**
+ * Detect and log last PREG operation error
+ *
+ * @param array $error Error data (line, file, code, message)
+ * @param bool $terminate Stop script execution
+ *
+ * @return bool True on error, False otherwise
+ */
+ public static function preg_error($error = [], $terminate = false)
+ {
+ if (($preg_error = preg_last_error()) != PREG_NO_ERROR) {
+ $errstr = "PCRE Error: $preg_error.";
+
+ if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
+ $errstr .= " Consider raising pcre.backtrack_limit!";
+ }
+ if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
+ $errstr .= " Consider raising pcre.recursion_limit!";
+ }
+
+ $error = array_merge(['code' => 620, 'line' => __LINE__, 'file' => __FILE__], $error);
+
+ if (!empty($error['message'])) {
+ $error['message'] .= ' ' . $errstr;
+ }
+ else {
+ $error['message'] = $errstr;
+ }
+
+ rcube::raise_error($error, true, $terminate);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Generate a temporary file path in the Roundcube temp directory
+ *
+ * @param string $file_name String identifier for the type of temp file
+ * @param bool $unique Generate unique file names based on $file_name
+ * @param bool $create Create the temp file or not
+ *
+ * @return string temporary file path
+ */
+ public static function temp_filename($file_name, $unique = true, $create = true)
+ {
+ $temp_dir = rcube::get_instance()->config->get('temp_dir');
+
+ // Fall back to system temp dir if configured dir is not writable
+ if (!is_writable($temp_dir)) {
+ $temp_dir = sys_get_temp_dir();
+ }
+
+ // On Windows tempnam() uses only the first three characters of prefix so use uniqid() and manually add the prefix
+ // Full prefix is required for garbage collection to recognise the file
+ $temp_file = $unique ? str_replace('.', '', uniqid($file_name, true)) : $file_name;
+ $temp_path = unslashify($temp_dir) . '/' . RCUBE_TEMP_FILE_PREFIX . $temp_file;
+
+ // Sanity check for unique file name
+ if ($unique && file_exists($temp_path)) {
+ return self::temp_filename($file_name, $unique, $create);
+ }
+
+ // Create the file to prevent possible race condition like tempnam() does
+ if ($create) {
+ touch($temp_path);
+ }
+
+ return $temp_path;
+ }
+
+ /**
+ * Clean the subject from reply and forward prefix
+ *
+ * @param string $subject Subject to clean
+ * @param string $mode Mode of cleaning : reply, forward or both
+ *
+ * @return string Cleaned subject
+ */
+ public static function remove_subject_prefix($subject, $mode = 'both')
+ {
+ $config = rcmail::get_instance()->config;
+
+ // Clean subject prefix for reply, forward or both
+ if ($mode == 'both') {
+ $reply_prefixes = $config->get('subject_reply_prefixes', ['Re:']);
+ $forward_prefixes = $config->get('subject_forward_prefixes', ['Fwd:', 'Fw:']);
+ $prefixes = array_merge($reply_prefixes, $forward_prefixes);
+ }
+ else if ($mode == 'reply') {
+ $prefixes = $config->get('subject_reply_prefixes', ['Re:']);
+ // replace (was: ...) (#1489375)
+ $subject = preg_replace('/\s*\([wW]as:[^\)]+\)\s*$/', '', $subject);
+ }
+ else if ($mode == 'forward') {
+ $prefixes = $config->get('subject_forward_prefixes', ['Fwd:', 'Fw:']);
+ }
+
+ // replace Re:, Re[x]:, Re-x (#1490497)
+ $pieces = array_map(function($prefix) {
+ $prefix = strtolower(str_replace(':', '', $prefix));
+ return "$prefix:|$prefix\[\d\]:|$prefix-\d:";
+ }, $prefixes);
+ $pattern = '/^('.implode('|', $pieces).')\s*/i';
+ do {
+ $subject = preg_replace($pattern, '', $subject, -1, $count);
+ }
+ while ($count);
+
+ return trim($subject);
+ }
+
+ /**
+ * Generates the HAproxy style PROXY protocol header for injection
+ * into the TCP stream, if configured.
+ *
+ * http://www.haproxy.org/download/1.6/doc/proxy-protocol.txt
+ *
+ * PROXY protocol headers must be sent before any other data is sent on the TCP socket.
+ *
+ * @param array $options Preferences array which may contain proxy_protocol (generally {driver}_conn_options)
+ *
+ * @return string Proxy protocol header data, if enabled, otherwise empty string
+ */
+ public static function proxy_protocol_header($options = null)
+ {
+ if (empty($options) || !is_array($options) || !array_key_exists('proxy_protocol', $options)) {
+ return '';
+ }
+
+ if (is_array($options['proxy_protocol'])) {
+ $version = $options['proxy_protocol']['version'];
+ $options = $options['proxy_protocol'];
+ }
+ else {
+ $version = (int) $options['proxy_protocol'];
+ $options = [];
+ }
+
+ $remote_addr = array_key_exists('remote_addr', $options) ? $options['remote_addr'] : self::remote_addr();
+ $remote_port = array_key_exists('remote_port', $options) ? $options['remote_port'] : $_SERVER['REMOTE_PORT'];
+ $local_addr = array_key_exists('local_addr', $options) ? $options['local_addr'] : $_SERVER['SERVER_ADDR'];
+ $local_port = array_key_exists('local_port', $options) ? $options['local_port'] : $_SERVER['SERVER_PORT'];
+ $ip_version = strpos($remote_addr, ':') === false ? 4 : 6;
+
+ // Text based PROXY protocol
+ if ($version == 1) {
+ // PROXY protocol does not support dual IPv6+IPv4 type addresses, e.g. ::127.0.0.1
+ if ($ip_version === 6 && strpos($remote_addr, '.') !== false) {
+ $remote_addr = inet_ntop(inet_pton($remote_addr));
+ }
+ if ($ip_version === 6 && strpos($local_addr, '.') !== false) {
+ $local_addr = inet_ntop(inet_pton($local_addr));
+ }
+
+ return "PROXY TCP{$ip_version} {$remote_addr} {$local_addr} {$remote_port} {$local_port}\r\n";
+ }
+
+ // Binary PROXY protocol
+ if ($version == 2) {
+ $addr = inet_pton($remote_addr) . inet_pton($local_addr) . pack('n', $remote_port) . pack('n', $local_port);
+ $head = implode([
+ '0D0A0D0A000D0A515549540A', // protocol header
+ '21', // protocol version and command
+ $ip_version === 6 ? '2' : '1', // IP version type
+ '1' // TCP
+ ]);
+
+ return pack('H*', $head) . pack('n', strlen($addr)) . $addr;
+ }
+
+ return '';
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Sep 20, 2:20 AM (7 h, 31 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9465864
Default Alt Text
D4845.id13878.diff (173 KB)
Attached To
Mode
D4845: IMAP data migrator and other improvements
Attached
Detach File
Event Timeline
Log In to Comment