Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
300 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/program/lib/Roundcube/rcube_imap.php b/program/lib/Roundcube/rcube_imap.php
index 386d4545d..caa6ea18a 100644
--- a/program/lib/Roundcube/rcube_imap.php
+++ b/program/lib/Roundcube/rcube_imap.php
@@ -1,4745 +1,4752 @@
<?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: |
| IMAP Storage Engine |
+-----------------------------------------------------------------------+
| Author: Thomas Bruederli <roundcube@gmail.com> |
| Author: Aleksander Machniak <alec@alec.pl> |
+-----------------------------------------------------------------------+
*/
/**
* Interface class for accessing an IMAP server
*
* @package Framework
* @subpackage Storage
*/
class rcube_imap extends rcube_storage
{
/**
* Instance of rcube_imap_generic
*
* @var rcube_imap_generic
*/
public $conn;
/**
* Instance of rcube_imap_cache
*
* @var rcube_imap_cache
*/
protected $mcache;
/**
* Instance of rcube_cache
*
* @var rcube_cache
*/
protected $cache;
protected $plugins;
protected $delimiter;
protected $namespace;
protected $struct_charset;
protected $search_set;
protected $search_string = '';
protected $search_charset = '';
protected $search_sort_field = '';
protected $search_threads = false;
protected $search_sorted = false;
protected $sort_field = '';
protected $sort_order = 'DESC';
protected $options = ['auth_type' => 'check', 'skip_deleted' => false];
protected $caching = false;
protected $messages_caching = false;
protected $threading = false;
protected $connect_done = false;
protected $list_excludes = [];
protected $list_root;
protected $msg_uid;
protected $sort_folder_collator;
/**
* Object constructor.
*/
public function __construct()
{
$this->conn = new rcube_imap_generic();
$this->plugins = rcube::get_instance()->plugins;
// Set namespace and delimiter from session,
// so some methods would work before connection
if (isset($_SESSION['imap_namespace'])) {
$this->namespace = $_SESSION['imap_namespace'];
}
if (isset($_SESSION['imap_delimiter'])) {
$this->delimiter = $_SESSION['imap_delimiter'];
}
if (!empty($_SESSION['imap_list_conf'])) {
list($this->list_root, $this->list_excludes) = $_SESSION['imap_list_conf'];
}
}
/**
* Magic getter for backward compat.
*
* @deprecated
*/
public function __get($name)
{
if (isset($this->{$name})) {
return $this->{$name};
}
}
/**
* Connect to an IMAP server
*
* @param string $host Host to connect
* @param string $user Username for IMAP account
* @param string $pass Password for IMAP account
* @param int $port Port to connect to
* @param string $use_ssl SSL schema (either ssl or tls) or null if plain connection
*
* @return bool True on success, False on failure
*/
public function connect($host, $user, $pass, $port = 143, $use_ssl = null)
{
// check for OpenSSL support in PHP build
if ($use_ssl && extension_loaded('openssl')) {
$this->options['ssl_mode'] = $use_ssl == 'imaps' ? 'ssl' : $use_ssl;
}
else if ($use_ssl) {
rcube::raise_error([
'code' => 403, 'type' => 'imap',
'file' => __FILE__, 'line' => __LINE__,
'message' => "OpenSSL not available"
], true, false);
$port = 143;
}
$this->options['port'] = $port;
if (!empty($this->options['debug'])) {
$this->set_debug(true);
$this->options['ident'] = [
'name' => 'Roundcube',
'version' => RCUBE_VERSION,
'php' => PHP_VERSION,
'os' => PHP_OS,
'command' => $_SERVER['REQUEST_URI'] ?? '',
];
}
$attempt = 0;
do {
$data = [
'host' => $host,
'user' => $user,
'attempt' => ++$attempt,
'retry' => false
];
$data = $this->plugins->exec_hook('storage_connect', array_merge($this->options, $data));
if ($attempt > 1 && !$data['retry']) {
break;
}
if (!empty($data['pass'])) {
$pass = $data['pass'];
}
// Handle per-host socket options
if (isset($data['socket_options'])) {
rcube_utils::parse_socket_options($data['socket_options'], $data['host']);
}
$this->conn->connect($data['host'], $data['user'], $pass, $data);
} while(!$this->conn->connected() && $data['retry']);
$config = [
'host' => $data['host'],
'user' => $data['user'],
'password' => $pass,
'port' => $port,
'ssl' => $use_ssl,
];
$this->options = array_merge($this->options, $config);
$this->connect_done = true;
if ($this->conn->connected()) {
// check for session identifier
$session = null;
if (preg_match('/\s+SESSIONID=([^=\s]+)/', $this->conn->result, $m)) {
$session = $m[1];
}
// get namespace and delimiter
$this->set_env();
// trigger post-connect hook
$this->plugins->exec_hook('storage_connected', [
'host' => $host, 'user' => $user, 'session' => $session
]);
return true;
}
// write error log
else if ($this->conn->error) {
if ($pass && $user) {
$message = sprintf("Login failed for %s against %s from %s. %s",
$user, $host, rcube_utils::remote_ip(), $this->conn->error);
rcube::raise_error([
'code' => 403, 'type' => 'imap',
'file' => __FILE__, 'line' => __LINE__,
'message' => $message
], true, false);
}
}
return false;
}
/**
* Close IMAP connection.
* Usually done on script shutdown
*/
public function close()
{
$this->connect_done = false;
$this->conn->closeConnection();
if ($this->mcache) {
$this->mcache->close();
}
}
/**
* Check connection state, connect if not connected.
*
* @return bool Connection state.
*/
public function check_connection()
{
// Establish connection if it wasn't done yet
if (!$this->connect_done && !empty($this->options['user'])) {
return $this->connect(
$this->options['host'],
$this->options['user'],
$this->options['password'],
$this->options['port'],
$this->options['ssl']
);
}
return $this->is_connected();
}
/**
* Checks IMAP connection.
*
* @return bool True on success, False on failure
*/
public function is_connected()
{
return $this->conn->connected();
}
/**
* Returns code of last error
*
* @return int Error code
*/
public function get_error_code()
{
return $this->conn->errornum;
}
/**
* Returns text of last error
*
* @return string Error string
*/
public function get_error_str()
{
return $this->conn->error;
}
/**
* Returns code of last command response
*
* @return int Response code
*/
public function get_response_code()
{
switch ($this->conn->resultcode) {
case 'NOPERM':
return self::NOPERM;
case 'READ-ONLY':
return self::READONLY;
case 'TRYCREATE':
return self::TRYCREATE;
case 'INUSE':
return self::INUSE;
case 'OVERQUOTA':
return self::OVERQUOTA;
case 'ALREADYEXISTS':
return self::ALREADYEXISTS;
case 'NONEXISTENT':
return self::NONEXISTENT;
case 'CONTACTADMIN':
return self::CONTACTADMIN;
default:
return self::UNKNOWN;
}
}
/**
* Activate/deactivate debug mode
*
* @param bool $dbg True if IMAP conversation should be logged
*/
public function set_debug($dbg = true)
{
$this->options['debug'] = $dbg;
$this->conn->setDebug($dbg, [$this, 'debug_handler']);
}
/**
* Set internal folder reference.
* All operations will be performed on this folder.
*
* @param string $folder Folder name
*/
public function set_folder($folder)
{
$this->folder = $folder;
}
/**
* Save a search result for future message listing methods
*
* @param array $set Search set, result from rcube_imap::get_search_set():
* 0 - searching criteria, string
* 1 - search result, rcube_result_index|rcube_result_thread
* 2 - searching character set, string
* 3 - sorting field, string
* 4 - true if sorted, bool
*/
public function set_search_set($set)
{
$set = (array) $set;
$this->search_string = $set[0] ?? null;
$this->search_set = $set[1] ?? null;
$this->search_charset = $set[2] ?? null;
$this->search_sort_field = $set[3] ?? null;
$this->search_sorted = $set[4] ?? null;
$this->search_threads = is_a($this->search_set, 'rcube_result_thread');
if (is_a($this->search_set, 'rcube_result_multifolder')) {
$this->set_threading(false);
}
}
/**
* Return the saved search set as hash array
*
* @return array|null Search set
*/
public function get_search_set()
{
if (empty($this->search_set)) {
return null;
}
return [
$this->search_string,
$this->search_set,
$this->search_charset,
$this->search_sort_field,
$this->search_sorted,
];
}
/**
* Returns the IMAP server's capability.
*
* @param string $cap Capability name
*
* @return mixed Capability value or TRUE if supported, FALSE if not
*/
public function get_capability($cap)
{
$cap = strtoupper($cap);
$sess_key = "STORAGE_$cap";
if (!isset($_SESSION[$sess_key])) {
if (!$this->check_connection()) {
return false;
}
if ($cap == rcube_storage::DUAL_USE_FOLDERS) {
$_SESSION[$sess_key] = $this->detect_dual_use_folders();
}
else {
$_SESSION[$sess_key] = $this->conn->getCapability($cap);
}
}
return $_SESSION[$sess_key];
}
/**
* Checks the PERMANENTFLAGS capability of the current folder
* and returns true if the given flag is supported by the IMAP server
*
* @param string $flag Permanentflag name
*
* @return bool True if this flag is supported
*/
public function check_permflag($flag)
{
$flag = strtoupper($flag);
$perm_flags = $this->get_permflags($this->folder);
$imap_flag = $this->conn->flags[$flag];
return $imap_flag && !empty($perm_flags) && in_array_nocase($imap_flag, $perm_flags);
}
/**
* Returns PERMANENTFLAGS of the specified folder
*
* @param string $folder Folder name
*
* @return array Flags
*/
public function get_permflags($folder)
{
if (!strlen($folder)) {
return [];
}
if (!$this->check_connection()) {
return [];
}
if ($this->conn->select($folder)) {
$permflags = $this->conn->data['PERMANENTFLAGS'];
}
else {
return [];
}
if (!isset($permflags) || !is_array($permflags)) {
$permflags = [];
}
return $permflags;
}
/**
* Returns the delimiter that is used by the IMAP server for folder separation
*
* @return string Delimiter string
*/
public function get_hierarchy_delimiter()
{
return $this->delimiter;
}
/**
* Get namespace
*
* @param string $name Namespace array index: personal, other, shared, prefix
*
* @return array Namespace data
*/
public function get_namespace($name = null)
{
$ns = $this->namespace;
if ($name) {
// an alias for BC
if ($name == 'prefix') {
$name = 'prefix_in';
}
return $ns[$name] ?? null;
}
unset($ns['prefix_in'], $ns['prefix_out']);
return $ns;
}
/**
* Sets delimiter and namespaces
*/
protected function set_env()
{
if ($this->delimiter !== null && $this->namespace !== null) {
return;
}
$config = rcube::get_instance()->config;
$imap_personal = $config->get('imap_ns_personal');
$imap_other = $config->get('imap_ns_other');
$imap_shared = $config->get('imap_ns_shared');
$imap_delimiter = $config->get('imap_delimiter');
if (!$this->check_connection()) {
return;
}
$ns = $this->conn->getNamespace();
// Set namespaces (NAMESPACE supported)
if (is_array($ns)) {
$this->namespace = $ns;
}
else {
$this->namespace = [
'personal' => null,
'other' => null,
'shared' => null,
];
}
if ($imap_delimiter) {
$this->delimiter = $imap_delimiter;
}
if (empty($this->delimiter) && !empty($this->namespace['personal'][0][1])) {
$this->delimiter = $this->namespace['personal'][0][1];
}
if (empty($this->delimiter)) {
$this->delimiter = $this->conn->getHierarchyDelimiter();
}
if (empty($this->delimiter)) {
$this->delimiter = '/';
}
$this->list_root = null;
$this->list_excludes = [];
// Overwrite namespaces
if ($imap_personal !== null) {
$this->namespace['personal'] = null;
foreach ((array) $imap_personal as $dir) {
$this->namespace['personal'][] = [$dir, $this->delimiter];
}
}
if ($imap_other === false) {
foreach ((array) $this->namespace['other'] as $dir) {
if (is_array($dir) && !empty($dir[0])) {
$this->list_excludes[] = $dir[0];
}
}
$this->namespace['other'] = null;
}
else if ($imap_other !== null) {
$this->namespace['other'] = null;
foreach ((array) $imap_other as $dir) {
if ($dir) {
$this->namespace['other'][] = [$dir, $this->delimiter];
}
}
}
if ($imap_shared === false) {
foreach ((array) $this->namespace['shared'] as $dir) {
if (is_array($dir) && !empty($dir[0])) {
$this->list_excludes[] = $dir[0];
}
}
$this->namespace['shared'] = null;
}
else if ($imap_shared !== null) {
$this->namespace['shared'] = null;
foreach ((array) $imap_shared as $dir) {
if ($dir) {
$this->namespace['shared'][] = [$dir, $this->delimiter];
}
}
}
// Performance optimization for case where we have no shared/other namespace
// and personal namespace has one prefix (#5073)
// In such a case we can tell the server to return only content of the
// specified folder in LIST/LSUB, no post-filtering
if (empty($this->namespace['other']) && empty($this->namespace['shared'])
&& !empty($this->namespace['personal']) && count($this->namespace['personal']) === 1
&& strlen($this->namespace['personal'][0][0]) > 1
) {
$this->list_root = $this->namespace['personal'][0][0];
$this->list_excludes = [];
}
// Find personal namespace prefix(es) for self::mod_folder()
if (!empty($this->namespace['personal']) && is_array($this->namespace['personal'])) {
// There can be more than one namespace root,
// - for prefix_out get the first one but only
// if there is only one root
// - for prefix_in get the first one but only
// if there is no non-prefixed namespace root (#5403)
$roots = [];
foreach ($this->namespace['personal'] as $ns) {
$roots[] = $ns[0];
}
if (!in_array('', $roots)) {
$this->namespace['prefix_in'] = $roots[0];
}
if (count($roots) == 1) {
$this->namespace['prefix_out'] = $roots[0];
}
}
$_SESSION['imap_namespace'] = $this->namespace;
$_SESSION['imap_delimiter'] = $this->delimiter;
$_SESSION['imap_list_conf'] = [$this->list_root, $this->list_excludes];
}
/**
* Returns IMAP server vendor name
*
* @return string Vendor name
* @since 1.2
*/
public function get_vendor()
{
if (isset($_SESSION['imap_vendor'])) {
return $_SESSION['imap_vendor'];
}
$config = rcube::get_instance()->config;
$imap_vendor = $config->get('imap_vendor');
if ($imap_vendor) {
return $imap_vendor;
}
if (!$this->check_connection()) {
return;
}
if (isset($this->conn->data['ID'])) {
$ident = $this->conn->data['ID'];
}
else if ($this->get_capability('ID')) {
$ident = $this->conn->id([
'name' => 'Roundcube',
'version' => RCUBE_VERSION,
'php' => PHP_VERSION,
'os' => PHP_OS,
]);
}
else {
$ident = null;
}
$vendor = (string) ($ident['name'] ?? $ident['NAME'] ?? '');
$ident = strtolower($vendor . ' ' . $this->conn->data['GREETING']);
$vendors = ['cyrus', 'dovecot', 'uw-imap', 'gimap', 'hmail', 'greenmail'];
foreach ($vendors as $v) {
if (strpos($ident, $v) !== false) {
$vendor = $v;
break;
}
}
return $_SESSION['imap_vendor'] = $vendor;
}
/**
* Get message count for a specific folder
*
* @param string $folder Folder name
* @param string $mode Mode for count [ALL|THREADS|UNSEEN|RECENT|EXISTS]
* @param bool $force Force reading from server and update cache
* @param bool $status Enables storing folder status info (max UID/count),
* required for folder_status()
*
* @return int Number of messages
*/
public function count($folder = '', $mode = 'ALL', $force = false, $status = true)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
return $this->countmessages($folder, $mode, $force, $status);
}
/**
* Protected method for getting number of messages
*
* @param string $folder Folder name
* @param string $mode Mode for count [ALL|THREADS|UNSEEN|RECENT|EXISTS]
* @param bool $force Force reading from server and update cache
* @param bool $status Enables storing folder status info (max UID/count),
* required for folder_status()
* @param bool $no_search Ignore current search result
*
* @return int Number of messages
* @see rcube_imap::count()
*/
protected function countmessages($folder, $mode = 'ALL', $force = false, $status = true, $no_search = false)
{
$mode = strtoupper($mode);
// Count search set, assume search set is always up-to-date (don't check $force flag)
// @TODO: this could be handled in more reliable way, e.g. a separate method
// maybe in rcube_imap_search
if (!$no_search && $this->search_string && $folder == $this->folder) {
if ($mode == 'ALL') {
return $this->search_set->count_messages();
}
if ($mode == 'THREADS') {
return $this->search_set->count();
}
}
// EXISTS is a special alias for ALL, it allows to get the number
// of all messages in a folder also when search is active and with
// any skip_deleted setting
$a_folder_cache = $this->get_cache('messagecount');
// return cached value
if (!$force && isset($a_folder_cache[$folder][$mode])) {
return $a_folder_cache[$folder][$mode];
}
if (!isset($a_folder_cache[$folder]) || !is_array($a_folder_cache[$folder])) {
$a_folder_cache[$folder] = [];
}
if ($mode == 'THREADS') {
$res = $this->threads($folder);
$count = $res->count();
if ($status) {
$msg_count = $res->count_messages();
$this->set_folder_stats($folder, 'cnt', $msg_count);
$this->set_folder_stats($folder, 'maxuid', $msg_count ? $this->id2uid($msg_count, $folder) : 0);
}
}
// Need connection here
else if (!$this->check_connection()) {
return 0;
}
// RECENT count is fetched a bit different
else if ($mode == 'RECENT') {
$count = $this->conn->countRecent($folder);
}
// use SEARCH for message counting
else if ($mode != 'EXISTS' && !empty($this->options['skip_deleted'])) {
$search_str = "ALL UNDELETED";
$keys = ['COUNT'];
if ($mode == 'UNSEEN') {
$search_str .= " UNSEEN";
}
else {
if ($this->messages_caching) {
$keys[] = 'ALL';
}
if ($status) {
$keys[] = 'MAX';
}
}
// @TODO: if $mode == 'ALL' we could try to use cache index here
// get message count using (E)SEARCH
// not very performant but more precise (using UNDELETED)
$index = $this->conn->search($folder, $search_str, true, $keys);
$count = $index->count();
if ($mode == 'ALL') {
// Cache index data, will be used in index_direct()
$this->icache['undeleted_idx'] = $index;
if ($status) {
$this->set_folder_stats($folder, 'cnt', $count);
$this->set_folder_stats($folder, 'maxuid', $index->max());
}
}
}
else {
if ($mode == 'UNSEEN') {
$count = $this->conn->countUnseen($folder);
}
else {
$count = $this->conn->countMessages($folder);
if ($status && $mode == 'ALL') {
$this->set_folder_stats($folder, 'cnt', $count);
$this->set_folder_stats($folder, 'maxuid', $count ? $this->id2uid($count, $folder) : 0);
}
}
}
$count = (int) $count;
if (!isset($a_folder_cache[$folder][$mode]) || $a_folder_cache[$folder][$mode] !== $count) {
$a_folder_cache[$folder][$mode] = $count;
// write back to cache
$this->update_cache('messagecount', $a_folder_cache);
}
return $count;
}
/**
* Public method for listing message flags
*
* @param string $folder Folder name
* @param array $uids Message UIDs
* @param int $mod_seq Optional MODSEQ value (of last flag update)
*
* @return array Indexed array with message flags
*/
public function list_flags($folder, $uids, $mod_seq = null)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
if (!$this->check_connection()) {
return [];
}
// @TODO: when cache was synchronized in this request
// we might already have asked for flag updates, use it.
$flags = $this->conn->fetch($folder, $uids, true, ['FLAGS'], $mod_seq);
$result = [];
if (!empty($flags)) {
foreach ($flags as $message) {
$result[$message->uid] = $message->flags;
}
}
return $result;
}
/**
* Public method for listing headers
*
* @param string $folder Folder name
* @param int $page Current page to list
* @param string $sort_field Header field to sort by
* @param string $sort_order Sort order [ASC|DESC]
* @param int $slice Number of slice items to extract from result array
*
* @return array Indexed array with message header objects
*/
public function list_messages($folder = '', $page = null, $sort_field = null, $sort_order = null, $slice = 0)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
return $this->_list_messages($folder, $page, $sort_field, $sort_order, $slice);
}
/**
* protected method for listing message headers
*
* @param string $folder Folder name
* @param int $page Current page to list
* @param string $sort_field Header field to sort by
* @param string $sort_order Sort order [ASC|DESC]
* @param int $slice Number of slice items to extract from result array
*
* @return array Indexed array with message header objects
* @see rcube_imap::list_messages
*/
protected function _list_messages($folder = '', $page = null, $sort_field = null, $sort_order = null, $slice = 0)
{
if (!is_string($folder) || !strlen($folder)) {
return [];
}
$this->set_sort_order($sort_field, $sort_order);
$page = $page ?: $this->list_page;
// use saved message set
if ($this->search_string) {
return $this->list_search_messages($folder, $page, $slice);
}
if ($this->threading) {
return $this->list_thread_messages($folder, $page, $slice);
}
// get UIDs of all messages in the folder, sorted
$index = $this->index($folder, $this->sort_field, $this->sort_order);
if ($index->is_empty()) {
return [];
}
$from = ($page-1) * $this->page_size;
$to = $from + $this->page_size;
$index->slice($from, $to - $from);
if ($slice) {
$index->slice(-$slice, $slice);
}
// fetch requested messages headers
$a_index = $index->get();
$a_msg_headers = $this->fetch_headers($folder, $a_index);
return array_values($a_msg_headers);
}
/**
* protected method for listing message headers using threads
*
* @param string $folder Folder name
* @param int $page Current page to list
* @param int $slice Number of slice items to extract from result array
*
* @return array Indexed array with message header objects
* @see rcube_imap::list_messages
*/
protected function list_thread_messages($folder, $page, $slice = 0)
{
// get all threads (not sorted)
if ($mcache = $this->get_mcache_engine()) {
$threads = $mcache->get_thread($folder);
}
else {
$threads = $this->threads($folder);
}
return $this->fetch_thread_headers($folder, $threads, $page, $slice);
}
/**
* Method for fetching threads data
*
* @param string $folder Folder name
*
* @return rcube_result_thread Thread data object
*/
function threads($folder)
{
if ($mcache = $this->get_mcache_engine()) {
// don't store in self's internal cache, cache has it's own internal cache
return $mcache->get_thread($folder);
}
if (!empty($this->icache['threads'])) {
if ($this->icache['threads']->get_parameters('MAILBOX') == $folder) {
return $this->icache['threads'];
}
}
// get all threads
$result = $this->threads_direct($folder);
// add to internal (fast) cache
return $this->icache['threads'] = $result;
}
/**
* Method for direct fetching of threads data
*
* @param string $folder Folder name
*
* @return rcube_result_thread Thread data object
*/
function threads_direct($folder)
{
if (!$this->check_connection()) {
return new rcube_result_thread();
}
// get all threads
return $this->conn->thread($folder, $this->threading,
$this->options['skip_deleted'] ? 'UNDELETED' : '', true);
}
/**
* protected method for fetching threaded messages headers
*
* @param string $folder Folder name
* @param rcube_result_thread $threads Threads data object
* @param int $page List page number
* @param int $slice Number of threads to slice
*
* @return array Messages headers
*/
protected function fetch_thread_headers($folder, $threads, $page, $slice = 0)
{
// Sort thread structure
$this->sort_threads($threads);
$from = ($page-1) * $this->page_size;
$to = $from + $this->page_size;
$threads->slice($from, $to - $from);
if ($slice) {
$threads->slice(-$slice, $slice);
}
// Get UIDs of all messages in all threads
$a_index = $threads->get();
// fetch requested headers from server
$a_msg_headers = $this->fetch_headers($folder, $a_index);
unset($a_index);
// Set depth, has_children and unread_children fields in headers
$this->set_thread_flags($a_msg_headers, $threads);
return array_values($a_msg_headers);
}
/**
* protected method for setting threaded messages flags:
* depth, has_children, unread_children, flagged_children
*
* @param array $headers Reference to headers array indexed by message UID
* @param rcube_result_thread $threads Threads data object
*
* @return array Message headers array indexed by message UID
*/
protected function set_thread_flags(&$headers, $threads)
{
$parents = [];
list($msg_depth, $msg_children) = $threads->get_thread_data();
foreach ($headers as $uid => $header) {
$depth = $msg_depth[$uid] ?? 0;
$parents = array_slice($parents, 0, $depth);
if (!empty($parents)) {
$headers[$uid]->parent_uid = end($parents);
if (empty($header->flags['SEEN'])) {
$headers[$parents[0]]->unread_children++;
}
if (!empty($header->flags['FLAGGED'])) {
$headers[$parents[0]]->flagged_children++;
}
}
array_push($parents, $uid);
$headers[$uid]->depth = $depth;
$headers[$uid]->has_children = !empty($msg_children[$uid]);
$headers[$uid]->unread_children = 0;
$headers[$uid]->flagged_children = 0;
}
}
/**
* A protected method for listing a set of message headers (search results)
*
* @param string $folder Folder name
* @param int $page Current page to list
* @param int $slice Number of slice items to extract from the result array
*
* @return array Indexed array with message header objects
*/
protected function list_search_messages($folder, $page, $slice = 0)
{
if (!is_string($folder) || !strlen($folder) || empty($this->search_set) || $this->search_set->is_empty()) {
return [];
}
$from = ($page-1) * $this->page_size;
// gather messages from a multi-folder search
if (!empty($this->search_set->multi)) {
$page_size = $this->page_size;
$sort_field = $this->sort_field;
$search_set = $this->search_set;
// fetch resultset headers, sort and slice them
if (!empty($sort_field) && $search_set->get_parameters('SORT') != $sort_field) {
$this->sort_field = null;
$this->page_size = 1000; // fetch up to 1000 matching messages per folder
$this->threading = false;
$a_msg_headers = [];
foreach ($search_set->sets as $resultset) {
if (!$resultset->is_empty()) {
$this->search_set = $resultset;
$this->search_threads = $resultset instanceof rcube_result_thread;
$a_headers = $this->list_search_messages($resultset->get_parameters('MAILBOX'), 1);
$a_msg_headers = array_merge($a_msg_headers, $a_headers);
unset($a_headers);
}
}
// sort headers
if (!empty($a_msg_headers)) {
$a_msg_headers = rcube_imap_generic::sortHeaders($a_msg_headers, $sort_field, $this->sort_order);
}
// store (sorted) message index
$search_set->set_message_index($a_msg_headers, $sort_field, $this->sort_order);
// only return the requested part of the set
$a_msg_headers = array_slice(array_values($a_msg_headers), $from, $page_size);
}
else {
if ($this->sort_order != $search_set->get_parameters('ORDER')) {
$search_set->revert();
}
// slice resultset first...
$index = array_slice($search_set->get(), $from, $page_size);
$fetch = [];
foreach ($index as $msg_id) {
list($uid, $folder) = explode('-', $msg_id, 2);
$fetch[$folder][] = $uid;
}
// ... and fetch the requested set of headers
$a_msg_headers = [];
foreach ($fetch as $folder => $a_index) {
$a_msg_headers = array_merge($a_msg_headers, array_values($this->fetch_headers($folder, $a_index)));
}
// Re-sort the result according to the original search set order
usort($a_msg_headers, function($a, $b) use ($index) {
return array_search($a->uid . '-' . $a->folder, $index) - array_search($b->uid . '-' . $b->folder, $index);
});
}
if ($slice) {
$a_msg_headers = array_slice($a_msg_headers, -$slice, $slice);
}
// restore members
$this->sort_field = $sort_field;
$this->page_size = $page_size;
$this->search_set = $search_set;
return $a_msg_headers;
}
// use saved messages from searching
if ($this->threading) {
return $this->list_search_thread_messages($folder, $page, $slice);
}
// search set is threaded, we need a new one
if ($this->search_threads) {
$this->search('', $this->search_string, $this->search_charset, $this->sort_field);
}
$index = clone $this->search_set;
// return empty array if no messages found
if ($index->is_empty()) {
return [];
}
// quickest method (default sorting)
if (!$this->search_sort_field && !$this->sort_field) {
$got_index = true;
}
// sorted messages, so we can first slice array and then fetch only wanted headers
else if ($this->search_sorted) { // SORT searching result
$got_index = true;
// reset search set if sorting field has been changed
if ($this->sort_field && $this->search_sort_field != $this->sort_field) {
$this->search('', $this->search_string, $this->search_charset, $this->sort_field);
$index = clone $this->search_set;
// return empty array if no messages found
if ($index->is_empty()) {
return [];
}
}
}
if (!empty($got_index)) {
if ($this->sort_order != $index->get_parameters('ORDER')) {
$index->revert();
}
// get messages uids for one page
$index->slice($from, $this->page_size);
if ($slice) {
$index->slice(-$slice, $slice);
}
// fetch headers
$a_index = $index->get();
$a_msg_headers = $this->fetch_headers($folder, $a_index);
return array_values($a_msg_headers);
}
// SEARCH result, need sorting
$cnt = $index->count();
// 300: experimental value for best result
if (($cnt > 300 && $cnt > $this->page_size) || !$this->sort_field) {
// use memory less expensive (and quick) method for big result set
$index = clone $this->index('', $this->sort_field, $this->sort_order);
// get messages uids for one page...
$index->slice($from, $this->page_size);
if ($slice) {
$index->slice(-$slice, $slice);
}
// ...and fetch headers
$a_index = $index->get();
$a_msg_headers = $this->fetch_headers($folder, $a_index);
return array_values($a_msg_headers);
}
else {
// for small result set we can fetch all messages headers
$a_index = $index->get();
$a_msg_headers = $this->fetch_headers($folder, $a_index, false);
// return empty array if no messages found
if (!is_array($a_msg_headers) || empty($a_msg_headers)) {
return [];
}
// if not already sorted
$a_msg_headers = rcube_imap_generic::sortHeaders(
$a_msg_headers, $this->sort_field, $this->sort_order);
$a_msg_headers = array_slice(array_values($a_msg_headers), $from, $this->page_size);
if ($slice) {
$a_msg_headers = array_slice($a_msg_headers, -$slice, $slice);
}
return $a_msg_headers;
}
}
/**
* protected method for listing a set of threaded message headers (search results)
*
* @param string $folder Folder name
* @param int $page Current page to list
* @param int $slice Number of slice items to extract from result array
*
* @return array Indexed array with message header objects
* @see rcube_imap::list_search_messages()
*/
protected function list_search_thread_messages($folder, $page, $slice = 0)
{
// update search_set if previous data was fetched with disabled threading
if (!$this->search_threads) {
if ($this->search_set->is_empty()) {
return [];
}
$this->search('', $this->search_string, $this->search_charset, $this->sort_field);
}
return $this->fetch_thread_headers($folder, clone $this->search_set, $page, $slice);
}
/**
* Fetches messages headers (by UID)
*
* @param string $folder Folder name
* @param array $msgs Message UIDs
* @param bool $sort Enables result sorting by $msgs
* @param bool $force Disables cache use
*
* @return array Messages headers indexed by UID
*/
function fetch_headers($folder, $msgs, $sort = true, $force = false)
{
if (empty($msgs)) {
return [];
}
if (!$force && ($mcache = $this->get_mcache_engine())) {
$headers = $mcache->get_messages($folder, $msgs);
}
else if (!$this->check_connection()) {
return [];
}
else {
// fetch requested headers from server
$headers = $this->conn->fetchHeaders(
$folder, $msgs, true, false, $this->get_fetch_headers(), $this->get_fetch_items());
}
if (empty($headers)) {
return [];
}
$msg_headers = [];
foreach ($headers as $h) {
$h->folder = $folder;
$msg_headers[$h->uid] = $h;
}
if ($sort) {
// use this class for message sorting
$sorter = new rcube_message_header_sorter();
$sorter->set_index($msgs);
$sorter->sort_headers($msg_headers);
}
return $msg_headers;
}
/**
* Returns current status of a folder (compared to the last time use)
*
* We compare the maximum UID to determine the number of
* new messages because the RECENT flag is not reliable.
*
* @param string $folder Folder name
* @param array $diff Difference data
*
* @return int Folder status
*/
public function folder_status($folder = null, &$diff = [])
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
$old = $this->get_folder_stats($folder);
// refresh message count -> will update
$this->countmessages($folder, 'ALL', true, true, true);
$result = 0;
if (empty($old)) {
return $result;
}
$new = $this->get_folder_stats($folder);
// got new messages
if ($new['maxuid'] > $old['maxuid']) {
$result += 1;
// get new message UIDs range, that can be used for example
// to get the data of these messages
$diff['new'] = ($old['maxuid'] + 1 < $new['maxuid'] ? ($old['maxuid']+1).':' : '') . $new['maxuid'];
}
// some messages has been deleted
if ($new['cnt'] < $old['cnt']) {
$result += 2;
}
// @TODO: optional checking for messages flags changes (?)
// @TODO: UIDVALIDITY checking
return $result;
}
/**
* Stores folder statistic data in session
* @TODO: move to separate DB table (cache?)
*
* @param string $folder Folder name
* @param string $name Data name
* @param mixed $data Data value
*/
protected function set_folder_stats($folder, $name, $data)
{
$_SESSION['folders'][$folder][$name] = $data;
}
/**
* Gets folder statistic data
*
* @param string $folder Folder name
*
* @return array Stats data
*/
protected function get_folder_stats($folder)
{
if (isset($_SESSION['folders'][$folder])) {
return (array) $_SESSION['folders'][$folder];
}
return [];
}
/**
* Return sorted list of message UIDs
*
* @param string $folder Folder to get index from
* @param string $sort_field Sort column
* @param string $sort_order Sort order [ASC, DESC]
* @param bool $no_threads Get not threaded index
* @param bool $no_search Get index not limited to search result (optionally)
*
* @return rcube_result_index|rcube_result_thread List of messages (UIDs)
*/
public function index($folder = '', $sort_field = null, $sort_order = null,
$no_threads = false, $no_search = false
) {
if (!$no_threads && $this->threading) {
return $this->thread_index($folder, $sort_field, $sort_order);
}
$this->set_sort_order($sort_field, $sort_order);
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
// we have a saved search result, get index from there
if ($this->search_string) {
if ($this->search_set->is_empty()) {
return new rcube_result_index($folder, '* SORT');
}
if ($this->search_set instanceof rcube_result_multifolder) {
$index = $this->search_set;
$index->folder = $folder;
// TODO: handle changed sorting
}
// search result is an index with the same sorting?
else if (($this->search_set instanceof rcube_result_index)
&& ((!$this->sort_field && !$this->search_sorted) ||
($this->search_sorted && $this->search_sort_field == $this->sort_field))
) {
$index = $this->search_set;
}
// $no_search is enabled when we are not interested in
// fetching index for search result, e.g. to sort
// threaded search result we can use full mailbox index.
// This makes possible to use index from cache
else if (!$no_search) {
if (!$this->sort_field) {
// No sorting needed, just build index from the search result
// @TODO: do we need to sort by UID here?
$search = $this->search_set->get_compressed();
$index = new rcube_result_index($folder, '* ESEARCH ALL ' . $search);
}
else {
$index = $this->index_direct($folder, $this->sort_field, $this->sort_order, $this->search_set);
}
}
if (isset($index)) {
if ($this->sort_order != $index->get_parameters('ORDER')) {
$index->revert();
}
return $index;
}
}
// check local cache
if ($mcache = $this->get_mcache_engine()) {
return $mcache->get_index($folder, $this->sort_field, $this->sort_order);
}
// fetch from IMAP server
return $this->index_direct($folder, $this->sort_field, $this->sort_order);
}
/**
* Return sorted list of message UIDs ignoring current search settings.
* Doesn't uses cache by default.
*
* @param string $folder Folder to get index from
* @param string $sort_field Sort column
* @param string $sort_order Sort order [ASC, DESC]
* @param rcube_result_index $search Optional messages set to limit the result
*
* @return rcube_result_index Sorted list of message UIDs
*/
public function index_direct($folder, $sort_field = null, $sort_order = null, $search = null)
{
if (!empty($search)) {
$search = $search->get_compressed();
}
// use message index sort as default sorting
if (!$sort_field) {
// use search result from count() if possible
if (empty($search) && $this->options['skip_deleted']
&& !empty($this->icache['undeleted_idx'])
&& $this->icache['undeleted_idx']->get_parameters('ALL') !== null
&& $this->icache['undeleted_idx']->get_parameters('MAILBOX') == $folder
) {
$index = $this->icache['undeleted_idx'];
}
else if (!$this->check_connection()) {
return new rcube_result_index();
}
else {
$query = $this->options['skip_deleted'] ? 'UNDELETED' : '';
if ($search) {
$query = trim($query . ' UID ' . $search);
}
$index = $this->conn->search($folder, $query, true);
}
}
else if (!$this->check_connection()) {
return new rcube_result_index();
}
// fetch complete message index
else {
if ($this->get_capability('SORT')) {
$query = $this->options['skip_deleted'] ? 'UNDELETED' : '';
if ($search) {
$query = trim($query . ' UID ' . $search);
}
$index = $this->conn->sort($folder, $sort_field, $query, true);
}
if (empty($index) || $index->is_error()) {
$index = $this->conn->index($folder, $search ? $search : "1:*",
$sort_field, $this->options['skip_deleted'],
$search ? true : false, true);
}
}
if ($sort_order != $index->get_parameters('ORDER')) {
$index->revert();
}
return $index;
}
/**
* Return index of threaded message UIDs
*
* @param string $folder Folder to get index from
* @param string $sort_field Sort column
* @param string $sort_order Sort order [ASC, DESC]
*
* @return rcube_result_thread Message UIDs
*/
public function thread_index($folder = '', $sort_field = null, $sort_order = null)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
// we have a saved search result, get index from there
if ($this->search_string && $this->search_threads && $folder == $this->folder) {
$threads = $this->search_set;
}
else {
// get all threads (default sort order)
$threads = $this->threads($folder);
}
$this->set_sort_order($sort_field, $sort_order);
$this->sort_threads($threads);
return $threads;
}
/**
* Sort threaded result, using THREAD=REFS method if available.
* If not, use any method and re-sort the result in THREAD=REFS way.
*
* @param rcube_result_thread $threads Threads result set
*/
protected function sort_threads($threads)
{
if ($threads->is_empty()) {
return;
}
// THREAD=ORDEREDSUBJECT: sorting by sent date of root message
// THREAD=REFERENCES: sorting by sent date of root message
// THREAD=REFS: sorting by the most recent date in each thread
if ($this->threading != 'REFS' || ($this->sort_field && $this->sort_field != 'date')) {
$sortby = $this->sort_field ?: 'date';
$index = $this->index($this->folder, $sortby, $this->sort_order, true, true);
if (!$index->is_empty()) {
$threads->sort($index);
}
}
else if ($this->sort_order != $threads->get_parameters('ORDER')) {
$threads->revert();
}
}
/**
* Invoke search request to IMAP server
*
* @param string $folder Folder name to search in
* @param string $search Search criteria
* @param string $charset Search charset
* @param string $sort_field Header field to sort by
*
* @return rcube_result_index Search result object
* @todo: Search criteria should be provided in non-IMAP format, e.g. array
*/
public function search($folder = '', $search = 'ALL', $charset = null, $sort_field = null)
{
if (!$search) {
$search = 'ALL';
}
if ((is_array($folder) && empty($folder)) || (!is_array($folder) && !strlen($folder))) {
$folder = $this->folder;
}
$plugin = $this->plugins->exec_hook('imap_search_before', [
'folder' => $folder,
'search' => $search,
'charset' => $charset,
'sort_field' => $sort_field,
'threading' => $this->threading,
'result' => null,
]);
$folder = $plugin['folder'];
$search = $plugin['search'];
$charset = $plugin['charset'];
$sort_field = $plugin['sort_field'];
$results = $plugin['result'];
// multi-folder search
if (!$results && is_array($folder) && count($folder) > 1 && $search != 'ALL') {
// connect IMAP to have all the required classes and settings loaded
$this->check_connection();
// disable threading
$this->threading = false;
$searcher = new rcube_imap_search($this->options, $this->conn);
// set limit to not exceed the client's request timeout
$searcher->set_timelimit(60);
// continue existing incomplete search
if (!empty($this->search_set) && $this->search_set->incomplete && $search == $this->search_string) {
$searcher->set_results($this->search_set);
}
// execute the search
$results = $searcher->exec(
$folder,
$search,
$charset ?: $this->default_charset,
$sort_field && $this->get_capability('SORT') ? $sort_field : null,
$this->threading
);
}
else if (!$results) {
$folder = is_array($folder) ? $folder[0] : $folder;
$search = is_array($search) ? $search[$folder] : $search;
$results = $this->search_index($folder, $search, $charset, $sort_field);
}
$sorted = $this->threading || $this->search_sorted || !empty($plugin['search_sorted']);
$this->set_search_set([$search, $results, $charset, $sort_field, $sorted]);
return $results;
}
/**
* Direct (real and simple) SEARCH request (without result sorting and caching).
*
* @param string $folder Mailbox name to search in
* @param string $str Search string
*
* @return rcube_result_index Search result (UIDs)
*/
public function search_once($folder = null, $str = 'ALL')
{
if (!$this->check_connection()) {
return new rcube_result_index();
}
if (!$str) {
$str = 'ALL';
}
// multi-folder search
if (is_array($folder) && count($folder) > 1) {
$searcher = new rcube_imap_search($this->options, $this->conn);
$index = $searcher->exec($folder, $str, $this->default_charset);
}
else {
$folder = is_array($folder) ? $folder[0] : $folder;
if (!strlen($folder)) {
$folder = $this->folder;
}
$index = $this->conn->search($folder, $str, true);
}
return $index;
}
/**
* protected search method
*
* @param string $folder Folder name
* @param string $criteria Search criteria
* @param string $charset Charset
* @param string $sort_field Sorting field
*
* @return rcube_result_index|rcube_result_thread Search results (UIDs)
* @see rcube_imap::search()
*/
protected function search_index($folder, $criteria = 'ALL', $charset = null, $sort_field = null)
{
if (!$this->check_connection()) {
if ($this->threading) {
return new rcube_result_thread();
}
else {
return new rcube_result_index();
}
}
if ($this->options['skip_deleted'] && !preg_match('/UNDELETED/', $criteria)) {
$criteria = 'UNDELETED '.$criteria;
}
// unset CHARSET if criteria string is ASCII, this way
// SEARCH won't be re-sent after "unsupported charset" response
if ($charset && $charset != 'US-ASCII' && is_ascii($criteria)) {
$charset = 'US-ASCII';
}
if ($this->threading) {
$threads = $this->conn->thread($folder, $this->threading, $criteria, true, $charset);
// Error, try with US-ASCII (RFC5256: SORT/THREAD must support US-ASCII and UTF-8,
// but I've seen that Courier doesn't support UTF-8)
if ($threads->is_error() && $charset && $charset != 'US-ASCII') {
$threads = $this->conn->thread($folder, $this->threading,
self::convert_criteria($criteria, $charset), true, 'US-ASCII');
}
return $threads;
}
if ($sort_field && $this->get_capability('SORT')) {
$charset = $charset ?: $this->default_charset;
$messages = $this->conn->sort($folder, $sort_field, $criteria, true, $charset);
// Error, try with US-ASCII (RFC5256: SORT/THREAD must support US-ASCII and UTF-8,
// but I've seen Courier with disabled UTF-8 support)
if ($messages->is_error() && $charset && $charset != 'US-ASCII') {
$messages = $this->conn->sort($folder, $sort_field,
self::convert_criteria($criteria, $charset), true, 'US-ASCII');
}
if (!$messages->is_error()) {
$this->search_sorted = true;
return $messages;
}
}
$messages = $this->conn->search($folder,
($charset && $charset != 'US-ASCII' ? "CHARSET $charset " : '') . $criteria, true);
// Error, try with US-ASCII (some servers may support only US-ASCII)
if ($messages->is_error() && $charset && $charset != 'US-ASCII') {
$messages = $this->conn->search($folder, self::convert_criteria($criteria, $charset), true);
}
$this->search_sorted = false;
return $messages;
}
/**
* Converts charset of search criteria string
*
* @param string $str Search string
* @param string $charset Original charset
* @param string $dest_charset Destination charset (default US-ASCII)
*
* @return string Search string
*/
public static function convert_criteria($str, $charset, $dest_charset = 'US-ASCII')
{
// convert strings to US_ASCII
if (preg_match_all('/\{([0-9]+)\}\r\n/', $str, $matches, PREG_OFFSET_CAPTURE)) {
$last = 0;
$res = '';
foreach ($matches[1] as $m) {
$string_offset = $m[1] + strlen($m[0]) + 4; // {}\r\n
$string = substr($str, $string_offset - 1, $m[0]);
$string = rcube_charset::convert($string, $charset, $dest_charset);
if ($string === false || !strlen($string)) {
continue;
}
$res .= substr($str, $last, $m[1] - $last - 1) . rcube_imap_generic::escape($string);
$last = $m[0] + $string_offset - 1;
}
if ($last < strlen($str)) {
$res .= substr($str, $last, strlen($str)-$last);
}
}
// strings for conversion not found
else {
$res = $str;
}
return $res;
}
/**
* Refresh saved search set
*
* @return array Current search set
*/
public function refresh_search()
{
if (!empty($this->search_string)) {
$this->search(
is_object($this->search_set) ? $this->search_set->get_parameters('MAILBOX') : '',
$this->search_string,
$this->search_charset,
$this->search_sort_field
);
}
return $this->get_search_set();
}
/**
* Flag certain result subsets as 'incomplete'.
* For subsequent refresh_search() calls to only refresh the updated parts.
*/
protected function set_search_dirty($folder)
{
if ($this->search_set && is_a($this->search_set, 'rcube_result_multifolder')) {
if ($subset = $this->search_set->get_set($folder)) {
$subset->incomplete = $this->search_set->incomplete = true;
}
}
}
/**
* Return message headers object of a specific message
*
* @param int $uid Message UID
* @param string $folder Folder to read from
* @param bool $force True to skip cache
*
* @return rcube_message_header Message headers
*/
public function get_message_headers($uid, $folder = null, $force = false)
{
// decode combined UID-folder identifier
if (preg_match('/^\d+-.+/', $uid)) {
list($uid, $folder) = explode('-', $uid, 2);
}
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
// get cached headers
if (!$force && $uid && ($mcache = $this->get_mcache_engine())) {
$headers = $mcache->get_message($folder, $uid);
}
else if (!$this->check_connection()) {
$headers = false;
}
else {
$headers = $this->conn->fetchHeader(
$folder, $uid, true, true, $this->get_fetch_headers(), $this->get_fetch_items());
if (is_object($headers)) {
$headers->folder = $folder;
}
}
return $headers;
}
/**
* Fetch message headers and body structure from the IMAP server and build
* an object structure.
*
* @param int $uid Message UID to fetch
* @param string $folder Folder to read from
*
* @return object rcube_message_header Message data
*/
public function get_message($uid, $folder = null)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
// decode combined UID-folder identifier
if (preg_match('/^\d+-.+/', $uid)) {
list($uid, $folder) = explode('-', $uid, 2);
}
// Check internal cache
if (!empty($this->icache['message']) && ($headers = $this->icache['message'])) {
// Make sure the folder and UID is what we expect.
// In case when the same process works with folders that are personal
// and shared two folders can contain the same UIDs.
if ($headers->uid == $uid && $headers->folder == $folder) {
return $headers;
}
}
$headers = $this->get_message_headers($uid, $folder);
// message doesn't exist?
if (empty($headers)) {
return null;
}
// structure might be cached
if (!empty($headers->structure)) {
return $headers;
}
$this->msg_uid = $uid;
if (!$this->check_connection()) {
return $headers;
}
if (empty($headers->bodystructure)) {
$headers->bodystructure = $this->conn->getStructure($folder, $uid, true);
}
$structure = $headers->bodystructure;
if (empty($structure)) {
return $headers;
}
// set message charset from message headers
if ($headers->charset) {
$this->struct_charset = $headers->charset;
}
else {
$this->struct_charset = $this->structure_charset($structure);
}
$headers->ctype = @strtolower($headers->ctype);
// Here we can recognize malformed BODYSTRUCTURE and
// 1. [@TODO] parse the message in other way to create our own message structure
// 2. or just show the raw message body.
// Example of structure for malformed MIME message:
// ("text" "plain" NIL NIL NIL "7bit" 2154 70 NIL NIL NIL)
if ($headers->ctype && !is_array($structure[0]) && $headers->ctype != 'text/plain'
&& strtolower($structure[0].'/'.$structure[1]) == 'text/plain'
) {
// A special known case "Content-type: text" (#1488968)
if ($headers->ctype == 'text') {
$structure[1] = 'plain';
$headers->ctype = 'text/plain';
}
// we can handle single-part messages, by simple fix in structure (#1486898)
else if (preg_match('/^(text|application)\/(.*)/', $headers->ctype, $m)) {
$structure[0] = $m[1];
$structure[1] = $m[2];
}
else {
// Try to parse the message using rcube_mime_decode.
// We need a better solution, it parses message
// in memory, which wouldn't work for very big messages,
// (it uses up to 10x more memory than the message size)
// it's also buggy and not actively developed
if ($headers->size && rcube_utils::mem_check($headers->size * 10)) {
$raw_msg = $this->get_raw_body($uid);
$struct = rcube_mime::parse_message($raw_msg);
}
else {
return $headers;
}
}
}
if (empty($struct)) {
$struct = $this->structure_part($structure, 0, '', $headers);
}
// some workarounds on simple messages...
if (empty($struct->parts)) {
// ...don't trust given content-type
if (!empty($headers->ctype)) {
$struct->mime_id = '1';
$struct->mimetype = strtolower($headers->ctype);
list($struct->ctype_primary, $struct->ctype_secondary) = explode('/', $struct->mimetype);
}
// ...and charset (there's a case described in #1488968 where invalid content-type
// results in invalid charset in BODYSTRUCTURE)
if (!empty($headers->charset) && $headers->charset != $struct->ctype_parameters['charset']) {
$struct->charset = $headers->charset;
$struct->ctype_parameters['charset'] = $headers->charset;
}
}
$headers->structure = $struct;
return $this->icache['message'] = $headers;
}
/**
* Build message part object
*
* @param array $part
* @param int $count
* @param string $parent
*/
protected function structure_part($part, $count = 0, $parent = '', $mime_headers = null)
{
$struct = new rcube_message_part;
$struct->mime_id = empty($parent) ? (string)$count : "$parent.$count";
// multipart
if (is_array($part[0])) {
$struct->ctype_primary = 'multipart';
/* RFC3501: BODYSTRUCTURE fields of multipart part
part1 array
part2 array
part3 array
....
1. subtype
2. parameters (optional)
3. description (optional)
4. language (optional)
5. location (optional)
*/
// find first non-array entry
for ($i=1; $i<count($part); $i++) {
if (is_string($part[$i])) {
$struct->ctype_secondary = strtolower($part[$i]);
// read content type parameters
if (isset($part[$i+1]) && is_array($part[$i+1])) {
$struct->ctype_parameters = [];
for ($j=0; $j<count($part[$i+1]); $j+=2) {
if (is_string($part[$i+1][$j])) {
$param = strtolower($part[$i+1][$j]);
$struct->ctype_parameters[$param] = $part[$i+1][$j+1];
}
}
}
break;
}
}
$struct->mimetype = 'multipart/'.$struct->ctype_secondary;
// build parts list for headers pre-fetching
for ($i=0; $i<count($part); $i++) {
// fetch message headers if message/rfc822 or named part
if (is_array($part[$i]) && !is_array($part[$i][0])) {
$tmp_part_id = $struct->mime_id ? $struct->mime_id.'.'.($i+1) : $i+1;
if (strtolower($part[$i][0]) == 'message' && strtolower($part[$i][1]) == 'rfc822') {
$mime_part_headers[] = $tmp_part_id;
}
else if ($this->is_attachment_part($part[$i])) {
$mime_part_headers[] = $tmp_part_id;
}
}
}
// pre-fetch headers of all parts (in one command for better performance)
// @TODO: we could do this before _structure_part() call, to fetch
// headers for parts on all levels
if (!empty($mime_part_headers)) {
$mime_part_headers = $this->conn->fetchMIMEHeaders($this->folder,
$this->msg_uid, $mime_part_headers);
}
$struct->parts = [];
for ($i=0, $count=0; $i<count($part); $i++) {
if (!is_array($part[$i])) {
break;
}
$tmp_part_id = $struct->mime_id ? $struct->mime_id.'.'.($i+1) : $i+1;
$struct->parts[] = $this->structure_part($part[$i], ++$count, $struct->mime_id,
!empty($mime_part_headers[$tmp_part_id]) ? $mime_part_headers[$tmp_part_id] : null);
}
return $struct;
}
/* RFC3501: BODYSTRUCTURE fields of non-multipart part
0. type
1. subtype
2. parameters
3. id
4. description
5. encoding
6. size
-- text
7. lines
-- message/rfc822
7. envelope structure
8. body structure
9. lines
--
x. md5 (optional)
x. disposition (optional)
x. language (optional)
x. location (optional)
*/
+ // regular part
+ // Note: If the BODYSTRUCTURE is invalid index 0 and 1 can be NULL (#9896)
+ if (is_array($part[1])) {
+ $struct->ctype_primary = 'multipart';
+ $struct->ctype_secondary = isset($part[0]) ? strtolower($part[0]) : 'mixed';
+ } else {
+ $struct->ctype_primary = isset($part[0]) ? strtolower($part[0]) : 'text';
+ $struct->ctype_secondary = isset($part[1]) ? strtolower($part[1]) : 'plain';
+ }
+
+ $struct->mimetype = $struct->ctype_primary . '/' . $struct->ctype_secondary;
+
// Sometimes it might be: 0. subtype, 1. parameters, ...
$params_idx = is_array($part[1]) ? 1 : 2;
- // regular part
- $struct->ctype_primary = is_array($part[1]) ? 'multipart' : strtolower($part[0]);
- $struct->ctype_secondary = is_array($part[1]) ? strtolower($part[0]) : strtolower($part[1]);
- $struct->mimetype = $struct->ctype_primary.'/'.$struct->ctype_secondary;
-
// read content type parameters
if (is_array($part[$params_idx])) {
$struct->ctype_parameters = [];
for ($i=0; $i<count($part[$params_idx]); $i+=2) {
if (is_string($part[$params_idx][$i])) {
$struct->ctype_parameters[strtolower($part[$params_idx][$i])] = $part[$params_idx][$i+1];
}
}
if (isset($struct->ctype_parameters['charset'])) {
$struct->charset = $struct->ctype_parameters['charset'];
}
}
// #1487700: workaround for lack of charset in malformed structure
if (empty($struct->charset) && !empty($mime_headers) && !empty($mime_headers->charset)) {
$struct->charset = $mime_headers->charset;
}
// Sanitize charset for security
if ($struct->charset && !rcube_charset::is_valid($struct->charset)) {
$struct->charset = '';
}
// read content encoding
if (!empty($part[5]) && !is_array($part[5])) {
$struct->encoding = strtolower($part[5]);
$struct->headers['content-transfer-encoding'] = $struct->encoding;
}
// get part size
if (!empty($part[6])) {
$struct->size = intval($part[6]);
}
// read part disposition
$di = 8;
if ($struct->ctype_primary == 'text') {
$di += 1;
}
else if ($struct->mimetype == 'message/rfc822') {
$di += 3;
}
if (isset($part[$di]) && is_array($part[$di]) && count($part[$di]) == 2) {
$struct->disposition = strtolower($part[$di][0]);
if ($struct->disposition && $struct->disposition !== 'inline' && $struct->disposition !== 'attachment') {
// RFC2183, Section 2.8 - unrecognized type should be treated as "attachment"
$struct->disposition = 'attachment';
}
if (is_array($part[$di][1])) {
for ($n=0; $n<count($part[$di][1]); $n+=2) {
if (is_string($part[$di][1][$n])) {
$struct->d_parameters[strtolower($part[$di][1][$n])] = $part[$di][1][$n+1];
}
}
}
}
// get message/rfc822's child-parts
if (isset($part[8]) && is_array($part[8]) && $di != 8) {
$struct->parts = [];
for ($i=0; $i<count($part[8]); $i++) {
if (!is_array($part[8][$i])) {
break;
}
$subpart_id = $struct->mime_id ? $struct->mime_id . '.' . ($i+1) : $i+1;
if ($this->is_attachment_part($part[8][$i])) {
$mime_part_headers[] = $subpart_id;
}
$struct->parts[$subpart_id] = $part[8][$i];
}
// Fetch attachment parts' headers in one go
if (!empty($mime_part_headers)) {
$mime_part_headers = $this->conn->fetchMIMEHeaders($this->folder, $this->msg_uid, $mime_part_headers);
}
$count = 0;
foreach ($struct->parts as $idx => $subpart) {
$struct->parts[$idx] = $this->structure_part($subpart, ++$count, $struct->mime_id,
!empty($mime_part_headers[$idx]) ? $mime_part_headers[$idx] : null);
}
$struct->parts = array_values($struct->parts);
}
// get part ID
if (!empty($part[3])) {
$struct->content_id = $struct->headers['content-id'] = trim($part[3]);
// FIXME: This is not the best idea. We should get rid of this at some point
if (empty($struct->disposition)) {
$struct->disposition = 'inline';
}
}
// fetch message headers if message/rfc822 or named part (could contain Content-Location header)
if (
$struct->ctype_primary == 'message'
|| (!empty($struct->ctype_parameters['name']) && !empty($struct->content_id))
) {
if (empty($mime_headers)) {
$mime_headers = $this->conn->fetchPartHeader($this->folder, $this->msg_uid, true, $struct->mime_id);
}
if (is_string($mime_headers)) {
$struct->headers = rcube_mime::parse_headers($mime_headers) + $struct->headers;
}
else if (is_object($mime_headers)) {
$struct->headers = get_object_vars($mime_headers) + $struct->headers;
}
// get real content-type of message/rfc822
if ($struct->mimetype == 'message/rfc822') {
// single-part
if (!is_array($part[8][0]) && !is_array($part[8][1])) {
$struct->real_mimetype = strtolower($part[8][0] . '/' . $part[8][1]);
}
// multi-part
else {
for ($n=0; $n<count($part[8]); $n++) {
if (!is_array($part[8][$n])) {
break;
}
}
$struct->real_mimetype = 'multipart/' . strtolower($part[8][$n]);
}
}
if ($struct->ctype_primary == 'message' && empty($struct->parts)) {
if (is_array($part[8]) && $di != 8) {
$struct->parts[] = $this->structure_part($part[8], ++$count, $struct->mime_id);
}
}
}
// normalize filename property
$this->set_part_filename($struct, $mime_headers);
return $struct;
}
/**
* Check if the mail structure part is an attachment part and requires
* fetching the MIME headers for further processing.
*/
protected function is_attachment_part($part)
{
if (!empty($part[2]) && is_array($part[2]) && empty($part[3])) {
$params = array_map('strtolower', array_filter($part[2], 'is_string'));
$find = ['name', 'filename', 'name*', 'filename*', 'name*0', 'filename*0', 'name*0*', 'filename*0*'];
// In case of malformed header check disposition. E.g. some servers for
// "Content-Type: PDF; name=test.pdf" may return text/plain and ignore name argument
return count(array_intersect($params, $find)) > 0
|| (isset($part[9]) && is_array($part[9]) && stripos($part[9][0], 'attachment') === 0);
}
return false;
}
/**
* Set attachment filename from message part structure
*
* @param rcube_message_part $part Part object
* @param string $headers Part's raw headers
*/
protected function set_part_filename(&$part, $headers = null)
{
// Some IMAP servers do not support RFC2231, if we have
// part headers we'll get attachment name from them, not the BODYSTRUCTURE
$rfc2231_params = [];
if (!empty($headers) || !empty($part->headers)) {
if (is_object($headers)) {
$headers = get_object_vars($headers);
}
else {
$headers = !empty($headers) ? rcube_mime::parse_headers($headers) : $part->headers;
}
$ctype = $headers['content-type'] ?? '';
$disposition = $headers['content-disposition'] ?? '';
$tokens = preg_split('/;[\s\r\n\t]*/', $ctype. ';' . $disposition);
foreach ($tokens as $token) {
// TODO: Use order defined by the parameter name not order of occurrence in the header
if (preg_match('/^(name|filename)\*([0-9]*)\*?="*([^"]+)"*/i', $token, $matches)) {
$key = strtolower($matches[1]);
$rfc2231_params[$key] = ($rfc2231_params[$key] ?? '') . $matches[3];
}
}
}
if (isset($rfc2231_params['name'])) {
$filename_encoded = $rfc2231_params['name'];
}
else if (isset($rfc2231_params['filename'])) {
$filename_encoded = $rfc2231_params['filename'];
}
else if (isset($part->d_parameters['filename*'])) {
$filename_encoded = $part->d_parameters['filename*'];
}
else if (isset($part->ctype_parameters['name*'])) {
$filename_encoded = $part->ctype_parameters['name*'];
}
else if (!empty($part->d_parameters['filename'])) {
$filename_mime = $part->d_parameters['filename'];
}
// read 'name' after rfc2231 parameters as it may contain truncated filename (from Thunderbird)
else if (!empty($part->ctype_parameters['name'])) {
$filename_mime = $part->ctype_parameters['name'];
}
else if (!empty($part->headers['content-description'])) {
$filename_mime = $part->headers['content-description'];
}
else {
return;
}
// decode filename according to RFC 2231, Section 4
if (isset($filename_encoded) && preg_match("/^([^']*)'[^']*'(.*)$/", $filename_encoded, $fmatches)) {
$filename_charset = $fmatches[1];
$filename_encoded = $fmatches[2];
$part->filename = rawurldecode($filename_encoded);
if (!empty($filename_charset)) {
$part->filename = rcube_charset::convert($part->filename, $filename_charset);
}
}
else {
if (!empty($part->charset)) {
$charset = $part->charset;
}
else if (!empty($this->struct_charset)) {
$charset = $this->struct_charset;
}
else {
$charset = $this->default_charset;
}
// Note: RFC2047 can appear inside RFC2231 formatted parameters too (#9725)
if (isset($filename_encoded)) {
$filename_mime = $filename_encoded;
}
$part->filename = rcube_mime::decode_mime_string($filename_mime, $charset);
}
// Workaround for invalid Content-Type (#6816)
// Some servers for "Content-Type: PDF; name=test.pdf" may return text/plain and ignore name argument
if ($part->mimetype == 'text/plain' && !empty($headers['content-type'])) {
$tokens = preg_split('/;[\s\r\n\t]*/', $headers['content-type']);
$type = rcube_mime::fix_mimetype($tokens[0]);
if ($type != $part->mimetype) {
$part->mimetype = $type;
list($part->ctype_primary, $part->ctype_secondary) = explode('/', $part->mimetype);
}
}
}
/**
* Get charset name from message structure (first part)
*
* @param array $structure Message structure
*
* @return string Charset name
*/
protected function structure_charset($structure)
{
while (is_array($structure)) {
if (isset($structure[2]) && is_array($structure[2]) && $structure[2][0] == 'charset') {
return $structure[2][1];
}
$structure = $structure[0];
}
}
/**
* Fetch message body of a specific message from the server
*
* @param int $uid Message UID
* @param string $part Part number
* @param rcube_message_part $o_part Part object created by get_structure()
* @param mixed $print True to print part, resource to write part contents in
* @param resource $fp File pointer to save the message part
* @param bool $skip_charset_conv Disables charset conversion
* @param int $max_bytes Only read this number of bytes
* @param bool $formatted Enables formatting of text/* parts bodies
*
* @return string Message/part body if not printed
*/
public function get_message_part($uid, $part = 1, $o_part = null, $print = null, $fp = null,
$skip_charset_conv = false, $max_bytes = 0, $formatted = true)
{
if (!$this->check_connection()) {
return null;
}
// get part data if not provided
if (!is_object($o_part)) {
$structure = $this->conn->getStructure($this->folder, $uid, true);
$part_data = rcube_imap_generic::getStructurePartData($structure, $part);
$o_part = new rcube_message_part;
$o_part->ctype_primary = $part_data['type'] ?? null;
$o_part->ctype_secondary = $part_data['subtype'] ?? null;
$o_part->encoding = $part_data['encoding'] ?? null;
$o_part->charset = $part_data['charset'] ?? null;
$o_part->size = $part_data['size'] ?? 0;
}
$body = '';
// Note: multipart/* parts will have size=0, we don't want to ignore them
if ($o_part && ($o_part->size || $o_part->ctype_primary == 'multipart')) {
$formatted = $formatted && $o_part->ctype_primary == 'text';
$body = $this->conn->handlePartBody($this->folder, $uid, true,
$part ? $part : 'TEXT', $o_part->encoding, $print, $fp, $formatted, $max_bytes);
}
if ($fp || $print) {
return true;
}
// convert charset (if text or message part)
if ($body && preg_match('/^(text|message)$/', $o_part->ctype_primary)) {
// Remove NULL characters if any (#1486189)
if ($formatted && strpos($body, "\x00") !== false) {
$body = str_replace("\x00", '', $body);
}
if (!$skip_charset_conv) {
if (!$o_part->charset || strtoupper($o_part->charset) == 'US-ASCII') {
// try to extract charset information from HTML meta tag (#1488125)
if ($o_part->ctype_secondary == 'html' && preg_match('/<meta[^>]+charset=([a-z0-9-_]+)/i', $body, $m)) {
$o_part->charset = strtoupper($m[1]);
}
else {
$o_part->charset = $this->default_charset;
}
}
$body = rcube_charset::convert($body, $o_part->charset);
}
}
return $body;
}
/**
* Returns the whole message source as string (or saves to a file)
*
* @param int $uid Message UID
* @param resource $fp File pointer to save the message
* @param string $part Optional message part ID
*
* @return string Message source string
*/
public function get_raw_body($uid, $fp = null, $part = null)
{
if (!$this->check_connection()) {
return null;
}
return $this->conn->handlePartBody($this->folder, $uid,
true, $part, null, false, $fp);
}
/**
* Returns the message headers as string
*
* @param int $uid Message UID
* @param string $part Optional message part ID
*
* @return string Message headers string
*/
public function get_raw_headers($uid, $part = null)
{
if (!$this->check_connection()) {
return null;
}
return $this->conn->fetchPartHeader($this->folder, $uid, true, $part);
}
/**
* Sends the whole message source to stdout
*
* @param int $uid Message UID
* @param bool $formatted Enables line-ending formatting
*/
public function print_raw_body($uid, $formatted = true)
{
if (!$this->check_connection()) {
return;
}
$this->conn->handlePartBody($this->folder, $uid, true, null, null, true, null, $formatted);
}
/**
* Set message flag to one or several messages
*
* @param mixed $uids Message UIDs as array or comma-separated string, or '*'
* @param string $flag Flag to set: SEEN, UNDELETED, DELETED, RECENT, ANSWERED, DRAFT, MDNSENT
* @param string $folder Folder name
* @param bool $skip_cache True to skip message cache clean up
*
* @return bool Operation status
*/
public function set_flag($uids, $flag, $folder = null, $skip_cache = false)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
if (!$this->check_connection()) {
return false;
}
$flag = strtoupper($flag);
list($uids, $all_mode) = $this->parse_uids($uids);
if (strpos($flag, 'UN') === 0) {
$result = $this->conn->unflag($folder, $uids, substr($flag, 2));
}
else {
$result = $this->conn->flag($folder, $uids, $flag);
}
if ($result && !$skip_cache) {
// reload message headers if cached
// update flags instead removing from cache
if ($mcache = $this->get_mcache_engine()) {
$status = strpos($flag, 'UN') !== 0;
$mflag = preg_replace('/^UN/', '', $flag);
$mcache->change_flag($folder, $all_mode ? null : explode(',', $uids),
$mflag, $status);
}
// clear cached counters
if ($flag == 'SEEN' || $flag == 'UNSEEN') {
$this->clear_messagecount($folder, ['SEEN', 'UNSEEN']);
}
else if ($flag == 'DELETED' || $flag == 'UNDELETED') {
$this->clear_messagecount($folder, ['ALL', 'THREADS']);
if ($this->options['skip_deleted']) {
// remove cached messages
$this->clear_message_cache($folder, $all_mode ? null : explode(',', $uids));
}
}
$this->set_search_dirty($folder);
}
unset($this->icache['message']);
return $result;
}
/**
* Append a mail message (source) to a specific folder
*
* @param string $folder Target folder
* @param string|array $message The message source string or filename
* or array (of strings and file pointers)
* @param string $headers Headers string if $message contains only the body
* @param bool $is_file True if $message is a filename
* @param array $flags Message flags
* @param mixed $date Message internal date
* @param bool $binary Enables BINARY append
*
* @return int|bool Appended message UID or True on success, False on error
*/
public function save_message($folder, &$message, $headers = '', $is_file = false, $flags = [], $date = null, $binary = false)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
if (!$this->check_connection()) {
return false;
}
// make sure folder exists
if (!$this->folder_exists($folder)) {
return false;
}
$date = $this->date_format($date);
if ($is_file) {
$saved = $this->conn->appendFromFile($folder, $message, $headers, $flags, $date, $binary);
}
else {
$saved = $this->conn->append($folder, $message, $flags, $date, $binary);
}
if ($saved) {
// increase messagecount of the target folder
$this->set_messagecount($folder, 'ALL', 1);
$this->plugins->exec_hook('message_saved', [
'folder' => $folder,
'message' => $message,
'headers' => $headers,
'is_file' => $is_file,
'flags' => $flags,
'date' => $date,
'binary' => $binary,
'result' => $saved,
]);
}
return $saved;
}
/**
* Move a message from one folder to another
*
* @param mixed $uids Message UIDs as array or comma-separated string, or '*'
* @param string $to_mbox Target folder
* @param string $from_mbox Source folder
*
* @return bool True on success, False on error
*/
public function move_message($uids, $to_mbox, $from_mbox = '')
{
if (!strlen($from_mbox)) {
$from_mbox = $this->folder;
}
if ($to_mbox === $from_mbox) {
return false;
}
list($uids, $all_mode) = $this->parse_uids($uids);
// exit if no message uids are specified
if (empty($uids)) {
return false;
}
if (!$this->check_connection()) {
return false;
}
$config = rcube::get_instance()->config;
$to_trash = $to_mbox == $config->get('trash_mbox');
// flag messages as read before moving them
if ($to_trash && $config->get('read_when_deleted')) {
// don't flush cache (4th argument)
$this->set_flag($uids, 'SEEN', $from_mbox, true);
}
// move messages
$moved = $this->conn->move($uids, $from_mbox, $to_mbox);
// when moving to Trash we make sure the folder exists
// as it's uncommon scenario we do this when MOVE fails, not before
if (!$moved && $to_trash && $this->get_response_code() == rcube_storage::TRYCREATE) {
if ($this->create_folder($to_mbox, true, 'trash')) {
$moved = $this->conn->move($uids, $from_mbox, $to_mbox);
}
}
if ($moved) {
$this->clear_messagecount($from_mbox);
$this->clear_messagecount($to_mbox);
$this->set_search_dirty($from_mbox);
$this->set_search_dirty($to_mbox);
// unset internal cache
unset($this->icache['threads']);
unset($this->icache['message']);
// remove message ids from search set
if ($this->search_set && $from_mbox == $this->folder) {
// threads are too complicated to just remove messages from set
if ($this->search_threads || $all_mode) {
$this->refresh_search();
}
else if (!$this->search_set->incomplete) {
$this->search_set->filter(explode(',', $uids), $this->folder);
}
}
// remove cached messages
// @TODO: do cache update instead of clearing it
$this->clear_message_cache($from_mbox, $all_mode ? null : explode(',', $uids));
}
return $moved;
}
/**
* Copy a message from one folder to another
*
* @param mixed $uids Message UIDs as array or comma-separated string, or '*'
* @param string $to_mbox Target folder
* @param string $from_mbox Source folder
*
* @return bool True on success, False on error
*/
public function copy_message($uids, $to_mbox, $from_mbox = '')
{
if (!strlen($from_mbox)) {
$from_mbox = $this->folder;
}
list($uids, ) = $this->parse_uids($uids);
// exit if no message uids are specified
if (empty($uids)) {
return false;
}
if (!$this->check_connection()) {
return false;
}
// copy messages
$copied = $this->conn->copy($uids, $from_mbox, $to_mbox);
if ($copied) {
$this->clear_messagecount($to_mbox);
}
return $copied;
}
/**
* Mark messages as deleted and expunge them
*
* @param mixed $uids Message UIDs as array or comma-separated string, or '*'
* @param string $folder Source folder
*
* @return bool True on success, False on error
*/
public function delete_message($uids, $folder = '')
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
list($uids, $all_mode) = $this->parse_uids($uids);
// exit if no message uids are specified
if (empty($uids)) {
return false;
}
if (!$this->check_connection()) {
return false;
}
$deleted = $this->conn->flag($folder, $uids, 'DELETED');
if ($deleted) {
// send expunge command in order to have the deleted message
// really deleted from the folder
$this->expunge_message($uids, $folder, false);
$this->clear_messagecount($folder);
// unset internal cache
unset($this->icache['threads']);
unset($this->icache['message']);
$this->set_search_dirty($folder);
// remove message ids from search set
if ($this->search_set && $folder == $this->folder) {
// threads are too complicated to just remove messages from set
if ($this->search_threads || $all_mode) {
$this->refresh_search();
}
else if (!$this->search_set->incomplete) {
$this->search_set->filter(explode(',', $uids));
}
}
// remove cached messages
$this->clear_message_cache($folder, $all_mode ? null : explode(',', $uids));
}
return $deleted;
}
/**
* Send IMAP expunge command and clear cache
*
* @param mixed $uids Message UIDs as array or comma-separated string, or '*'
* @param string $folder Folder name
* @param bool $clear_cache False if cache should not be cleared
*
* @return bool True on success, False on failure
*/
public function expunge_message($uids, $folder = null, $clear_cache = true)
{
if ($uids && $this->get_capability('UIDPLUS')) {
list($uids, $all_mode) = $this->parse_uids($uids);
}
else {
$uids = null;
}
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
if (!$this->check_connection()) {
return false;
}
// force folder selection and check if folder is writeable
// to prevent a situation when CLOSE is executed on closed
// or EXPUNGE on read-only folder
$result = $this->conn->select($folder);
if (!$result) {
return false;
}
// CLOSE(+SELECT) should be faster than EXPUNGE
if (empty($uids) || !empty($all_mode)) {
$result = $this->conn->close();
}
else {
$result = $this->conn->expunge($folder, $uids);
}
if ($result && $clear_cache) {
$this->clear_message_cache($folder, (!empty($all_mode) || empty($uids)) ? null : explode(',', $uids));
$this->clear_messagecount($folder);
}
return $result;
}
/**
* Annotate a message.
*
* @param array $annotation Message annotation key-value array
* @param mixed $uids Message UIDs as array or comma-separated string, or '*'
* @param string $folder Folder name
*
* @return bool True on success, False on failure
*/
public function annotate_message($annotation, $uids, $folder = null)
{
[$uids] = $this->parse_uids($uids);
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
if (!$this->check_connection()) {
return false;
}
unset($this->icache['message']);
return $this->conn->storeMessageAnnotation($folder, $uids, $annotation);
}
/* --------------------------------
* folder management
* --------------------------------*/
/**
* Public method for listing subscribed folders.
*
* @param string $root Optional root folder
* @param string $name Optional name pattern
* @param string $filter Optional filter
* @param string $rights Optional ACL requirements
* @param bool $skip_sort Enable to return unsorted list (for better performance)
*
* @return array List of folders
*/
public function list_folders_subscribed($root = '', $name = '*', $filter = null, $rights = null, $skip_sort = false)
{
$cache_key = rcube_cache::key_name('mailboxes', [$root, $name, $filter, $rights]);
// get cached folder list
$a_mboxes = $this->get_cache($cache_key);
if (is_array($a_mboxes)) {
return $a_mboxes;
}
// Give plugins a chance to provide a list of folders
$data = $this->plugins->exec_hook('storage_folders',
['root' => $root, 'name' => $name, 'filter' => $filter, 'mode' => 'LSUB']);
if (isset($data['folders'])) {
$a_mboxes = $data['folders'];
}
else {
$a_mboxes = $this->list_folders_subscribed_direct($root, $name);
}
if (!is_array($a_mboxes)) {
return [];
}
// filter folders list according to rights requirements
if ($rights && $this->get_capability('ACL')) {
$a_mboxes = $this->filter_rights($a_mboxes, $rights);
}
// INBOX should always be available
if (in_array_nocase($root . $name, ['*', '%', 'INBOX', 'INBOX*'])
&& (!$filter || $filter == 'mail') && !in_array('INBOX', $a_mboxes)
) {
array_unshift($a_mboxes, 'INBOX');
}
// sort folders (always sort for cache)
if (!$skip_sort || $this->cache) {
$a_mboxes = $this->sort_folder_list($a_mboxes);
}
// write folders list to cache
$this->update_cache($cache_key, $a_mboxes);
return $a_mboxes;
}
/**
* Method for direct folders listing (LSUB)
*
* @param string $root Optional root folder
* @param string $name Optional name pattern
*
* @return array List of subscribed folders
* @see rcube_imap::list_folders_subscribed()
*/
public function list_folders_subscribed_direct($root = '', $name = '*')
{
if (!$this->check_connection()) {
return null;
}
$config = rcube::get_instance()->config;
$list_root = $root === '' && $this->list_root ? $this->list_root : $root;
// Server supports LIST-EXTENDED, we can use selection options
// #1486225: Some dovecot versions return wrong result using LIST-EXTENDED
$list_extended = !$config->get('imap_force_lsub') && $this->get_capability('LIST-EXTENDED');
if ($list_extended) {
// This will also set folder options, LSUB doesn't do that
$result = $this->conn->listMailboxes($list_root, $name, null, ['SUBSCRIBED']);
}
else {
// retrieve list of folders from IMAP server using LSUB
$result = $this->conn->listSubscribed($list_root, $name);
}
if (!is_array($result)) {
return [];
}
// Add/Remove folders according to some configuration options
$this->list_folders_filter($result, $root . $name, ($list_extended ? 'ext-' : '') . 'subscribed');
// Save the last command state, so we can ignore errors on any following UNSUBSCRIBE calls
$state = $this->save_conn_state();
if ($list_extended) {
// unsubscribe non-existent folders, remove from the list
if ($name == '*' && !empty($this->conn->data['LIST'])) {
foreach ($result as $idx => $folder) {
if (($opts = $this->conn->data['LIST'][$folder])
&& in_array_nocase('\\NonExistent', $opts)
) {
$this->conn->unsubscribe($folder);
unset($result[$idx]);
}
}
}
}
else {
// unsubscribe non-existent folders, remove them from the list
if (!empty($result) && $name == '*') {
$existing = $this->list_folders($root, $name);
// Try to make sure the list of existing folders is not malformed,
// we don't want to unsubscribe existing folders on error
if (is_array($existing) && (!empty($root) || count($existing) > 1)) {
$nonexisting = array_diff($result, $existing);
$result = array_diff($result, $nonexisting);
foreach ($nonexisting as $folder) {
$this->conn->unsubscribe($folder);
}
}
}
}
$this->restore_conn_state($state);
return $result;
}
/**
* Get a list of all folders available on the server
*
* @param string $root IMAP root dir
* @param string $name Optional name pattern
* @param mixed $filter Optional filter
* @param string $rights Optional ACL requirements
* @param bool $skip_sort Enable to return unsorted list (for better performance)
*
* @return array Indexed array with folder names
*/
public function list_folders($root = '', $name = '*', $filter = null, $rights = null, $skip_sort = false)
{
$cache_key = rcube_cache::key_name('mailboxes.list', [$root, $name, $filter, $rights]);
// get cached folder list
$a_mboxes = $this->get_cache($cache_key);
if (is_array($a_mboxes)) {
return $a_mboxes;
}
// Give plugins a chance to provide a list of folders
$data = $this->plugins->exec_hook('storage_folders',
['root' => $root, 'name' => $name, 'filter' => $filter, 'mode' => 'LIST']);
if (isset($data['folders'])) {
$a_mboxes = $data['folders'];
}
else {
// retrieve list of folders from IMAP server
$a_mboxes = $this->list_folders_direct($root, $name);
}
if (!is_array($a_mboxes)) {
$a_mboxes = [];
}
// INBOX should always be available
if (in_array_nocase($root . $name, ['*', '%', 'INBOX', 'INBOX*'])
&& (!$filter || $filter == 'mail') && !in_array('INBOX', $a_mboxes)
) {
array_unshift($a_mboxes, 'INBOX');
}
// cache folder attributes
if ($root == '' && $name == '*' && empty($filter) && !empty($this->conn->data)) {
$this->update_cache('mailboxes.attributes', $this->conn->data['LIST']);
}
// filter folders list according to rights requirements
if ($rights && $this->get_capability('ACL')) {
$a_mboxes = $this->filter_rights($a_mboxes, $rights);
}
// filter folders and sort them
if (!$skip_sort) {
$a_mboxes = $this->sort_folder_list($a_mboxes);
}
// write folders list to cache
$this->update_cache($cache_key, $a_mboxes);
return $a_mboxes;
}
/**
* Method for direct folders listing (LIST)
*
* @param string $root Optional root folder
* @param string $name Optional name pattern
*
* @return array List of folders
* @see rcube_imap::list_folders()
*/
public function list_folders_direct($root = '', $name = '*')
{
if (!$this->check_connection()) {
return null;
}
$list_root = $root === '' && $this->list_root ? $this->list_root : $root;
$result = $this->conn->listMailboxes($list_root, $name);
if (!is_array($result)) {
return [];
}
// Add/Remove folders according to some configuration options
$this->list_folders_filter($result, $root . $name);
return $result;
}
/**
* Apply configured filters on folders list
*/
protected function list_folders_filter(&$result, $root, $update_type = null)
{
$config = rcube::get_instance()->config;
// #1486796: some server configurations doesn't return folders in all namespaces
if ($root === '*' && $config->get('imap_force_ns')) {
$this->list_folders_update($result, $update_type);
}
// Remove hidden folders
if ($config->get('imap_skip_hidden_folders')) {
$result = array_filter($result, function($v) { return $v[0] != '.'; });
}
// Remove folders in shared namespaces (if configured, see self::set_env())
if ($root === '*' && !empty($this->list_excludes)) {
$result = array_filter($result, function($v) {
foreach ($this->list_excludes as $prefix) {
if (strpos($v, $prefix) === 0) {
return false;
}
}
return true;
});
}
}
/**
* Fix folders list by adding folders from other namespaces.
* Needed on some servers e.g. Courier IMAP
*
* @param array $result Reference to folders list
* @param string $type Listing type (ext-subscribed, subscribed or all)
*/
protected function list_folders_update(&$result, $type = null)
{
$namespace = $this->get_namespace();
$search = [];
// build list of namespace prefixes
foreach ((array)$namespace as $ns) {
if (is_array($ns)) {
foreach ($ns as $ns_data) {
if (strlen($ns_data[0])) {
$search[] = $ns_data[0];
}
}
}
}
if (!empty($search)) {
// go through all folders detecting namespace usage
foreach ($result as $folder) {
foreach ($search as $idx => $prefix) {
if (strpos($folder, $prefix) === 0) {
unset($search[$idx]);
}
}
if (empty($search)) {
break;
}
}
// get folders in hidden namespaces and add to the result
foreach ($search as $prefix) {
if ($type == 'ext-subscribed') {
$list = $this->conn->listMailboxes('', $prefix . '*', null, ['SUBSCRIBED']);
}
else if ($type == 'subscribed') {
$list = $this->conn->listSubscribed('', $prefix . '*');
}
else {
$list = $this->conn->listMailboxes('', $prefix . '*');
}
if (!empty($list)) {
$result = array_merge($result, $list);
}
}
}
}
/**
* Filter the given list of folders according to access rights
*
* For performance reasons we assume user has full rights
* on all personal folders.
*/
protected function filter_rights($a_folders, $rights)
{
$regex = '/('.$rights.')/';
foreach ($a_folders as $idx => $folder) {
if ($this->folder_namespace($folder) == 'personal') {
continue;
}
$myrights = implode('', (array)$this->my_rights($folder));
if ($myrights !== null && !preg_match($regex, $myrights)) {
unset($a_folders[$idx]);
}
}
return $a_folders;
}
/**
* Get mailbox quota information
*
* @param string $folder Folder name
*
* @return mixed Quota info or False if not supported
*/
public function get_quota($folder = null)
{
if ($this->get_capability('QUOTA') && $this->check_connection()) {
return $this->conn->getQuota($folder);
}
return false;
}
/**
* Get folder size (size of all messages in a folder)
*
* @param string $folder Folder name
*
* @return int Folder size in bytes, False on error
*/
public function folder_size($folder)
{
if (!is_string($folder) || !strlen($folder)) {
return false;
}
if (!$this->check_connection()) {
return 0;
}
if ($this->get_capability('STATUS=SIZE')) {
$status = $this->conn->status($folder, ['SIZE']);
if (is_array($status) && array_key_exists('SIZE', $status)) {
return (int) $status['SIZE'];
}
}
// On Cyrus we can use special folder annotation, which should be much faster
if ($this->get_vendor() == 'cyrus') {
$idx = '/shared/vendor/cmu/cyrus-imapd/size';
$result = $this->get_metadata($folder, $idx, [], true);
if (!empty($result) && isset($result[$folder][$idx]) && is_numeric($result[$folder][$idx])) {
return $result[$folder][$idx];
}
}
// @TODO: could we try to use QUOTA here?
$result = $this->conn->fetchHeaderIndex($folder, '1:*', 'SIZE', false);
if (is_array($result)) {
$result = array_sum($result);
}
return $result;
}
/**
* Subscribe to a specific folder(s)
*
* @param array $folders Folder name(s)
*
* @return bool True on success, False on failure
*/
public function subscribe($folders)
{
// let this common function do the main work
return $this->change_subscription($folders, 'subscribe');
}
/**
* Unsubscribe folder(s)
*
* @param array $folders Folder name(s)
*
* @return bool True on success, False on failure
*/
public function unsubscribe($folders)
{
// let this common function do the main work
return $this->change_subscription($folders, 'unsubscribe');
}
/**
* Create a new folder on the server and register it in local cache
*
* @param string $folder New folder name
* @param bool $subscribe True if the new folder should be subscribed
* @param string $type Optional folder type (junk, trash, drafts, sent, archive)
* @param bool $noselect Make the folder a \NoSelect folder by adding hierarchy
* separator at the end (useful for server that do not support
* both folders and messages as folder children)
*
* @return bool True on success, False on failure
*/
public function create_folder($folder, $subscribe = false, $type = null, $noselect = false)
{
if (!$this->check_connection()) {
return false;
}
if ($noselect) {
$folder .= $this->delimiter;
}
$result = $this->conn->createFolder($folder, $type ? ["\\" . ucfirst($type)] : null);
// Folder creation may fail when specific special-use flag is not supported.
// Try to create it anyway with no flag specified (#7147)
if (!$result && $type) {
$result = $this->conn->createFolder($folder);
}
// try to subscribe it
if ($result) {
// clear cache
$this->clear_cache('mailboxes', true);
if ($subscribe && !$noselect) {
$this->subscribe($folder);
}
}
return $result;
}
/**
* Set a new name to an existing folder
*
* @param string $folder Folder to rename
* @param string $new_name New folder name
*
* @return bool True on success, False on failure
*/
public function rename_folder($folder, $new_name)
{
if (!strlen($new_name)) {
return false;
}
if (!$this->check_connection()) {
return false;
}
$delm = $this->get_hierarchy_delimiter();
// get list of subscribed folders
if ((strpos($folder, '%') === false) && (strpos($folder, '*') === false)) {
$a_subscribed = $this->list_folders_subscribed($folder . $delm, '*');
$subscribed = $this->folder_exists($folder, true);
}
else {
$a_subscribed = $this->list_folders_subscribed();
$subscribed = in_array($folder, $a_subscribed);
}
$result = $this->conn->renameFolder($folder, $new_name);
if ($result) {
// unsubscribe the old folder, subscribe the new one
if ($subscribed) {
$this->conn->unsubscribe($folder);
$this->conn->subscribe($new_name);
}
// check if folder children are subscribed
foreach ($a_subscribed as $c_subscribed) {
if (strpos($c_subscribed, $folder.$delm) === 0) {
$this->conn->unsubscribe($c_subscribed);
$this->conn->subscribe(preg_replace('/^'.preg_quote($folder, '/').'/',
$new_name, $c_subscribed));
// clear cache
$this->clear_message_cache($c_subscribed);
}
}
// clear cache
$this->clear_message_cache($folder);
$this->clear_cache('mailboxes', true);
}
return $result;
}
/**
* Remove folder (with subfolders) from the server
*
* @param string $folder Folder name
*
* @return bool True on success, False on failure
*/
public function delete_folder($folder)
{
if (!$this->check_connection()) {
return false;
}
$delm = $this->get_hierarchy_delimiter();
// get list of sub-folders or all folders
// if folder name contains special characters
$path = strpos($folder, '*') === false && strpos($folder, '%') === false ? ($folder . $delm) : '';
$sub_mboxes = $this->list_folders($path, '*');
// According to RFC3501 deleting a \Noselect folder
// with subfolders may fail. To workaround this we delete
// subfolders first (in reverse order) (#5466)
if (!empty($sub_mboxes)) {
foreach (array_reverse($sub_mboxes) as $mbox) {
if (strpos($mbox, $folder . $delm) === 0) {
if ($this->conn->deleteFolder($mbox)) {
$this->conn->unsubscribe($mbox);
$this->clear_message_cache($mbox);
}
}
}
}
// delete the folder
if ($result = $this->conn->deleteFolder($folder)) {
// and unsubscribe it
$this->conn->unsubscribe($folder);
$this->clear_message_cache($folder);
}
$this->clear_cache('mailboxes', true);
return $result;
}
/**
* Detect special folder associations stored in storage backend
*/
public function get_special_folders($forced = false)
{
$result = parent::get_special_folders();
$rcube = rcube::get_instance();
// Lock SPECIAL-USE after user preferences change (#4782)
if ($rcube->config->get('lock_special_folders')) {
return $result;
}
if (isset($this->icache['special-use'])) {
return array_merge($result, $this->icache['special-use']);
}
if (!$forced || !$this->get_capability('SPECIAL-USE')) {
return $result;
}
if (!$this->check_connection()) {
return $result;
}
$types = array_map(function($value) { return "\\" . ucfirst($value); }, rcube_storage::$folder_types);
$special = [];
// request \Subscribed flag in LIST response as performance improvement for folder_exists()
$folders = $this->conn->listMailboxes('', '*', ['SUBSCRIBED'], ['SPECIAL-USE']);
if (!empty($folders)) {
foreach ($folders as $idx => $folder) {
if (is_array($folder)) {
$folder = $idx;
}
if (!empty($this->conn->data['LIST'][$folder])) {
$flags = $this->conn->data['LIST'][$folder];
foreach ($types as $type) {
if (in_array($type, $flags)) {
$type = strtolower(substr($type, 1));
// Ignore all but first personal special folder per type (#9781)
if (isset($special[$type]) && $this->folder_namespace($special[$type]) == 'personal') {
continue;
}
$special[$type] = $folder;
}
}
}
}
}
$this->icache['special-use'] = $special;
unset($this->icache['special-folders']);
return array_merge($result, $special);
}
/**
* Set special folder associations stored in storage backend
*/
public function set_special_folders($specials)
{
if (!$this->get_capability('SPECIAL-USE') || !$this->get_capability('METADATA')) {
return false;
}
if (!$this->check_connection()) {
return false;
}
$folders = $this->get_special_folders(true);
$old = isset($this->icache['special-use']) ? (array) $this->icache['special-use'] : [];
foreach ($specials as $type => $folder) {
if (in_array($type, rcube_storage::$folder_types)) {
$old_folder = $old[$type] ?? null;
if ($old_folder !== $folder) {
// unset old-folder metadata
if ($old_folder !== null) {
$this->delete_metadata($old_folder, ['/private/specialuse']);
}
// set new folder metadata
if ($folder) {
$this->set_metadata($folder, ['/private/specialuse' => "\\" . ucfirst($type)]);
}
}
}
}
$this->icache['special-use'] = $specials;
unset($this->icache['special-folders']);
return true;
}
/**
* Checks if folder exists and is subscribed
*
* @param string $folder Folder name
* @param bool $subscription Enable subscription checking
*
* @return bool True or False
*/
public function folder_exists($folder, $subscription = false)
{
if ($folder == 'INBOX') {
return true;
}
$key = $subscription ? 'subscribed' : 'existing';
if (!empty($this->icache[$key]) && in_array($folder, (array) $this->icache[$key])) {
return true;
}
if (!$this->check_connection()) {
return false;
}
if ($subscription) {
// It's possible we already called LIST command, check LIST data
if (!empty($this->conn->data['LIST']) && !empty($this->conn->data['LIST'][$folder])
&& in_array_nocase('\\Subscribed', $this->conn->data['LIST'][$folder])
) {
$a_folders = [$folder];
}
else {
$a_folders = $this->conn->listSubscribed('', $folder);
}
}
else {
// It's possible we already called LIST command, check LIST data
if (!empty($this->conn->data['LIST']) && isset($this->conn->data['LIST'][$folder])) {
$a_folders = [$folder];
}
else {
$a_folders = $this->conn->listMailboxes('', $folder);
}
}
if (is_array($a_folders) && in_array($folder, $a_folders)) {
$this->icache[$key][] = $folder;
return true;
}
return false;
}
/**
* Returns the namespace where the folder is in
*
* @param string $folder Folder name
*
* @return string One of 'personal', 'other' or 'shared'
*/
public function folder_namespace($folder)
{
if ($folder == 'INBOX') {
return 'personal';
}
foreach ($this->namespace as $type => $namespace) {
if (is_array($namespace)) {
foreach ($namespace as $ns) {
if ($len = strlen($ns[0])) {
if (($len > 1 && $folder == substr($ns[0], 0, -1))
|| strpos($folder, $ns[0]) === 0
) {
return $type;
}
}
}
}
}
return 'personal';
}
/**
* Modify folder name according to personal namespace prefix.
* For output it removes prefix of the personal namespace if it's possible.
* For input it adds the prefix. Use it before creating a folder in root
* of the folders tree.
*
* @param string $folder Folder name
* @param string $mode Mode name (out/in)
*
* @return string Folder name
*/
public function mod_folder($folder, $mode = 'out')
{
$prefix = $this->namespace['prefix_' . $mode] ?? null;
if ($prefix === null || $prefix === ''
|| !($prefix_len = strlen($prefix)) || !strlen($folder)
) {
return $folder;
}
// remove prefix for output
if ($mode == 'out') {
if (substr($folder, 0, $prefix_len) === $prefix) {
return substr($folder, $prefix_len);
}
return $folder;
}
// add prefix for input (e.g. folder creation)
return $prefix . $folder;
}
/**
* Gets folder attributes from LIST response, e.g. \Noselect, \Noinferiors
*
* @param string $folder Folder name
* @param bool $force Set to True if attributes should be refreshed
*
* @return array Options list
*/
public function folder_attributes($folder, $force = false)
{
// get attributes directly from LIST command
if (!empty($this->conn->data['LIST'])
&& isset($this->conn->data['LIST'][$folder])
&& is_array($this->conn->data['LIST'][$folder])
) {
$opts = $this->conn->data['LIST'][$folder];
}
// get cached folder attributes
else if (!$force) {
$opts = $this->get_cache('mailboxes.attributes');
if ($opts && isset($opts[$folder])) {
$opts = $opts[$folder];
}
}
if (!isset($opts) || !is_array($opts)) {
if (!$this->check_connection()) {
return [];
}
$this->conn->listMailboxes('', $folder);
if (isset($this->conn->data['LIST'][$folder])) {
$opts = $this->conn->data['LIST'][$folder];
}
}
return isset($opts) && is_array($opts) ? $opts : [];
}
/**
* Gets connection (and current folder) data: UIDVALIDITY, EXISTS, RECENT,
* PERMANENTFLAGS, UIDNEXT, UNSEEN
*
* @param string $folder Folder name
*
* @return array Data
*/
public function folder_data($folder)
{
if (!strlen((string) $folder)) {
$folder = $this->folder !== null ? $this->folder : 'INBOX';
}
if ($this->conn->selected != $folder) {
if (!$this->check_connection()) {
return [];
}
if ($this->conn->select($folder)) {
$this->folder = $folder;
}
else {
return null;
}
}
$data = $this->conn->data;
// add (E)SEARCH result for ALL UNDELETED query
if (!empty($this->icache['undeleted_idx'])
&& $this->icache['undeleted_idx']->get_parameters('MAILBOX') == $folder
) {
$data['UNDELETED'] = $this->icache['undeleted_idx'];
}
// dovecot does not return HIGHESTMODSEQ until requested, we use it though in our caching system
// calling STATUS is needed only once, after first use mod-seq db will be maintained
if (!isset($data['HIGHESTMODSEQ']) && empty($data['NOMODSEQ'])
&& ($this->get_capability('QRESYNC') || $this->get_capability('CONDSTORE'))
) {
if ($add_data = $this->conn->status($folder, ['HIGHESTMODSEQ'])) {
$data = array_merge($data, $add_data);
}
}
return $data;
}
/**
* Returns extended information about the folder
*
* @param string $folder Folder name
*
* @return array Data
*/
public function folder_info($folder)
{
if (!empty($this->icache['options']) && $this->icache['options']['name'] == $folder) {
return $this->icache['options'];
}
// get cached metadata
$cache_key = rcube_cache::key_name('mailboxes.folder-info', [$folder]);
$cached = $this->get_cache($cache_key);
if (is_array($cached)) {
return $cached;
}
$acl = $this->get_capability('ACL');
$namespace = $this->get_namespace();
$options = ['is_root' => false];
// check if the folder is a namespace prefix
if (!empty($namespace)) {
$mbox = $folder . $this->delimiter;
foreach ($namespace as $ns) {
if (!empty($ns)) {
foreach ($ns as $item) {
if ($item[0] === $mbox) {
$options['is_root'] = true;
break 2;
}
}
}
}
}
// check if the folder is other user virtual-root
if ($options['is_root'] && !empty($namespace) && !empty($namespace['other'])) {
$parts = explode($this->delimiter, $folder);
if (count($parts) == 2) {
$mbox = $parts[0] . $this->delimiter;
foreach ($namespace['other'] as $item) {
if ($item[0] === $mbox) {
$options['is_root'] = true;
break;
}
}
}
}
$options['name'] = $folder;
$options['attributes'] = $this->folder_attributes($folder, true);
$options['namespace'] = $this->folder_namespace($folder);
$options['special'] = $this->is_special_folder($folder);
$options['noselect'] = false;
// Set 'noselect' flag
if (is_array($options['attributes'])) {
foreach ($options['attributes'] as $attrib) {
$attrib = strtolower($attrib);
if ($attrib == '\noselect' || $attrib == '\nonexistent') {
$options['noselect'] = true;
}
}
}
else {
$options['noselect'] = true;
}
// Get folder rights (MYRIGHTS)
if ($acl && ($rights = $this->my_rights($folder))) {
$options['rights'] = $rights;
}
// Set 'norename' flag
if (!empty($options['rights'])) {
$rfc_4314 = is_array($this->get_capability('RIGHTS'));
$options['norename'] = ($rfc_4314 && !in_array('x', $options['rights']))
|| (!$rfc_4314 && !in_array('d', $options['rights']));
if (!$options['noselect']) {
$options['noselect'] = !in_array('r', $options['rights']);
}
}
else {
$options['norename'] = $options['is_root'] || $options['namespace'] != 'personal';
}
// update caches
$this->icache['options'] = $options;
$this->update_cache($cache_key, $options);
return $options;
}
/**
* Synchronizes messages cache.
*
* @param string $folder Folder name
*/
public function folder_sync($folder)
{
if ($mcache = $this->get_mcache_engine()) {
$mcache->synchronize($folder);
}
}
/**
* Check if the folder name is valid
*
* @param string $folder Folder name (UTF-8)
* @param string &$char First forbidden character found
*
* @return bool True if the name is valid, False otherwise
*/
public function folder_validate($folder, &$char = null)
{
if (parent::folder_validate($folder, $char)) {
$vendor = $this->get_vendor();
$regexp = '\\x00-\\x1F\\x7F%*';
if ($vendor == 'cyrus') {
// List based on testing Kolab's Cyrus-IMAP 2.5
$regexp .= '!`@(){}|\\?<;"';
}
if (!preg_match("/[$regexp]/", $folder, $m)) {
return true;
}
$char = $m[0];
}
return false;
}
/**
* Get message header names for rcube_imap_generic::fetchHeader(s)
*
* @return string Space-separated list of header names
*/
protected function get_fetch_headers()
{
if (!empty($this->options['fetch_headers'])) {
$headers = explode(' ', $this->options['fetch_headers']);
}
else {
$headers = [];
}
if ($this->messages_caching || !empty($this->options['all_headers'])) {
$headers = array_merge($headers, $this->all_headers);
}
return $headers;
}
/**
* Get additional FETCH items for rcube_imap_generic::fetchHeader(s)
*
* @return array List of items
*/
protected function get_fetch_items()
{
return $this->options['fetch_items'] ?? [];
}
/* -----------------------------------------
* ACL and METADATA/ANNOTATEMORE methods
* ----------------------------------------*/
/**
* Changes the ACL on the specified folder (SETACL)
*
* @param string $folder Folder name
* @param string $user User name
* @param string $acl ACL string
*
* @return bool True on success, False on failure
* @since 0.5-beta
*/
public function set_acl($folder, $user, $acl)
{
if (!$this->get_capability('ACL')) {
return false;
}
if (!$this->check_connection()) {
return false;
}
$this->clear_cache(rcube_cache::key_name('mailboxes.folder-info', [$folder]));
return $this->conn->setACL($folder, $user, $acl);
}
/**
* Removes any <identifier,rights> pair for the
* specified user from the ACL for the specified
* folder (DELETEACL)
*
* @param string $folder Folder name
* @param string $user User name
*
* @return bool True on success, False on failure
* @since 0.5-beta
*/
public function delete_acl($folder, $user)
{
if (!$this->get_capability('ACL')) {
return false;
}
if (!$this->check_connection()) {
return false;
}
return $this->conn->deleteACL($folder, $user);
}
/**
* Returns the access control list for folder (GETACL)
*
* @param string $folder Folder name
*
* @return array User-rights array on success, NULL on error
* @since 0.5-beta
*/
public function get_acl($folder)
{
if (!$this->get_capability('ACL')) {
return null;
}
if (!$this->check_connection()) {
return null;
}
return $this->conn->getACL($folder);
}
/**
* Returns information about what rights can be granted to the
* user (identifier) in the ACL for the folder (LISTRIGHTS)
*
* @param string $folder Folder name
* @param string $user User name
*
* @return array List of user rights
* @since 0.5-beta
*/
public function list_rights($folder, $user)
{
if (!$this->get_capability('ACL')) {
return null;
}
if (!$this->check_connection()) {
return null;
}
return $this->conn->listRights($folder, $user);
}
/**
* Returns the set of rights that the current user has to
* folder (MYRIGHTS)
*
* @param string $folder Folder name
*
* @return array MYRIGHTS response on success, NULL on error
* @since 0.5-beta
*/
public function my_rights($folder)
{
if (!$this->get_capability('ACL')) {
return null;
}
if (!$this->check_connection()) {
return null;
}
return $this->conn->myRights($folder);
}
/**
* Sets IMAP metadata/annotations (SETMETADATA/SETANNOTATION)
*
* @param string $folder Folder name (empty for server metadata)
* @param array $entries Entry-value array (use NULL value as NIL)
*
* @return bool True on success, False on failure
* @since 0.5-beta
*/
public function set_metadata($folder, $entries)
{
if (!$this->check_connection()) {
return false;
}
$this->clear_cache('mailboxes.metadata.', true);
if ($this->get_capability('METADATA') ||
(!is_string($folder) || !strlen($folder) && $this->get_capability('METADATA-SERVER'))
) {
return $this->conn->setMetadata($folder, $entries);
}
if ($this->get_capability('ANNOTATEMORE') || $this->get_capability('ANNOTATEMORE2')) {
foreach ((array)$entries as $entry => $value) {
list($ent, $attr) = $this->md2annotate($entry);
$entries[$entry] = [$ent, $attr, $value];
}
return $this->conn->setAnnotation($folder, $entries);
}
return false;
}
/**
* Unsets IMAP metadata/annotations (SETMETADATA/SETANNOTATION)
*
* @param string $folder Folder name (empty for server metadata)
* @param array $entries Entry names array
*
* @return bool True on success, False on failure
* @since 0.5-beta
*/
public function delete_metadata($folder, $entries)
{
if (!$this->check_connection()) {
return false;
}
$this->clear_cache('mailboxes.metadata.', true);
if ($this->get_capability('METADATA') ||
(!is_string($folder) || !strlen($folder) && $this->get_capability('METADATA-SERVER'))
) {
return $this->conn->deleteMetadata($folder, $entries);
}
if ($this->get_capability('ANNOTATEMORE') || $this->get_capability('ANNOTATEMORE2')) {
foreach ((array)$entries as $idx => $entry) {
list($ent, $attr) = $this->md2annotate($entry);
$entries[$idx] = [$ent, $attr, null];
}
return $this->conn->setAnnotation($folder, $entries);
}
return false;
}
/**
* Returns IMAP metadata/annotations (GETMETADATA/GETANNOTATION)
*
* @param string $folder Folder name (empty for server metadata)
* @param array $entries Entries
* @param array $options Command options (with MAXSIZE and DEPTH keys)
* @param bool $force Disables cache use
*
* @return array Metadata entry-value hash array on success, NULL on error
* @since 0.5-beta
*/
public function get_metadata($folder, $entries, $options = [], $force = false)
{
$entries = (array) $entries;
if (!$force) {
$cache_key = rcube_cache::key_name('mailboxes.metadata', [$folder, $options, $entries]);
// get cached data
$cached_data = $this->get_cache($cache_key);
if (is_array($cached_data)) {
return $cached_data;
}
}
if (!$this->check_connection()) {
return null;
}
if ($this->get_capability('METADATA') ||
(!is_string($folder) || !strlen($folder) && $this->get_capability('METADATA-SERVER'))
) {
$res = $this->conn->getMetadata($folder, $entries, $options);
}
else if ($this->get_capability('ANNOTATEMORE') || $this->get_capability('ANNOTATEMORE2')) {
$queries = [];
$res = [];
// Convert entry names
foreach ($entries as $entry) {
list($ent, $attr) = $this->md2annotate($entry);
$queries[$attr][] = $ent;
}
// @TODO: Honor MAXSIZE and DEPTH options
foreach ($queries as $attrib => $entry) {
$result = $this->conn->getAnnotation($folder, $entry, $attrib);
// an error, invalidate any previous getAnnotation() results
if (!is_array($result)) {
return null;
}
foreach ($result as $fldr => $data) {
$res[$fldr] = array_merge((array) $res[$fldr], $data);
}
}
}
if (isset($res)) {
if (!$force && !empty($cache_key)) {
$this->update_cache($cache_key, $res);
}
return $res;
}
}
/**
* Converts the METADATA extension entry name into the correct
* entry-attrib names for older ANNOTATEMORE version.
*
* @param string $entry Entry name
*
* @return array Entry-attribute list, NULL if not supported (?)
*/
protected function md2annotate($entry)
{
if (substr($entry, 0, 7) == '/shared') {
return [substr($entry, 7), 'value.shared'];
}
else if (substr($entry, 0, 8) == '/private') {
return [substr($entry, 8), 'value.priv'];
}
// @TODO: log error
}
/* --------------------------------
* internal caching methods
* --------------------------------*/
/**
* Enable or disable indexes caching
*
* @param string $type Cache type (@see rcube::get_cache)
*/
public function set_caching($type)
{
if ($type) {
$this->caching = $type;
}
else {
if ($this->cache) {
$this->cache->close();
}
$this->cache = null;
$this->caching = false;
}
}
/**
* Getter for IMAP cache object
*/
protected function get_cache_engine()
{
if ($this->caching && !$this->cache) {
$rcube = rcube::get_instance();
$ttl = $rcube->config->get('imap_cache_ttl', '10d');
$this->cache = $rcube->get_cache('IMAP', $this->caching, $ttl);
}
return $this->cache;
}
/**
* Returns cached value
*
* @param string $key Cache key
*
* @return mixed
*/
public function get_cache($key)
{
if ($cache = $this->get_cache_engine()) {
return $cache->get($key);
}
}
/**
* Update cache
*
* @param string $key Cache key
* @param mixed $data Data
*/
public function update_cache($key, $data)
{
if ($cache = $this->get_cache_engine()) {
$cache->set($key, $data);
}
}
/**
* Clears the cache.
*
* @param string $key Cache key name or pattern
* @param bool $prefix_mode Enable it to clear all keys starting
* with prefix specified in $key
*/
public function clear_cache($key = null, $prefix_mode = false)
{
if ($cache = $this->get_cache_engine()) {
$cache->remove($key, $prefix_mode);
}
}
/* --------------------------------
* message caching methods
* --------------------------------*/
/**
* Enable or disable messages caching
*
* @param bool $set Flag
* @param int $mode Cache mode
*/
public function set_messages_caching($set, $mode = null)
{
if ($set) {
$this->messages_caching = true;
if ($mode && ($cache = $this->get_mcache_engine())) {
$cache->set_mode($mode);
}
}
else {
if ($this->mcache) {
$this->mcache->close();
}
$this->mcache = null;
$this->messages_caching = false;
}
}
/**
* Getter for messages cache object
*/
protected function get_mcache_engine()
{
if ($this->messages_caching && !$this->mcache) {
$rcube = rcube::get_instance();
if (($dbh = $rcube->get_dbh()) && ($userid = $rcube->get_user_id())) {
$ttl = $rcube->config->get('messages_cache_ttl', '10d');
$threshold = $rcube->config->get('messages_cache_threshold', 50);
$this->mcache = new rcube_imap_cache(
$dbh, $this, $userid, $this->options['skip_deleted'], $ttl, $threshold);
}
}
return $this->mcache;
}
/**
* Clears the messages cache.
*
* @param string $folder Folder name
* @param array $uids Optional message UIDs to remove from cache
*/
protected function clear_message_cache($folder = null, $uids = null)
{
if ($mcache = $this->get_mcache_engine()) {
$mcache->clear($folder, $uids);
}
}
/**
* Delete outdated cache entries
*/
function cache_gc()
{
rcube_imap_cache::gc();
}
/* --------------------------------
* protected methods
* --------------------------------*/
/**
* Determines if server supports dual use folders (those can
* contain both sub-folders and messages).
*
* @return bool
*/
protected function detect_dual_use_folders()
{
$val = rcube::get_instance()->config->get('imap_dual_use_folders');
if ($val !== null) {
return (bool) $val;
}
if (!$this->check_connection()) {
return false;
}
$folder = str_replace('.', '', 'foldertest' . microtime(true));
$folder = $this->mod_folder($folder, 'in');
$subfolder = $folder . $this->delimiter . 'foldertest';
if ($this->conn->createFolder($folder)) {
if ($created = $this->conn->createFolder($subfolder)) {
$this->conn->deleteFolder($subfolder);
}
$this->conn->deleteFolder($folder);
return $created;
}
return false;
}
/**
* Validate the given input and save to local properties
*
* @param string $sort_field Sort column
* @param string $sort_order Sort order
*/
protected function set_sort_order($sort_field, $sort_order)
{
if ($sort_field != null) {
$this->sort_field = asciiwords($sort_field);
}
if ($sort_order != null) {
$this->sort_order = strtoupper($sort_order) == 'DESC' ? 'DESC' : 'ASC';
}
}
/**
* Sort folders in alphabetical order. Optionally put special folders
* first and other-users/shared namespaces last.
*
* @param array $a_folders Folders list
* @param bool $skip_special Skip special folders handling
*
* @return array Sorted list
*/
public function sort_folder_list($a_folders, $skip_special = false)
{
$folders = [];
// convert names to UTF-8
foreach ($a_folders as $folder) {
// for better performance skip encoding conversion
// if the string does not look like UTF7-IMAP
$folders[$folder] = strpos($folder, '&') === false ? $folder : rcube_charset::convert($folder, 'UTF7-IMAP');
}
// sort folders
// asort($folders, SORT_LOCALE_STRING) is not properly sorting case sensitive names
uasort($folders, [$this, 'sort_folder_comparator']);
$folders = array_keys($folders);
if ($skip_special || empty($folders)) {
return $folders;
}
// Collect special folders and non-personal namespace roots
$specials = array_merge(['INBOX'], array_values($this->get_special_folders()));
$ns_roots = [];
foreach (['other', 'shared'] as $ns_name) {
if ($ns = $this->get_namespace($ns_name)) {
foreach ($ns as $root) {
if (isset($root[0]) && strlen($root[0])) {
$ns_roots[rtrim($root[0], $root[1])] = $root[0];
}
}
}
}
// Force the type of folder name variable (#1485527)
$folders = array_map('strval', $folders);
$out = [];
// Put special folders on top...
$specials = array_unique(array_intersect($specials, $folders));
$folders = array_merge($specials, array_diff($folders, $specials));
// ... and rebuild the list to move their subfolders where they belong
$this->sort_folder_specials(null, $folders, $specials, $out);
// Put other-user/shared namespaces at the end
if (!empty($ns_roots)) {
$folders = [];
foreach ($out as $folder) {
foreach ($ns_roots as $root => $prefix) {
if ($folder === $root || strpos($folder, $prefix) === 0) {
$folders[] = $folder;
}
}
}
if (!empty($folders)) {
$out = array_merge(array_diff($out, $folders), $folders);
}
}
return $out;
}
/**
* Recursive function to put subfolders of special folders in place
*/
protected function sort_folder_specials($folder, &$list, &$specials, &$out)
{
$count = count($list);
for ($i = 0; $i < $count; $i++) {
$name = $list[$i];
if ($name === null) {
continue;
}
if ($folder === null || strpos($name, $folder.$this->delimiter) === 0) {
$out[] = $name;
$list[$i] = null;
if (!empty($specials) && ($found = array_search($name, $specials)) !== false) {
unset($specials[$found]);
$this->sort_folder_specials($name, $list, $specials, $out);
}
}
}
}
/**
* Callback for uasort() that implements correct
* locale-aware case-sensitive sorting
*/
protected function sort_folder_comparator($str1, $str2)
{
if ($this->sort_folder_collator === null) {
$this->sort_folder_collator = false;
// strcoll() does not work with UTF8 locale on Windows,
// use Collator from the intl extension
if (stripos(PHP_OS, 'win') === 0 && function_exists('collator_compare')) {
$locale = $this->options['language'] ?: 'en_US';
$this->sort_folder_collator = collator_create($locale) ?: false;
}
}
$path1 = explode($this->delimiter, $str1);
$path2 = explode($this->delimiter, $str2);
$len = max(count($path1), count($path2));
for ($idx = 0; $idx < $len; $idx++) {
$folder1 = $path1[$idx] ?? '';
$folder2 = $path2[$idx] ?? '';
if ($folder1 === $folder2) {
continue;
}
if ($this->sort_folder_collator) {
return collator_compare($this->sort_folder_collator, $folder1, $folder2);
}
return strcoll($folder1, $folder2);
}
}
/**
* Find UID of the specified message sequence ID
*
* @param int $id Message (sequence) ID
* @param string $folder Folder name
*
* @return int Message UID
*/
public function id2uid($id, $folder = null)
{
if (!is_string($folder) || !strlen($folder)) {
$folder = $this->folder;
}
if (!$this->check_connection()) {
return null;
}
return $this->conn->ID2UID($folder, $id);
}
/**
* Subscribe/unsubscribe a list of folders and update local cache
*/
protected function change_subscription($folders, $mode)
{
$updated = 0;
$folders = (array) $folders;
if (!empty($folders)) {
if (!$this->check_connection()) {
return false;
}
foreach ($folders as $folder) {
$updated += (int) $this->conn->{$mode}($folder);
}
}
// clear cached folders list(s)
if ($updated) {
$this->clear_cache('mailboxes', true);
}
return $updated == count($folders);
}
/**
* Increase/decrease messagecount for a specific folder
*/
protected function set_messagecount($folder, $mode, $increment)
{
if (!is_numeric($increment)) {
return false;
}
$mode = strtoupper($mode);
$a_folder_cache = $this->get_cache('messagecount');
if (
!isset($a_folder_cache[$folder])
|| !is_array($a_folder_cache[$folder])
|| !isset($a_folder_cache[$folder][$mode])
) {
return false;
}
// add incremental value to messagecount
$a_folder_cache[$folder][$mode] += $increment;
// there's something wrong, delete from cache
if ($a_folder_cache[$folder][$mode] < 0) {
unset($a_folder_cache[$folder][$mode]);
}
// write back to cache
$this->update_cache('messagecount', $a_folder_cache);
return true;
}
/**
* Remove messagecount of a specific folder from cache
*/
protected function clear_messagecount($folder, $mode = [])
{
$a_folder_cache = $this->get_cache('messagecount');
if (isset($a_folder_cache[$folder]) && is_array($a_folder_cache[$folder])) {
if (!empty($mode)) {
foreach ((array) $mode as $key) {
unset($a_folder_cache[$folder][$key]);
}
}
else {
unset($a_folder_cache[$folder]);
}
$this->update_cache('messagecount', $a_folder_cache);
}
}
/**
* Converts date string/object into IMAP date/time format
*/
protected function date_format($date)
{
if (empty($date)) {
return null;
}
if (!is_object($date) || !is_a($date, 'DateTime')) {
try {
$timestamp = rcube_utils::strtotime($date);
$date = new DateTime("@".$timestamp);
}
catch (Exception $e) {
return null;
}
}
return $date->format('d-M-Y H:i:s O');
}
/**
* Remember state of the IMAP connection (last IMAP command).
* Use e.g. if you want to execute more commands and ignore results of these.
*
* @return array Connection state
*/
protected function save_conn_state()
{
return [
$this->conn->error,
$this->conn->errornum,
$this->conn->resultcode,
];
}
/**
* Restore saved connection state.
*
* @param array $state Connection result
*/
protected function restore_conn_state($state)
{
list($this->conn->error, $this->conn->errornum, $this->conn->resultcode) = $state;
}
/**
* This is our own debug handler for the IMAP connection
*/
public function debug_handler($imap, $message)
{
rcube::write_log('imap', $message);
}
}
diff --git a/program/lib/Roundcube/rcube_imap_generic.php b/program/lib/Roundcube/rcube_imap_generic.php
index 9a06cca96..5942ab175 100644
--- a/program/lib/Roundcube/rcube_imap_generic.php
+++ b/program/lib/Roundcube/rcube_imap_generic.php
@@ -1,4355 +1,4357 @@
<?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: |
| Provide alternative IMAP library that doesn't rely on the standard |
| C-Client based version. This allows to function regardless |
| of whether or not the PHP build it's running on has IMAP |
| functionality built-in. |
| |
| Based on Iloha IMAP Library. See http://ilohamail.org/ for details |
+-----------------------------------------------------------------------+
| Author: Aleksander Machniak <alec@alec.pl> |
| Author: Ryo Chijiiwa <Ryo@IlohaMail.org> |
+-----------------------------------------------------------------------+
*/
/**
* PHP based wrapper class to connect to an IMAP server
*
* @package Framework
* @subpackage Storage
*/
class rcube_imap_generic
{
public $error;
public $errornum;
public $result;
public $resultcode;
public $selected;
public $data = [];
public $flags = [
'SEEN' => '\\Seen',
'DELETED' => '\\Deleted',
'ANSWERED' => '\\Answered',
'DRAFT' => '\\Draft',
'FLAGGED' => '\\Flagged',
'FORWARDED' => '$Forwarded',
'MDNSENT' => '$MDNSent',
'*' => '\\*',
];
protected $fp;
protected $host;
protected $user;
protected $cmd_tag;
protected $cmd_num = 0;
protected $resourceid;
protected $extensions_enabled;
protected $prefs = [];
protected $logged = false;
protected $capability = [];
protected $capability_read = false;
protected $debug = false;
protected $debug_handler = false;
const ERROR_OK = 0;
const ERROR_NO = -1;
const ERROR_BAD = -2;
const ERROR_BYE = -3;
const ERROR_UNKNOWN = -4;
const ERROR_COMMAND = -5;
const ERROR_READONLY = -6;
const COMMAND_NORESPONSE = 1;
const COMMAND_CAPABILITY = 2;
const COMMAND_LASTLINE = 4;
const COMMAND_ANONYMIZED = 8;
const DEBUG_LINE_LENGTH = 4098; // 4KB + 2B for \r\n
/**
* Send simple (one line) command to the connection stream
*
* @param string $string Command string
* @param bool $endln True if CRLF need to be added at the end of command
* @param bool $anonymized Don't write the given data to log but a placeholder
*
* @return int Number of bytes sent, False on error
*/
protected function putLine($string, $endln = true, $anonymized = false)
{
if (!$this->fp) {
return false;
}
if ($this->debug) {
// anonymize the sent command for logging
$cut = $endln ? 2 : 0;
if ($anonymized && preg_match('/^(A\d+ (?:[A-Z]+ )+)(.+)/', $string, $m)) {
$log = $m[1] . sprintf('****** [%d]', strlen($m[2]) - $cut);
}
else if ($anonymized) {
$log = sprintf('****** [%d]', strlen($string) - $cut);
}
else {
$log = rtrim($string);
}
$this->debug('C: ' . $log);
}
if ($endln) {
$string .= "\r\n";
}
$res = fwrite($this->fp, $string);
if ($res === false) {
$this->closeSocket();
}
return $res;
}
/**
* Send command to the connection stream with Command Continuation
* Requests (RFC3501 7.5) and LITERAL+ (RFC2088) and LITERAL- (RFC7888) support.
*
* @param string $string Command string
* @param bool $endln True if CRLF need to be added at the end of command
* @param bool $anonymized Don't write the given data to log but a placeholder
*
* @return int|bool Number of bytes sent, False on error
*/
protected function putLineC($string, $endln = true, $anonymized = false)
{
if (!$this->fp) {
return false;
}
if ($endln) {
$string .= "\r\n";
}
$res = 0;
if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) {
for ($i = 0, $cnt = count($parts); $i < $cnt; $i++) {
if ($i + 1 < $cnt && preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i+1], $matches)) {
// LITERAL+/LITERAL- support
$literal_plus = false;
if (
!empty($this->prefs['literal+'])
|| (!empty($this->prefs['literal-']) && $matches[1] <= 4096)
) {
$parts[$i+1] = sprintf("{%d+}\r\n", $matches[1]);
$literal_plus = true;
}
$bytes = $this->putLine($parts[$i].$parts[$i+1], false, $anonymized);
if ($bytes === false) {
return false;
}
$res += $bytes;
// don't wait if server supports LITERAL+ capability
if (!$literal_plus) {
$line = $this->readLine(1000);
// handle error in command
if (!isset($line[0]) || $line[0] != '+') {
return false;
}
}
$i++;
}
else {
$bytes = $this->putLine($parts[$i], false, $anonymized);
if ($bytes === false) {
return false;
}
$res += $bytes;
}
}
}
return $res;
}
/**
* Reads line from the connection stream
*
* @param int $size Buffer size
*
* @return string Line of text response
*/
protected function readLine($size = 1024)
{
$line = '';
if (!$size) {
$size = 1024;
}
do {
if ($this->eof()) {
return $line;
}
$buffer = fgets($this->fp, $size);
if ($buffer === false) {
$this->closeSocket();
break;
}
if ($this->debug) {
$this->debug('S: '. rtrim($buffer));
}
$line .= $buffer;
}
while (substr($buffer, -1) != "\n");
return $line;
}
/**
* Reads a line of data from the connection stream including all
* string continuation literals.
*
* @param int $size Buffer size
*
* @return string Line of text response
*/
protected function readFullLine($size = 1024)
{
$line = $this->readLine($size);
// include all string literals untile the real end of "line"
while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) {
$bytes = $m[1];
$out = '';
while (strlen($out) < $bytes) {
$out = $this->readBytes($bytes);
if ($out === '') {
break;
}
$line .= $out;
}
$line .= $this->readLine($size);
}
return $line;
}
/**
* Reads more data from the connection stream when provided
* data contain string literal
*
* @param string $line Response text
* @param bool $escape Enables escaping
*
* @return string Line of text response
*/
protected function multLine($line, $escape = false)
{
$line = rtrim($line);
if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
$out = '';
$str = substr($line, 0, -strlen($m[0]));
$bytes = $m[1];
while (strlen($out) < $bytes) {
$line = $this->readBytes($bytes);
if ($line === '') {
break;
}
$out .= $line;
}
$line = $str . ($escape ? $this->escape($out) : $out);
}
return $line;
}
/**
* Reads specified number of bytes from the connection stream
*
* @param int $bytes Number of bytes to get
*
* @return string Response text
*/
protected function readBytes($bytes)
{
$data = '';
$len = 0;
while ($len < $bytes && !$this->eof()) {
$d = fread($this->fp, $bytes-$len);
if ($this->debug) {
$this->debug('S: '. $d);
}
$data .= $d;
$data_len = strlen($data);
if ($len == $data_len) {
break; // nothing was read -> exit to avoid apache lockups
}
$len = $data_len;
}
return $data;
}
/**
* Reads complete response to the IMAP command
*
* @param array $untagged Will be filled with untagged response lines
*
* @return string Response text
*/
protected function readReply(&$untagged = null)
{
while (true) {
$line = trim($this->readLine(1024));
// store untagged response lines
if (isset($line[0]) && $line[0] == '*') {
$untagged[] = $line;
}
else {
break;
}
}
if ($untagged) {
$untagged = implode("\n", $untagged);
}
return $line;
}
/**
* Response parser.
*
* @param string $string Response text
* @param string $err_prefix Error message prefix
*
* @return int Response status
*/
protected function parseResult($string, $err_prefix = '')
{
if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) {
$res = strtoupper($matches[1]);
$str = trim($matches[2]);
if ($res == 'OK') {
$this->errornum = self::ERROR_OK;
}
else if ($res == 'NO') {
$this->errornum = self::ERROR_NO;
}
else if ($res == 'BAD') {
$this->errornum = self::ERROR_BAD;
}
else if ($res == 'BYE') {
$this->closeSocket();
$this->errornum = self::ERROR_BYE;
}
if ($str) {
$str = trim($str);
// get response string and code (RFC5530)
if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) {
$this->resultcode = strtoupper($m[1]);
$str = trim(substr($str, strlen($m[1]) + 2));
}
else {
$this->resultcode = null;
// parse response for [APPENDUID 1204196876 3456]
if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) {
$this->data['APPENDUID'] = $m[1];
}
// parse response for [COPYUID 1204196876 3456:3457 123:124]
else if (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) {
$this->data['COPYUID'] = [$m[1], $m[2]];
}
}
$this->result = $str;
if ($this->errornum != self::ERROR_OK) {
$this->error = $err_prefix ? $err_prefix.$str : $str;
}
}
return $this->errornum;
}
return self::ERROR_UNKNOWN;
}
/**
* Checks connection stream state.
*
* @return bool True if connection is closed
*/
protected function eof()
{
if (!$this->fp) {
return true;
}
// If a connection opened by fsockopen() wasn't closed
// by the server, feof() will hang.
$start = microtime(true);
if (feof($this->fp) ||
($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout']))
) {
$this->closeSocket();
return true;
}
return false;
}
/**
* Closes connection stream.
*/
protected function closeSocket()
{
if ($this->fp) {
fclose($this->fp);
$this->fp = null;
}
}
/**
* Error code/message setter.
*/
protected function setError($code, $msg = '')
{
$this->errornum = $code;
$this->error = $msg;
return $code;
}
/**
* Checks response status.
* Checks if command response line starts with specified prefix (or * BYE/BAD)
*
* @param string $string Response text
* @param string $match Prefix to match with (case-sensitive)
* @param bool $error Enables BYE/BAD checking
* @param bool $nonempty Enables empty response checking
*
* @return bool True any check is true or connection is closed.
*/
protected function startsWith($string, $match, $error = false, $nonempty = false)
{
if (!$this->fp) {
return true;
}
if (strncmp($string, $match, strlen($match)) == 0) {
return true;
}
if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) {
if (strtoupper($m[1]) == 'BYE') {
$this->closeSocket();
}
return true;
}
if ($nonempty && !strlen($string)) {
return true;
}
return false;
}
/**
* Capabilities checker
*/
protected function hasCapability($name)
{
if (empty($this->capability) || empty($name)) {
return false;
}
if (in_array($name, $this->capability)) {
return true;
}
else if (strpos($name, '=')) {
return false;
}
$result = [];
foreach ($this->capability as $cap) {
$entry = explode('=', $cap);
if ($entry[0] == $name) {
$result[] = $entry[1];
}
}
return $result ?: false;
}
/**
* Capabilities checker
*
* @param string $name Capability name
*
* @return mixed Capability values array for key=value pairs, true/false for others
*/
public function getCapability($name)
{
$result = $this->hasCapability($name);
if (!empty($result)) {
return $result;
}
else if ($this->capability_read) {
return false;
}
// get capabilities (only once) because initial
// optional CAPABILITY response may differ
$result = $this->execute('CAPABILITY');
if ($result[0] == self::ERROR_OK) {
$this->parseCapability($result[1]);
}
$this->capability_read = true;
return $this->hasCapability($name);
}
/**
* Clears detected server capabilities
*/
public function clearCapability()
{
$this->capability = [];
$this->capability_read = false;
}
/**
* DIGEST-MD5/CRAM-MD5/PLAIN Authentication
*
* @param string $user Username
* @param string $pass Password
* @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5)
*
* @return resource|int Connection resource on success, error code on error
*/
protected function authenticate($user, $pass, $type = 'PLAIN')
{
if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') {
if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) {
return $this->setError(self::ERROR_BYE,
"The Auth_SASL package is required for DIGEST-MD5 authentication");
}
$this->putLine($this->nextTag() . " AUTHENTICATE $type");
$line = trim($this->readReply());
if ($line[0] == '+') {
$challenge = substr($line, 2);
}
else {
return $this->parseResult($line);
}
if ($type == 'CRAM-MD5') {
// RFC2195: CRAM-MD5
$ipad = '';
$opad = '';
$xor = function($str1, $str2) {
$result = '';
$size = strlen($str1);
for ($i=0; $i<$size; $i++) {
$result .= chr(ord($str1[$i]) ^ ord($str2[$i]));
}
return $result;
};
// initialize ipad, opad
for ($i=0; $i<64; $i++) {
$ipad .= chr(0x36);
$opad .= chr(0x5C);
}
// pad $pass so it's 64 bytes
$pass = str_pad($pass, 64, chr(0));
// generate hash
$hash = md5($xor($pass, $opad) . pack("H*",
md5($xor($pass, $ipad) . base64_decode($challenge))));
$reply = base64_encode($user . ' ' . $hash);
// send result
$this->putLine($reply, true, true);
}
else {
// RFC2831: DIGEST-MD5
// proxy authorization
if (!empty($this->prefs['auth_cid'])) {
$authc = $this->prefs['auth_cid'];
$pass = $this->prefs['auth_pw'];
}
else {
$authc = $user;
$user = '';
}
$auth_sasl = new Auth_SASL;
$auth_sasl = $auth_sasl->factory('digestmd5');
$reply = base64_encode($auth_sasl->getResponse($authc, $pass,
base64_decode($challenge), $this->host, 'imap', $user));
// send result
$this->putLine($reply, true, true);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// check response
$challenge = substr($line, 2);
$challenge = base64_decode($challenge);
if (strpos($challenge, 'rspauth=') === false) {
return $this->setError(self::ERROR_BAD,
"Unexpected response from server to DIGEST-MD5 response");
}
$this->putLine('');
}
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'GSSAPI') {
if (!extension_loaded('krb5')) {
return $this->setError(self::ERROR_BYE,
"The krb5 extension is required for GSSAPI authentication");
}
if (empty($this->prefs['gssapi_cn'])) {
return $this->setError(self::ERROR_BYE,
"The gssapi_cn parameter is required for GSSAPI authentication");
}
if (empty($this->prefs['gssapi_context'])) {
return $this->setError(self::ERROR_BYE,
"The gssapi_context parameter is required for GSSAPI authentication");
}
putenv('KRB5CCNAME=' . $this->prefs['gssapi_cn']);
try {
$ccache = new KRB5CCache();
$ccache->open($this->prefs['gssapi_cn']);
$gssapicontext = new GSSAPIContext();
$gssapicontext->acquireCredentials($ccache);
$token = '';
$success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], '', 0, 0, $token);
$token = base64_encode($token);
}
catch (Exception $e) {
trigger_error($e->getMessage(), E_USER_WARNING);
return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
}
$this->putLine($this->nextTag() . " AUTHENTICATE GSSAPI " . $token);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
try {
$itoken = base64_decode(substr($line, 2));
if (!$gssapicontext->unwrap($itoken, $itoken)) {
throw new Exception("GSSAPI SASL input token unwrap failed");
}
if (strlen($itoken) < 4) {
throw new Exception("GSSAPI SASL input token invalid");
}
// Integrity/encryption layers are not supported. The first bit
// indicates that the server supports "no security layers".
// 0x00 should not occur, but support broken implementations.
$server_layers = ord($itoken[0]);
if ($server_layers && ($server_layers & 0x1) != 0x1) {
throw new Exception("Server requires GSSAPI SASL integrity/encryption");
}
// Construct output token. 0x01 in the first octet = SASL layer "none",
// zero in the following three octets = no data follows.
// See https://github.com/cyrusimap/cyrus-sasl/blob/e41cfb986c1b1935770de554872247453fdbb079/plugins/gssapi.c#L1284
if (!$gssapicontext->wrap(pack("CCCC", 0x1, 0, 0, 0), $otoken, true)) {
throw new Exception("GSSAPI SASL output token wrap failed");
}
}
catch (Exception $e) {
trigger_error($e->getMessage(), E_USER_WARNING);
return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
}
$this->putLine(base64_encode($otoken));
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'PLAIN') {
// proxy authorization
if (!empty($this->prefs['auth_cid'])) {
$authc = $this->prefs['auth_cid'];
$pass = $this->prefs['auth_pw'];
}
else {
$authc = $user;
$user = '';
}
$reply = base64_encode($user . chr(0) . $authc . chr(0) . $pass);
// RFC 4959 (SASL-IR): save one round trip
if ($this->getCapability('SASL-IR')) {
list($result, $line) = $this->execute("AUTHENTICATE PLAIN", [$reply],
self::COMMAND_LASTLINE | self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
}
else {
$this->putLine($this->nextTag() . " AUTHENTICATE PLAIN");
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// send result, get reply and process it
$this->putLine($reply, true, true);
$line = $this->readReply();
$result = $this->parseResult($line);
}
}
else if ($type == 'LOGIN') {
$this->putLine($this->nextTag() . " AUTHENTICATE LOGIN");
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
$this->putLine(base64_encode($user), true, true);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// send result, get reply and process it
$this->putLine(base64_encode($pass), true, true);
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'XOAUTH2') {
$auth = base64_encode("user=$user\1auth=$pass\1\1");
$this->putLine($this->nextTag() . " AUTHENTICATE XOAUTH2 $auth", true, true);
$line = trim($this->readReply());
if ($line[0] == '+') {
// send empty line
$this->putLine('', true, true);
$line = $this->readReply();
}
$result = $this->parseResult($line);
}
else {
$line = 'not supported';
$result = self::ERROR_UNKNOWN;
}
if ($result === self::ERROR_OK) {
// optional CAPABILITY response
if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
$this->parseCapability($matches[1], true);
}
return $this->fp;
}
return $this->setError($result, "AUTHENTICATE $type: $line");
}
/**
* LOGIN Authentication
*
* @param string $user Username
* @param string $password Password
*
* @return resource|int Connection resource on success, error code on error
*/
protected function login($user, $password)
{
// Prevent from sending credentials in plain text when connection is not secure
if ($this->getCapability('LOGINDISABLED')) {
return $this->setError(self::ERROR_BAD, "Login disabled by IMAP server");
}
list($code, $response) = $this->execute('LOGIN', [$this->escape($user, true), $this->escape($password, true)],
self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
// re-set capabilities list if untagged CAPABILITY response provided
if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) {
$this->parseCapability($matches[1], true);
}
if ($code == self::ERROR_OK) {
return $this->fp;
}
return $code;
}
/**
* Detects hierarchy delimiter
*
* @return string The delimiter
*/
public function getHierarchyDelimiter()
{
if (!empty($this->prefs['delimiter'])) {
return $this->prefs['delimiter'];
}
// try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8)
list($code, $response) = $this->execute('LIST', [$this->escape(''), $this->escape('')]);
if ($code == self::ERROR_OK) {
$args = $this->tokenizeResponse($response, 4);
$delimiter = $args[3];
if (strlen($delimiter) > 0) {
return ($this->prefs['delimiter'] = $delimiter);
}
}
}
/**
* NAMESPACE handler (RFC 2342)
*
* @return array Namespace data hash (personal, other, shared)
*/
public function getNamespace()
{
if (array_key_exists('namespace', $this->prefs)) {
return $this->prefs['namespace'];
}
if (!$this->getCapability('NAMESPACE')) {
return self::ERROR_BAD;
}
list($code, $response) = $this->execute('NAMESPACE');
if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) {
$response = substr($response, 11);
$data = $this->tokenizeResponse($response);
}
if (!isset($data) || !is_array($data)) {
return $code;
}
$this->prefs['namespace'] = [
'personal' => $data[0],
'other' => $data[1],
'shared' => $data[2],
];
return $this->prefs['namespace'];
}
/**
* Connects to IMAP server and authenticates.
*
* @param string $host Server hostname or IP
* @param string $user User name
* @param string $password Password
* @param array $options Connection and class options
*
* @return bool True on success, False on failure
*/
public function connect($host, $user, $password, $options = [])
{
// configure
$this->set_prefs($options);
$this->host = $host;
$this->user = $user;
$this->logged = false;
$this->selected = null;
// check input
if (empty($host)) {
$this->setError(self::ERROR_BAD, "Empty host");
return false;
}
if (empty($user)) {
$this->setError(self::ERROR_NO, "Empty user");
return false;
}
if (empty($password) && empty($options['gssapi_cn'])) {
$this->setError(self::ERROR_NO, "Empty password");
return false;
}
// Connect
if (!$this->_connect($host)) {
return false;
}
// Send pre authentication ID info (#7860)
if (!empty($this->prefs['preauth_ident']) && $this->getCapability('ID')) {
$this->data['ID'] = $this->id($this->prefs['preauth_ident']);
}
$auth_method = $this->prefs['auth_type'];
$auth_methods = [];
$result = null;
// check for supported auth methods
if (!$auth_method || $auth_method == 'CHECK') {
if ($auth_caps = $this->getCapability('AUTH')) {
$auth_methods = $auth_caps;
}
// Use best (for security) supported authentication method
$all_methods = ['DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN'];
if (!empty($this->prefs['gssapi_cn'])) {
array_unshift($all_methods, 'GSSAPI');
}
foreach ($all_methods as $auth_method) {
if (in_array($auth_method, $auth_methods)) {
break;
}
}
// Prefer LOGIN over AUTHENTICATE LOGIN for performance reasons
if ($auth_method == 'LOGIN' && !$this->getCapability('LOGINDISABLED')) {
$auth_method = 'IMAP';
}
}
// pre-login capabilities can be not complete
$this->capability_read = false;
// Authenticate
switch ($auth_method) {
case 'CRAM_MD5':
$auth_method = 'CRAM-MD5';
case 'CRAM-MD5':
case 'DIGEST-MD5':
case 'GSSAPI':
case 'PLAIN':
case 'LOGIN':
case 'XOAUTH2':
$result = $this->authenticate($user, $password, $auth_method);
break;
case 'IMAP':
$result = $this->login($user, $password);
break;
default:
$this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method");
}
// Connected and authenticated
if (is_resource($result)) {
if (!empty($this->prefs['force_caps'])) {
$this->clearCapability();
}
$this->logged = true;
// Send ID info after authentication to ensure reliable result (#7517)
if (!empty($this->prefs['ident']) && $this->getCapability('ID')) {
$this->data['ID'] = $this->id($this->prefs['ident']);
}
return true;
}
$this->closeConnection();
return false;
}
/**
* Connects to IMAP server.
*
* @param string $host Server hostname or IP
*
* @return bool True on success, False on failure
*/
protected function _connect($host)
{
// initialize connection
$this->error = '';
$this->errornum = self::ERROR_OK;
$port = empty($this->prefs['port']) ? 143 : $this->prefs['port'];
$ssl_mode = $this->prefs['ssl_mode'] ?? null;
// check for SSL
if (!empty($ssl_mode) && $ssl_mode != 'tls') {
$host = $ssl_mode . '://' . $host;
}
if (empty($this->prefs['timeout']) || $this->prefs['timeout'] < 0) {
$this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout')));
}
if ($this->debug) {
// set connection identifier for debug output
$this->resourceid = strtoupper(substr(md5(microtime() . $host . $this->user), 0, 4));
$_host = ($ssl_mode == 'tls' ? 'tls://' : '') . $host . ':' . $port;
$this->debug("Connecting to $_host...");
}
if (!empty($this->prefs['socket_options'])) {
$options = array_intersect_key($this->prefs['socket_options'], ['ssl' => 1, 'socket' => 1]);
$context = stream_context_create($options);
$this->fp = stream_socket_client($host . ':' . $port, $errno, $errstr,
$this->prefs['timeout'], STREAM_CLIENT_CONNECT, $context);
}
else {
$this->fp = @fsockopen($host, $port, $errno, $errstr, $this->prefs['timeout']);
}
if (!$this->fp) {
$this->setError(self::ERROR_BAD, sprintf("Could not connect to %s:%d: %s",
$host, $port, $errstr ?: "Unknown reason"));
return false;
}
if ($this->prefs['timeout'] > 0) {
stream_set_timeout($this->fp, $this->prefs['timeout']);
}
$line = trim(fgets($this->fp, 8192));
if ($this->debug && $line) {
$this->debug('S: '. $line);
}
// Connected to wrong port or connection error?
if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) {
if ($line) {
$error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $port, $line);
}
else {
$error = sprintf("Empty startup greeting (%s:%d)", $host, $port);
}
$this->setError(self::ERROR_BAD, $error);
$this->closeConnection();
return false;
}
$this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line));
// RFC3501 [7.1] optional CAPABILITY response
if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
$this->parseCapability($matches[1], true);
}
// TLS connection
if ($ssl_mode == 'tls' && $this->getCapability('STARTTLS')) {
$res = $this->execute('STARTTLS');
if (empty($res) || $res[0] != self::ERROR_OK) {
$this->closeConnection();
return false;
}
if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) {
$crypto_method = $this->prefs['socket_options']['ssl']['crypto_method'];
}
else {
// There is no flag to enable all TLS methods. Net_SMTP
// handles enabling TLS similarly.
$crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT
| @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
| @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
}
if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) {
$this->setError(self::ERROR_BAD, "Unable to negotiate TLS");
$this->closeConnection();
return false;
}
// Now we're secure, capabilities need to be reread
$this->clearCapability();
}
return true;
}
/**
* Initializes environment
*/
protected function set_prefs($prefs)
{
// set preferences
if (is_array($prefs)) {
$this->prefs = $prefs;
}
// set auth method
if (!empty($this->prefs['auth_type'])) {
$this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']);
}
else {
$this->prefs['auth_type'] = 'CHECK';
}
// disabled capabilities
if (!empty($this->prefs['disabled_caps'])) {
$this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']);
}
// additional message flags
if (!empty($this->prefs['message_flags'])) {
$this->flags = array_merge($this->flags, $this->prefs['message_flags']);
unset($this->prefs['message_flags']);
}
}
/**
* Checks connection status
*
* @return bool True if connection is active and user is logged in, False otherwise.
*/
public function connected()
{
return $this->fp && $this->logged;
}
/**
* Closes connection with logout.
*/
public function closeConnection()
{
if ($this->logged && $this->putLine($this->nextTag() . ' LOGOUT')) {
$this->readReply();
}
$this->closeSocket();
$this->clearCapability();
}
/**
* Executes SELECT command (if mailbox is already not in selected state)
*
* @param string $mailbox Mailbox name
* @param array $qresync_data QRESYNC data (RFC5162)
*
* @return bool True on success, false on error
*/
public function select($mailbox, $qresync_data = null)
{
if (!strlen($mailbox)) {
return false;
}
if ($this->selected === $mailbox) {
return true;
}
$params = [$this->escape($mailbox)];
// QRESYNC data items
// 0. the last known UIDVALIDITY,
// 1. the last known modification sequence,
// 2. the optional set of known UIDs, and
// 3. an optional parenthesized list of known sequence ranges and their
// corresponding UIDs.
if (!empty($qresync_data)) {
if (!empty($qresync_data[2])) {
$qresync_data[2] = self::compressMessageSet($qresync_data[2]);
}
$params[] = ['QRESYNC', $qresync_data];
}
list($code, $response) = $this->execute('SELECT', $params);
if ($code == self::ERROR_OK) {
$this->clear_mailbox_cache();
$response = explode("\r\n", $response);
foreach ($response as $line) {
if (preg_match('/^\* OK \[/i', $line)) {
$pos = strcspn($line, ' ]', 6);
$token = strtoupper(substr($line, 6, $pos));
$pos += 7;
switch ($token) {
case 'UIDNEXT':
case 'UIDVALIDITY':
case 'UNSEEN':
if ($len = strspn($line, '0123456789', $pos)) {
$this->data[$token] = (int) substr($line, $pos, $len);
}
break;
case 'HIGHESTMODSEQ':
if ($len = strspn($line, '0123456789', $pos)) {
$this->data[$token] = (string) substr($line, $pos, $len);
}
break;
case 'NOMODSEQ':
$this->data[$token] = true;
break;
case 'PERMANENTFLAGS':
$start = strpos($line, '(', $pos);
$end = strrpos($line, ')');
if ($start && $end) {
$flags = substr($line, $start + 1, $end - $start - 1);
$this->data[$token] = explode(' ', $flags);
}
break;
}
}
else if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) {
$token = strtoupper($match[2]);
switch ($token) {
case 'EXISTS':
case 'RECENT':
$this->data[$token] = (int) $match[1];
break;
case 'FETCH':
// QRESYNC FETCH response (RFC5162)
$line = substr($line, strlen($match[0]));
$fetch_data = $this->tokenizeResponse($line, 1);
$data = ['id' => $match[1]];
for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) {
$data[strtolower($fetch_data[$i])] = $fetch_data[$i+1];
}
$this->data['QRESYNC'][$data['uid']] = $data;
break;
}
}
// QRESYNC VANISHED response (RFC5162)
else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
$line = substr($line, strlen($match[0]));
$v_data = $this->tokenizeResponse($line, 1);
$this->data['VANISHED'] = $v_data;
}
}
$this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY';
$this->selected = $mailbox;
return true;
}
return false;
}
/**
* Executes STATUS command
*
* @param string $mailbox Mailbox name
* @param array $items Additional requested item names. By default
* MESSAGES and UNSEEN are requested. Other defined
* in RFC3501: UIDNEXT, UIDVALIDITY, RECENT
*
* @return array Status item-value hash
* @since 0.5-beta
*/
public function status($mailbox, $items = [])
{
if (!strlen($mailbox)) {
return false;
}
if (!in_array('MESSAGES', $items)) {
$items[] = 'MESSAGES';
}
if (!in_array('UNSEEN', $items)) {
$items[] = 'UNSEEN';
}
list($code, $response) = $this->execute('STATUS',
[$this->escape($mailbox), '(' . implode(' ', $items) . ')'], 0, '/^\* STATUS /i');
if ($code == self::ERROR_OK && $response) {
$result = [];
$response = substr($response, 9); // remove prefix "* STATUS "
list($mbox, $items) = $this->tokenizeResponse($response, 2);
// Fix for #1487859. Some buggy server returns not quoted
// folder name with spaces. Let's try to handle this situation
if (!is_array($items) && ($pos = strpos($response, '(')) !== false) {
$response = substr($response, $pos);
$items = $this->tokenizeResponse($response, 1);
}
if (!is_array($items)) {
return $result;
}
for ($i=0, $len=count($items); $i<$len; $i += 2) {
$result[$items[$i]] = $items[$i+1];
}
$this->data['STATUS:'.$mailbox] = $result;
return $result;
}
return false;
}
/**
* Executes EXPUNGE command
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UIDs to expunge
*
* @return bool True on success, False on error
*/
public function expunge($mailbox, $messages = null)
{
if (!$this->select($mailbox)) {
return false;
}
if (empty($this->data['READ-WRITE'])) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
// Clear internal status cache
$this->clear_status_cache($mailbox);
if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) {
$messages = self::compressMessageSet($messages);
$result = $this->execute('UID EXPUNGE', [$messages], self::COMMAND_NORESPONSE);
}
else {
$result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE);
}
if ($result == self::ERROR_OK) {
$this->selected = null; // state has changed, need to reselect
return true;
}
return false;
}
/**
* Executes CLOSE command
*
* @return bool True on success, False on error
* @since 0.5
*/
public function close()
{
$result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE);
if ($result == self::ERROR_OK) {
$this->selected = null;
return true;
}
return false;
}
/**
* Folder subscription (SUBSCRIBE)
*
* @param string $mailbox Mailbox name
*
* @return bool True on success, False on error
*/
public function subscribe($mailbox)
{
$result = $this->execute('SUBSCRIBE', [$this->escape($mailbox)], self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder unsubscription (UNSUBSCRIBE)
*
* @param string $mailbox Mailbox name
*
* @return bool True on success, False on error
*/
public function unsubscribe($mailbox)
{
$result = $this->execute('UNSUBSCRIBE', [$this->escape($mailbox)], self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder creation (CREATE)
*
* @param string $mailbox Mailbox name
* @param array $types Optional folder types (RFC 6154)
*
* @return bool True on success, False on error
*/
public function createFolder($mailbox, $types = null)
{
$args = [$this->escape($mailbox)];
// RFC 6154: CREATE-SPECIAL-USE
if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) {
$args[] = '(USE (' . implode(' ', $types) . '))';
}
$result = $this->execute('CREATE', $args, self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder renaming (RENAME)
*
* @param string $from Mailbox name
* @param string $to Mailbox name
*
* @return bool True on success, False on error
*/
public function renameFolder($from, $to)
{
$result = $this->execute('RENAME', [$this->escape($from), $this->escape($to)], self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Executes DELETE command
*
* @param string $mailbox Mailbox name
*
* @return bool True on success, False on error
*/
public function deleteFolder($mailbox)
{
// Unselect the folder to prevent "BYE Fatal error: Mailbox has been (re)moved" on Cyrus IMAP
if ($this->selected === $mailbox && $this->hasCapability('UNSELECT')) {
$this->execute('UNSELECT', [], self::COMMAND_NORESPONSE);
}
$result = $this->execute('DELETE', [$this->escape($mailbox)], self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Removes all messages in a folder
*
* @param string $mailbox Mailbox name
*
* @return bool True on success, False on error
*/
public function clearFolder($mailbox)
{
if ($this->countMessages($mailbox) > 0) {
$res = $this->flag($mailbox, '1:*', 'DELETED');
}
else {
return true;
}
if (!empty($res)) {
if ($this->selected === $mailbox) {
$res = $this->close();
}
else {
$res = $this->expunge($mailbox);
}
return $res;
}
return false;
}
/**
* Returns list of mailboxes
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param array $return_opts (see self::_listMailboxes)
* @param array $select_opts (see self::_listMailboxes)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYRIGHTS response
* is requested, False on error.
*/
public function listMailboxes($ref, $mailbox, $return_opts = [], $select_opts = [])
{
return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts);
}
/**
* Returns list of subscribed mailboxes
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param array $return_opts (see self::_listMailboxes)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYRIGHTS response
* is requested, False on error.
*/
public function listSubscribed($ref, $mailbox, $return_opts = [])
{
return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null);
}
/**
* IMAP LIST/LSUB command
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param bool $subscribed Enables returning subscribed mailboxes only
* @param array $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED)
* Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN,
* MYRIGHTS, SUBSCRIBED, CHILDREN
* @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED)
* Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE,
* SPECIAL-USE (RFC6154)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYRIGHTS response
* is requested, False on error.
*/
protected function _listMailboxes($ref, $mailbox, $subscribed = false, $return_opts = [], $select_opts = [])
{
if (!strlen($mailbox)) {
$mailbox = '*';
}
$lstatus = false;
$args = [];
$rets = [];
if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) {
$select_opts = (array) $select_opts;
$args[] = '(' . implode(' ', $select_opts) . ')';
}
$args[] = $this->escape($ref);
$args[] = $this->escape($mailbox);
if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) {
$ext_opts = ['SUBSCRIBED', 'CHILDREN'];
$rets = array_intersect($return_opts, $ext_opts);
$return_opts = array_diff($return_opts, $rets);
}
if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) {
$lstatus = true;
$status_opts = ['MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', 'SIZE'];
$opts = array_diff($return_opts, $status_opts);
$status_opts = array_diff($return_opts, $opts);
if (!empty($status_opts)) {
$rets[] = 'STATUS (' . implode(' ', $status_opts) . ')';
}
if (!empty($opts)) {
$rets = array_merge($rets, $opts);
}
}
if (!empty($rets)) {
$args[] = 'RETURN (' . implode(' ', $rets) . ')';
}
list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args);
if ($code == self::ERROR_OK) {
$folders = [];
$last = 0;
$pos = 0;
$response .= "\r\n";
while ($pos = strpos($response, "\r\n", $pos+1)) {
// literal string, not real end-of-command-line
if ($response[$pos-1] == '}') {
continue;
}
$line = substr($response, $last, $pos - $last);
$last = $pos + 2;
if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) {
continue;
}
$cmd = strtoupper($m[1]);
$line = substr($line, strlen($m[0]));
// * LIST (<options>) <delimiter> <mailbox>
if ($cmd == 'LIST' || $cmd == 'LSUB') {
list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3);
// Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879)
if ($delim) {
$mailbox = rtrim($mailbox, $delim);
}
// Make it easier for the client to deal with INBOX folder
// by always returning the word with all capital letters
if (strlen($mailbox) == 5
&& ($mailbox[0] == 'i' || $mailbox[0] == 'I')
&& ($mailbox[1] == 'n' || $mailbox[1] == 'N')
&& ($mailbox[2] == 'b' || $mailbox[2] == 'B')
&& ($mailbox[3] == 'o' || $mailbox[3] == 'O')
&& ($mailbox[4] == 'x' || $mailbox[4] == 'X')
) {
$mailbox = 'INBOX';
}
// Add to result array
if (!$lstatus) {
$folders[] = $mailbox;
}
else {
$folders[$mailbox] = [];
}
// store folder options
if ($cmd == 'LIST') {
// Add to options array
if (empty($this->data['LIST'][$mailbox])) {
$this->data['LIST'][$mailbox] = $opts;
}
else if (!empty($opts)) {
$this->data['LIST'][$mailbox] = array_unique(array_merge(
$this->data['LIST'][$mailbox], $opts));
}
}
}
else if ($lstatus) {
// * STATUS <mailbox> (<result>)
if ($cmd == 'STATUS') {
list($mailbox, $status) = $this->tokenizeResponse($line, 2);
for ($i=0, $len=count($status); $i<$len; $i += 2) {
list($name, $value) = $this->tokenizeResponse($status, 2);
$folders[$mailbox][$name] = $value;
}
}
// * MYRIGHTS <mailbox> <acl>
else if ($cmd == 'MYRIGHTS') {
list($mailbox, $acl) = $this->tokenizeResponse($line, 2);
$folders[$mailbox]['MYRIGHTS'] = $acl;
}
}
}
return $folders;
}
return false;
}
/**
* Returns count of all messages in a folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countMessages($mailbox)
{
if ($this->selected === $mailbox && isset($this->data['EXISTS'])) {
return $this->data['EXISTS'];
}
// Check internal cache
if (!empty($this->data['STATUS:'.$mailbox])) {
$cache = $this->data['STATUS:'.$mailbox];
if (isset($cache['MESSAGES'])) {
return (int) $cache['MESSAGES'];
}
}
// Try STATUS (should be faster than SELECT)
$counts = $this->status($mailbox);
if (is_array($counts)) {
return (int) $counts['MESSAGES'];
}
return false;
}
/**
* Returns count of messages with \Recent flag in a folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countRecent($mailbox)
{
if ($this->selected === $mailbox && isset($this->data['RECENT'])) {
return $this->data['RECENT'];
}
// Check internal cache
$cache = $this->data['STATUS:'.$mailbox];
if (!empty($cache) && isset($cache['RECENT'])) {
return (int) $cache['RECENT'];
}
// Try STATUS (should be faster than SELECT)
$counts = $this->status($mailbox, ['RECENT']);
if (is_array($counts)) {
return (int) $counts['RECENT'];
}
return false;
}
/**
* Returns count of messages without \Seen flag in a specified folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countUnseen($mailbox)
{
// Check internal cache
if (!empty($this->data['STATUS:'.$mailbox])) {
$cache = $this->data['STATUS:'.$mailbox];
if (isset($cache['UNSEEN'])) {
return (int) $cache['UNSEEN'];
}
}
// Try STATUS (should be faster than SELECT+SEARCH)
$counts = $this->status($mailbox);
if (is_array($counts)) {
return (int) $counts['UNSEEN'];
}
// Invoke SEARCH as a fallback
$index = $this->search($mailbox, 'ALL UNSEEN', false, ['COUNT']);
if (!$index->is_error()) {
return $index->count();
}
return false;
}
/**
* Executes ID command (RFC2971)
*
* @param array $items Client identification information key/value hash
*
* @return array|false Server identification information key/value hash, False on error
* @since 0.6
*/
public function id($items = [])
{
if (is_array($items) && !empty($items)) {
foreach ($items as $key => $value) {
$args[] = $this->escape($key, true);
$args[] = $this->escape($value, true);
}
}
list($code, $response) = $this->execute('ID',
[!empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null)],
0, '/^\* ID /i'
);
if ($code == self::ERROR_OK && $response) {
$response = substr($response, 5); // remove prefix "* ID "
$items = $this->tokenizeResponse($response, 1);
$result = [];
if (is_array($items)) {
for ($i=0, $len=count($items); $i<$len; $i += 2) {
$result[$items[$i]] = $items[$i+1];
}
}
return $result;
}
return false;
}
/**
* Executes ENABLE command (RFC5161)
*
* @param mixed $extension Extension name to enable (or array of names)
*
* @return array|bool List of enabled extensions, False on error
* @since 0.6
*/
public function enable($extension)
{
if (empty($extension)) {
return false;
}
if (!$this->hasCapability('ENABLE')) {
return false;
}
if (!is_array($extension)) {
$extension = [$extension];
}
if (!empty($this->extensions_enabled)) {
// check if all extensions are already enabled
$diff = array_diff($extension, $this->extensions_enabled);
if (empty($diff)) {
return $extension;
}
// Make sure the mailbox isn't selected, before enabling extension(s)
if ($this->selected !== null) {
$this->close();
}
}
list($code, $response) = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i');
if ($code == self::ERROR_OK && $response) {
$response = substr($response, 10); // remove prefix "* ENABLED "
$result = (array) $this->tokenizeResponse($response);
$this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result));
return $this->extensions_enabled;
}
return false;
}
/**
* Executes SORT command
*
* @param string $mailbox Mailbox name
* @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param string $criteria Searching criteria
* @param bool $return_uid Enables UID SORT usage
* @param string $encoding Character set
*
* @return rcube_result_index Response data
*/
public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
{
$old_sel = $this->selected;
$supported = ['ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO'];
$field = strtoupper($field);
if ($field == 'INTERNALDATE') {
$field = 'ARRIVAL';
}
if (!in_array($field, $supported)) {
return new rcube_result_index($mailbox);
}
if (!$this->select($mailbox)) {
return new rcube_result_index($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && empty($this->data['EXISTS'])) {
return new rcube_result_index($mailbox, '* SORT');
}
// RFC 5957: SORT=DISPLAY
if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) {
$field = 'DISPLAY' . $field;
}
$encoding = $encoding ? trim($encoding) : 'US-ASCII';
$criteria = $criteria ? 'ALL ' . trim($criteria) : 'ALL';
list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT',
["($field)", $encoding, $criteria]);
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_index($mailbox, $response);
}
/**
* Executes THREAD command
*
* @param string $mailbox Mailbox name
* @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS)
* @param string $criteria Searching criteria
* @param bool $return_uid Enables UIDs in result instead of sequence numbers
* @param string $encoding Character set
*
* @return rcube_result_thread Thread data
*/
public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
{
$old_sel = $this->selected;
if (!$this->select($mailbox)) {
return new rcube_result_thread($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_thread($mailbox, '* THREAD');
}
$encoding = $encoding ? trim($encoding) : 'US-ASCII';
$algorithm = $algorithm ? trim($algorithm) : 'REFERENCES';
$criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL';
list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD',
[$algorithm, $encoding, $criteria]);
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_thread($mailbox, $response);
}
/**
* Executes SEARCH command
*
* @param string $mailbox Mailbox name
* @param string $criteria Searching criteria
* @param bool $return_uid Enable UID in result instead of sequence ID
* @param array $items Return items (MIN, MAX, COUNT, ALL)
*
* @return rcube_result_index Result data
*/
public function search($mailbox, $criteria, $return_uid = false, $items = [])
{
$old_sel = $this->selected;
if (!$this->select($mailbox)) {
return new rcube_result_index($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_index($mailbox, '* SEARCH');
}
// If ESEARCH is supported always use ALL
// but not when items are specified or using simple id2uid search
if (empty($items) && preg_match('/[^0-9]/', $criteria)) {
$items = ['ALL'];
}
$esearch = empty($items) ? false : $this->getCapability('ESEARCH');
$criteria = trim($criteria);
$params = '';
// RFC4731: ESEARCH
if (!empty($items) && $esearch) {
$params .= 'RETURN (' . implode(' ', $items) . ')';
}
if (!empty($criteria)) {
$params .= ($params ? ' ' : '') . $criteria;
}
else {
$params .= 'ALL';
}
list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH', [$params]);
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_index($mailbox, $response);
}
/**
* Simulates SORT command by using FETCH and sorting.
*
* @param string $mailbox Mailbox name
* @param string|array $message_set Searching criteria (list of messages to return)
* @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param bool $skip_deleted Makes that DELETED messages will be skipped
* @param bool $uidfetch Enables UID FETCH usage
* @param bool $return_uid Enables returning UIDs instead of IDs
*
* @return rcube_result_index Response data
*/
public function index($mailbox, $message_set, $index_field = '', $skip_deleted = true,
$uidfetch = false, $return_uid = false)
{
$msg_index = $this->fetchHeaderIndex($mailbox, $message_set,
$index_field, $skip_deleted, $uidfetch, $return_uid);
if (!empty($msg_index)) {
asort($msg_index); // ASC
$msg_index = array_keys($msg_index);
$msg_index = '* SEARCH ' . implode(' ', $msg_index);
}
else {
$msg_index = is_array($msg_index) ? '* SEARCH' : null;
}
return new rcube_result_index($mailbox, $msg_index);
}
/**
* Fetches specified header/data value for a set of messages.
*
* @param string $mailbox Mailbox name
* @param string|array $message_set Searching criteria (list of messages to return)
* @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param bool $skip_deleted Makes that DELETED messages will be skipped
* @param bool $uidfetch Enables UID FETCH usage
* @param bool $return_uid Enables returning UIDs instead of IDs
*
* @return array|bool List of header values or False on failure
*/
public function fetchHeaderIndex($mailbox, $message_set, $index_field = '', $skip_deleted = true,
$uidfetch = false, $return_uid = false)
{
// Validate input
if (is_array($message_set)) {
if (!($message_set = $this->compressMessageSet($message_set))) {
return false;
}
}
else if (empty($message_set)) {
return false;
}
else if (strpos($message_set, ':')) {
list($from_idx, $to_idx) = explode(':', $message_set);
if ($to_idx != '*' && (int) $from_idx > (int) $to_idx) {
return false;
}
}
$index_field = empty($index_field) ? 'DATE' : strtoupper($index_field);
$supported = [
'DATE' => 1,
'INTERNALDATE' => 4,
'ARRIVAL' => 4,
'FROM' => 1,
'REPLY-TO' => 1,
'SENDER' => 1,
'TO' => 1,
'CC' => 1,
'SUBJECT' => 1,
'UID' => 2,
'SIZE' => 2,
'SEEN' => 3,
'RECENT' => 3,
'DELETED' => 3,
];
if (empty($supported[$index_field])) {
return false;
}
$mode = $supported[$index_field];
// Select the mailbox
if (!$this->select($mailbox)) {
return false;
}
// build FETCH command string
$key = $this->nextTag();
$cmd = $uidfetch ? 'UID FETCH' : 'FETCH';
$fields = [];
if ($return_uid) {
$fields[] = 'UID';
}
if ($skip_deleted) {
$fields[] = 'FLAGS';
}
if ($mode == 1) {
if ($index_field == 'DATE') {
$fields[] = 'INTERNALDATE';
}
$fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]";
}
else if ($mode == 2) {
if ($index_field == 'SIZE') {
$fields[] = 'RFC822.SIZE';
}
else if (!$return_uid || $index_field != 'UID') {
$fields[] = $index_field;
}
}
else if ($mode == 3 && !$skip_deleted) {
$fields[] = 'FLAGS';
}
else if ($mode == 4) {
$fields[] = 'INTERNALDATE';
}
$request = "$key $cmd $message_set (" . implode(' ', $fields) . ")";
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
$result = [];
do {
$line = rtrim($this->readLine(200));
$line = $this->multLine($line);
if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
$id = $m[1];
$flags = null;
if ($return_uid) {
if (preg_match('/UID ([0-9]+)/', $line, $matches)) {
$id = (int) $matches[1];
}
else {
continue;
}
}
if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
$flags = explode(' ', strtoupper($matches[1]));
if (in_array('\\DELETED', $flags)) {
continue;
}
}
if ($mode == 1 && $index_field == 'DATE') {
if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) {
$value = preg_replace(['/^"*[a-z]+:/i'], '', $matches[1]);
$value = trim($value);
$result[$id] = rcube_utils::strtotime($value);
}
// non-existent/empty Date: header, use INTERNALDATE
if (empty($result[$id])) {
if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
$result[$id] = rcube_utils::strtotime($matches[1]);
}
else {
$result[$id] = 0;
}
}
}
else if ($mode == 1) {
if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) {
$value = preg_replace(['/^"*[a-z]+:/i', '/\s+$/sm'], ['', ''], $matches[2]);
$result[$id] = trim($value);
}
else {
$result[$id] = '';
}
}
else if ($mode == 2) {
if (preg_match('/' . $index_field . ' ([0-9]+)/', $line, $matches)) {
$result[$id] = trim($matches[1]);
}
else {
$result[$id] = 0;
}
}
else if ($mode == 3) {
if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
$flags = explode(' ', $matches[1]);
}
$result[$id] = in_array("\\".$index_field, (array) $flags) ? 1 : 0;
}
else if ($mode == 4) {
if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
$result[$id] = rcube_utils::strtotime($matches[1]);
}
else {
$result[$id] = 0;
}
}
}
}
while (!$this->startsWith($line, $key, true, true));
return $result;
}
/**
* Returns message sequence identifier
*
* @param string $mailbox Mailbox name
* @param int $uid Message unique identifier (UID)
*
* @return int Message sequence identifier
*/
public function UID2ID($mailbox, $uid)
{
if ($uid > 0) {
$index = $this->search($mailbox, "UID $uid");
if ($index->count() == 1) {
$arr = $index->get();
return (int) $arr[0];
}
}
}
/**
* Returns message unique identifier (UID)
*
* @param string $mailbox Mailbox name
* @param int $id Message sequence identifier
*
* @return int Message unique identifier
*/
public function ID2UID($mailbox, $id)
{
if (empty($id) || $id < 0) {
return null;
}
if (!$this->select($mailbox)) {
return null;
}
if (!empty($this->data['UID-MAP'][$id])) {
return $this->data['UID-MAP'][$id];
}
if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) {
return null;
}
$index = $this->search($mailbox, $id, true);
if ($index->count() == 1) {
$arr = $index->get();
return $this->data['UID-MAP'][$id] = (int) $arr[0];
}
}
/**
* Sets flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
*
* @return bool True on success, False on failure
*/
public function flag($mailbox, $messages, $flag)
{
return $this->modFlag($mailbox, $messages, $flag, '+');
}
/**
* Unsets flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
*
* @return bool True on success, False on failure
*/
public function unflag($mailbox, $messages, $flag)
{
return $this->modFlag($mailbox, $messages, $flag, '-');
}
/**
* Changes flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
* @param string $mod Modifier [+|-]. Default: "+".
*
* @return bool True on success, False on failure
*/
protected function modFlag($mailbox, $messages, $flag, $mod = '+')
{
if (!$flag) {
return false;
}
if (!$this->select($mailbox)) {
return false;
}
if (empty($this->data['READ-WRITE'])) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
if (!empty($this->flags[strtoupper($flag)])) {
$flag = $this->flags[strtoupper($flag)];
}
// if PERMANENTFLAGS is not specified all flags are allowed
if (!empty($this->data['PERMANENTFLAGS'])
&& !in_array($flag, (array) $this->data['PERMANENTFLAGS'])
&& !in_array('\\*', (array) $this->data['PERMANENTFLAGS'])
) {
return false;
}
// Clear internal status cache
if ($flag == 'SEEN') {
unset($this->data['STATUS:'.$mailbox]['UNSEEN']);
}
if ($mod != '+' && $mod != '-') {
$mod = '+';
}
$result = $this->execute('UID STORE',
[$this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
/**
* Copies message(s) from one folder to another
*
* @param string|array $messages Message UID(s)
* @param string $from Mailbox name
* @param string $to Destination mailbox name
*
* @return bool True on success, False on failure
*/
public function copy($messages, $from, $to)
{
// Clear last COPYUID data
unset($this->data['COPYUID']);
if (!$this->select($from)) {
return false;
}
// Clear internal status cache
unset($this->data['STATUS:'.$to]);
$result = $this->execute('UID COPY',
[$this->compressMessageSet($messages), $this->escape($to)],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
/**
* Moves message(s) from one folder to another.
*
* @param string|array $messages Message UID(s)
* @param string $from Mailbox name
* @param string $to Destination mailbox name
*
* @return bool True on success, False on failure
*/
public function move($messages, $from, $to)
{
if (!$this->select($from)) {
return false;
}
if (empty($this->data['READ-WRITE'])) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
// use MOVE command (RFC 6851)
if ($this->hasCapability('MOVE')) {
// Clear last COPYUID data
unset($this->data['COPYUID']);
// Clear internal status cache
unset($this->data['STATUS:'.$to]);
$this->clear_status_cache($from);
$result = $this->execute('UID MOVE',
[$this->compressMessageSet($messages), $this->escape($to)],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
// use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE
$result = $this->copy($messages, $from, $to);
if ($result) {
// Clear internal status cache
unset($this->data['STATUS:'.$from]);
$result = $this->flag($from, $messages, 'DELETED');
if ($messages == '*') {
// CLOSE+SELECT should be faster than EXPUNGE
$this->close();
}
else {
$this->expunge($from, $messages);
}
}
return $result;
}
/**
* FETCH command (RFC3501)
*
* @param string $mailbox Mailbox name
* @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
* @param bool $is_uid True if $message_set contains UIDs
* @param array $query_items FETCH command data items
* @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query
* @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query
*
* @return array List of rcube_message_header elements, False on error
* @since 0.6
*/
public function fetch($mailbox, $message_set, $is_uid = false, $query_items = [],
$mod_seq = null, $vanished = false)
{
if (!$this->select($mailbox)) {
return false;
}
$message_set = $this->compressMessageSet($message_set);
$result = [];
$key = $this->nextTag();
$cmd = ($is_uid ? 'UID ' : '') . 'FETCH';
$request = "$key $cmd $message_set (" . implode(' ', $query_items) . ")";
if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) {
$request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")";
}
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
do {
$line = $this->readFullLine(4096);
if (!$line) {
break;
}
// Sample reply line:
// * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen)
// INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...)
// BODY[HEADER.FIELDS ...
if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
$id = intval($m[1]);
$result[$id] = new rcube_message_header;
$result[$id]->id = $id;
$result[$id]->subject = '';
$result[$id]->messageID = 'mid:' . $id;
$result[$id]->folder = $mailbox;
$headers = null;
$line = substr($line, strlen($m[0]) + 2);
// Tokenize response and assign to object properties
while (($tokens = $this->tokenizeResponse($line, 2)) && count($tokens) == 2) {
list($name, $value) = $tokens;
if ($name == 'UID') {
$result[$id]->uid = intval($value);
}
else if ($name == 'RFC822.SIZE') {
$result[$id]->size = intval($value);
}
else if ($name == 'RFC822.TEXT') {
$result[$id]->body = $value;
}
else if ($name == 'INTERNALDATE') {
$result[$id]->internaldate = $value;
$result[$id]->date = $value;
$result[$id]->timestamp = rcube_utils::strtotime($value);
}
else if ($name == 'FLAGS') {
if (!empty($value)) {
foreach ((array)$value as $flag) {
$flag = str_replace(['$', "\\"], '', $flag);
$flag = strtoupper($flag);
$result[$id]->flags[$flag] = true;
}
}
}
else if ($name == 'ANNOTATION') {
$result[$id]->annotations = [];
if (!empty($value) && is_array($value)) {
$n = 0;
while (!empty($value[$n]) && is_string($value[$n])) {
$name = $value[$n++];
$list = $value[$n++];
$result[$id]->annotations[$name] = [];
$c = 0;
while (!empty($list[$c]) && is_string($list[$c])) {
$result[$id]->annotations[$name][$list[$c++]] = $list[$c++];
}
}
}
}
else if ($name == 'MODSEQ') {
$result[$id]->modseq = $value[0];
}
else if ($name == 'ENVELOPE') {
$result[$id]->envelope = $value;
}
else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) {
- if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) {
+ if (is_string($value[0]) && is_string($value[1])
+ && strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822'
+ ) {
$value = [$value];
}
$result[$id]->bodystructure = $value;
}
else if ($name == 'RFC822') {
$result[$id]->body = $value;
}
else if (stripos($name, 'BODY[') === 0) {
$name = str_replace(']', '', substr($name, 5));
if ($name == 'HEADER.FIELDS') {
// skip ']' after headers list
$this->tokenizeResponse($line, 1);
$headers = $this->tokenizeResponse($line, 1);
}
else if (strlen($name)) {
$result[$id]->bodypart[$name] = $value;
}
else {
$result[$id]->body = $value;
}
}
}
// create array with header field:data
if (!empty($headers)) {
$headers = explode("\n", trim($headers));
$lines = [];
$ln = 0;
foreach ($headers as $resln) {
if (!isset($resln[0]) || ord($resln[0]) <= 32) {
$lines[$ln] = ($lines[$ln] ?? '') . (empty($lines[$ln]) ? '' : "\n") . trim($resln);
}
else {
$lines[++$ln] = trim($resln);
}
}
foreach ($lines as $str) {
if (strpos($str, ':') === false) {
continue;
}
list($field, $string) = explode(':', $str, 2);
$field = strtolower($field);
$string = preg_replace('/\n[\t\s]*/', ' ', trim($string));
switch ($field) {
case 'date';
$string = substr($string, 0, 128);
$result[$id]->date = $string;
$result[$id]->timestamp = rcube_utils::strtotime($string);
break;
case 'to':
$result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string);
break;
case 'from':
case 'subject':
$string = substr($string, 0, 2048);
case 'cc':
case 'bcc':
case 'references':
$result[$id]->{$field} = $string;
break;
case 'reply-to':
$result[$id]->replyto = $string;
break;
case 'content-transfer-encoding':
$result[$id]->encoding = substr($string, 0, 32);
break;
case 'content-type':
$ctype_parts = preg_split('/[; ]+/', $string);
$result[$id]->ctype = strtolower(array_first($ctype_parts));
if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) {
$result[$id]->charset = $regs[1];
}
break;
case 'in-reply-to':
$result[$id]->in_reply_to = str_replace(["\n", '<', '>'], '', $string);
break;
case 'disposition-notification-to':
case 'x-confirm-reading-to':
$result[$id]->mdn_to = substr($string, 0, 2048);
break;
case 'message-id':
$result[$id]->messageID = substr($string, 0, 2048);
break;
case 'x-priority':
if (preg_match('/^(\d+)/', $string, $matches)) {
$result[$id]->priority = intval($matches[1]);
}
break;
default:
if (strlen($field) < 3) {
break;
}
if (!empty($result[$id]->others[$field])) {
$string = array_merge((array) $result[$id]->others[$field], (array) $string);
}
$result[$id]->others[$field] = $string;
}
}
}
}
// VANISHED response (QRESYNC RFC5162)
// Sample: * VANISHED (EARLIER) 300:310,405,411
else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
$line = substr($line, strlen($match[0]));
$v_data = $this->tokenizeResponse($line, 1);
$this->data['VANISHED'] = $v_data;
}
}
while (!$this->startsWith($line, $key, true));
return $result;
}
/**
* Returns message(s) data (flags, headers, etc.)
*
* @param string $mailbox Mailbox name
* @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
* @param bool $is_uid True if $message_set contains UIDs
* @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
* @param array $add_headers List of additional headers to fetch
* @param array $query_items List of additional items to fetch
*
* @return bool|array List of rcube_message_header elements, False on error
*/
public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = [], $query_items = [])
{
$query_items = array_unique(array_merge($query_items, ['UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE']));
$headers = ['DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO',
'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY'];
if (!empty($add_headers)) {
$add_headers = array_map('strtoupper', $add_headers);
$headers = array_unique(array_merge($headers, $add_headers));
}
if ($bodystr) {
$query_items[] = 'BODYSTRUCTURE';
}
$query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]';
return $this->fetch($mailbox, $message_set, $is_uid, $query_items);
}
/**
* Returns message data (flags, headers, etc.)
*
* @param string $mailbox Mailbox name
* @param int $id Message sequence identifier or UID
* @param bool $is_uid True if $id is an UID
* @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
* @param array $add_headers List of additional headers
* @param array $query_items List of additional items to fetch
*
* @return bool|rcube_message_header Message data, False on error
*/
public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = [], $query_items = [])
{
$a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers, $query_items);
if (is_array($a)) {
return array_first($a);
}
return false;
}
/**
* Sort messages by specified header field
*
* @param array $messages Array of rcube_message_header objects
* @param string $field Name of the property to sort by
* @param string $order Sorting order (ASC|DESC)
*
* @return array Sorted input array
*/
public static function sortHeaders($messages, $field, $order = 'ASC')
{
$field = empty($field) ? 'uid' : strtolower($field);
$order = empty($order) ? 'ASC' : strtoupper($order);
$index = [];
reset($messages);
// Create an index
foreach ($messages as $key => $headers) {
switch ($field) {
case 'arrival':
$field = 'internaldate';
// no-break
case 'date':
case 'internaldate':
case 'timestamp':
$value = rcube_utils::strtotime($headers->$field);
if (!$value && $field != 'timestamp') {
$value = $headers->timestamp;
}
break;
default:
// @TODO: decode header value, convert to UTF-8
$value = $headers->$field;
if (is_string($value)) {
$value = str_replace('"', '', $value);
if ($field == 'subject') {
$value = rcube_utils::remove_subject_prefix($value);
}
}
}
$index[$key] = $value;
}
$sort_order = $order == 'ASC' ? SORT_ASC : SORT_DESC;
$sort_flags = SORT_STRING | SORT_FLAG_CASE;
if (in_array($field, ['arrival', 'date', 'internaldate', 'timestamp', 'size', 'uid', 'id'])) {
$sort_flags = SORT_NUMERIC;
}
array_multisort($index, $sort_order, $sort_flags, $messages);
return $messages;
}
/**
* Fetch MIME headers of specified message parts
*
* @param string $mailbox Mailbox name
* @param int $uid Message UID
* @param array $parts Message part identifiers
* @param bool $mime Use MIME instead of HEADER
*
* @return array|bool Array containing headers string for each specified body
* False on failure.
*/
public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true)
{
if (!$this->select($mailbox)) {
return false;
}
$parts = (array) $parts;
$key = $this->nextTag();
$peeks = [];
$type = $mime ? 'MIME' : 'HEADER';
// format request
foreach ($parts as $part) {
$peeks[] = "BODY.PEEK[$part.$type]";
}
$request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')';
// send request
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command");
return false;
}
$result = [];
do {
$line = $this->readLine(1024);
if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) {
$line = ltrim(substr($line, strlen($m[0])));
while (preg_match('/^\s*BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) {
$line = substr($line, strlen($matches[0]));
$result[$matches[1]] = trim($this->multLine($line));
$line = $this->readLine(1024);
}
}
}
while (!$this->startsWith($line, $key, true));
return $result;
}
/**
* Fetches message part header
*/
public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null)
{
$part = empty($part) ? 'HEADER' : $part.'.MIME';
return $this->handlePartBody($mailbox, $id, $is_uid, $part);
}
/**
* Fetches body of the specified message part
*/
public function handlePartBody($mailbox, $id, $is_uid = false, $part = '', $encoding = null, $print = null,
$file = null, $formatted = false, $max_bytes = 0)
{
if (!$this->select($mailbox)) {
return false;
}
$binary = true;
$initiated = false;
do {
if (!$initiated) {
switch ($encoding) {
case 'base64':
$mode = 1;
break;
case 'quoted-printable':
$mode = 2;
break;
case 'x-uuencode':
case 'x-uue':
case 'uue':
case 'uuencode':
$mode = 3;
break;
default:
$mode = $formatted ? 4 : 0;
}
// Use BINARY extension when possible (and safe)
$binary = $binary && $mode && preg_match('/^[0-9.]+$/', (string) $part) && $this->hasCapability('BINARY');
$fetch_mode = $binary ? 'BINARY' : 'BODY';
$partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : '';
// format request
$key = $this->nextTag();
$cmd = ($is_uid ? 'UID ' : '') . 'FETCH';
$request = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)";
$result = false;
$found = false;
$initiated = true;
// send request
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
if ($binary) {
// WARNING: Use $formatted argument with care, this may break binary data stream
$mode = -1;
}
}
$line = trim($this->readLine(1024));
if (!$line) {
break;
}
// handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request
if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) {
$binary = $initiated = false;
continue;
}
// skip irrelevant untagged responses (we have a result already)
if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) {
continue;
}
$line = $m[2];
// handle one line response
if ($line[0] == '(' && substr($line, -1) == ')') {
// tokenize content inside brackets
// the content can be e.g.: (UID 9844 BODY[2.4] NIL)
$line = preg_replace('/(^\(|\)$)/', '', $line);
$tokens = $this->tokenizeResponse($line);
for ($i=0; $i<count($tokens); $i+=2) {
if (preg_match('/^(BODY|BINARY)/i', $tokens[$i])) {
$result = $tokens[$i+1];
$found = true;
break;
}
}
// Cyrus IMAP does not return a NO-response on error, but we can detect it
// and fallback to a non-binary fetch (#9097)
if ($binary && !$found) {
$binary = $initiated = false;
$line = trim($this->readLine(1024)); // the OK response line
continue;
}
if ($result !== false) {
$result = $this->decodeContent($result, $mode, true, $prev, $formatted);
}
}
// response with string literal
else if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
$bytes = (int) $m[1];
$prev = '';
$found = true;
$chunkSize = 1024 * 1024;
// empty body
if (!$bytes) {
$result = '';
}
// An optimal path for a case when we need the body as-is in a string
else if (!$mode && !$file && !$print) {
$result = $this->readBytes($bytes);
}
else while ($bytes > 0) {
$chunk = $this->readBytes($bytes > $chunkSize ? $chunkSize : $bytes);
if ($chunk === '') {
break;
}
$len = strlen($chunk);
if ($len > $bytes) {
$chunk = substr($chunk, 0, $bytes);
$len = strlen($chunk);
}
$bytes -= $len;
$chunk = $this->decodeContent($chunk, $mode, $bytes <= 0, $prev, $formatted);
if ($file) {
if (fwrite($file, $chunk) === false) {
break;
}
}
else if ($print) {
echo $chunk;
}
else {
$result .= $chunk;
}
}
}
}
while (!$this->startsWith($line, $key, true) || !$initiated);
if ($result !== false) {
if ($file) {
return fwrite($file, $result);
}
else if ($print) {
echo $result;
return true;
}
return $result;
}
return false;
}
/**
* Decodes a chunk of a message part content from a FETCH response.
*
* @param string $chunk Content
* @param int $mode Encoding mode
* @param bool $is_last Whether it is a last chunk of data
* @param string $prev Extra content from the previous chunk
* @param bool $formatted Format the content for output
*
* @return string Encoded string
*/
protected static function decodeContent($chunk, $mode, $is_last = false, &$prev = '', $formatted = false)
{
// BASE64
if ($mode == 1) {
$chunk = $prev . preg_replace('|[^a-zA-Z0-9+=/]|', '', $chunk);
// create chunks with proper length for base64 decoding
$length = strlen($chunk);
if ($length % 4) {
$length = floor($length / 4) * 4;
$prev = substr($chunk, $length);
$chunk = substr($chunk, 0, $length);
}
else {
$prev = '';
}
// There might be multiple base64 blocks in a single message part,
// we have to pass them separately to base64_decode() (#9290)
$result = '';
foreach (preg_split('|=+|', $chunk, -1, \PREG_SPLIT_NO_EMPTY) as $_chunk) {
$result .= base64_decode($_chunk);
}
$chunk = $result;
}
// QUOTED-PRINTABLE
elseif ($mode == 2) {
if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
return '';
}
$chunk = quoted_printable_decode($chunk);
}
// X-UUENCODE
elseif ($mode == 3) {
if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
return '';
}
$chunk = preg_replace(
['/\r?\n/', '/(^|\n)end$/', '/^begin\s+[0-7]{3,4}\s+[^\n]+\n/'],
["\n", '', ''],
$chunk
);
if (!strlen($chunk)) {
return '';
}
$chunk = convert_uudecode($chunk);
}
// Plain text formatted
// TODO: Formatting should be handled outside of this class
elseif ($mode == 4) {
if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
return '';
}
if ($is_last) {
$chunk = rtrim($chunk, "\t\r\n\0\x0B");
}
}
if ($formatted) {
$chunk = preg_replace('/[\t\r\0\x0B]+\n/', "\n", $chunk);
}
return $chunk;
}
/**
* A helper for a new-line aware parsing. See self::decodeContent().
*/
private static function decodeContentChunk(&$chunk, &$prev, $is_last)
{
$chunk = $prev . $chunk;
$prev = '';
if (!$is_last) {
if (($pos = strrpos($chunk, "\n")) !== false) {
$prev = substr($chunk, $pos + 1);
$chunk = substr($chunk, 0, $pos + 1);
} else {
$prev = $chunk;
return false;
}
}
return true;
}
/**
* Handler for IMAP APPEND command
*
* @param string $mailbox Mailbox name
* @param string|array $message The message source string or array (of strings and file pointers)
* @param array $flags Message flags
* @param string $date Message internal date
* @param bool $binary Enable BINARY append (RFC3516)
*
* @return string|bool On success APPENDUID response (if available) or True, False on failure
*/
public function append($mailbox, &$message, $flags = [], $date = null, $binary = false)
{
unset($this->data['APPENDUID']);
if ($mailbox === null || $mailbox === '') {
return false;
}
$binary = $binary && $this->getCapability('BINARY');
$literal_plus = !$binary && !empty($this->prefs['literal+']);
$len = 0;
$msg = is_array($message) ? $message : [&$message];
$chunk_size = 512000;
for ($i=0, $cnt=count($msg); $i<$cnt; $i++) {
if (is_resource($msg[$i])) {
$stat = fstat($msg[$i]);
if ($stat === false) {
return false;
}
$len += $stat['size'];
}
else {
if (!$binary) {
$msg[$i] = str_replace("\r", '', $msg[$i]);
$msg[$i] = str_replace("\n", "\r\n", $msg[$i]);
}
$len += strlen($msg[$i]);
}
}
if (!$len) {
return false;
}
// build APPEND command
$key = $this->nextTag();
$request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')';
if (!empty($date)) {
$request .= ' ' . $this->escape($date);
}
$request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}';
// send APPEND command
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send APPEND command");
return false;
}
// Do not wait when LITERAL+ is supported
if (!$literal_plus) {
$line = $this->readReply();
if ($line[0] != '+') {
$this->parseResult($line, 'APPEND: ');
return false;
}
}
foreach ($msg as $msg_part) {
// file pointer
if (is_resource($msg_part)) {
rewind($msg_part);
while (!feof($msg_part) && $this->fp) {
$buffer = fread($msg_part, $chunk_size);
$this->putLine($buffer, false);
}
fclose($msg_part);
}
// string
else {
$size = strlen($msg_part);
// Break up the data by sending one chunk (up to 512k) at a time.
// This approach reduces our peak memory usage
for ($offset = 0; $offset < $size; $offset += $chunk_size) {
$chunk = substr($msg_part, $offset, $chunk_size);
if (!$this->putLine($chunk, false)) {
return false;
}
}
}
}
if (!$this->putLine('')) { // \r\n
return false;
}
do {
$line = $this->readLine();
} while (!$this->startsWith($line, $key, true, true));
// Clear internal status cache
unset($this->data['STATUS:'.$mailbox]);
if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) {
return false;
}
if (!empty($this->data['APPENDUID'])) {
return $this->data['APPENDUID'];
}
return true;
}
/**
* Handler for IMAP APPEND command.
*
* @param string $mailbox Mailbox name
* @param string $path Path to the file with message body
* @param string $headers Message headers
* @param array $flags Message flags
* @param string $date Message internal date
* @param bool $binary Enable BINARY append (RFC3516)
*
* @return string|bool On success APPENDUID response (if available) or True, False on failure
*/
public function appendFromFile($mailbox, $path, $headers = null, $flags = [], $date = null, $binary = false)
{
// open message file
if (file_exists(realpath($path))) {
$fp = fopen($path, 'r');
}
if (empty($fp)) {
$this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading");
return false;
}
$message = [];
if ($headers) {
$message[] = trim($headers, "\r\n") . "\r\n\r\n";
}
$message[] = $fp;
return $this->append($mailbox, $message, $flags, $date, $binary);
}
/**
* Returns QUOTA information
*
* @param string $mailbox Mailbox name
*
* @return array|false Quota information, False on error
*/
public function getQuota($mailbox = null)
{
if ($mailbox === null || $mailbox === '') {
$mailbox = 'INBOX';
}
// a0001 GETQUOTAROOT INBOX
// * QUOTAROOT INBOX user/sample
// * QUOTA user/sample (STORAGE 654 9765)
// a0001 OK Completed
list($code, $response) = $this->execute('GETQUOTAROOT', [$this->escape($mailbox)], 0, '/^\* QUOTA /i');
if ($code != self::ERROR_OK) {
return false;
}
$min_free = PHP_INT_MAX;
$result = [];
$all = [];
foreach (explode("\n", $response) as $line) {
$tokens = $this->tokenizeResponse($line, 3);
$quota_root = $tokens[2] ?? null;
$quotas = $this->tokenizeResponse($line, 1);
if (empty($quotas)) {
continue;
}
foreach (array_chunk($quotas, 3) as $quota) {
list($type, $used, $total) = $quota;
$type = strtolower($type);
if ($type && $total) {
$all[$quota_root][$type]['used'] = intval($used);
$all[$quota_root][$type]['total'] = intval($total);
}
}
if (empty($all[$quota_root]['storage'])) {
continue;
}
$used = $all[$quota_root]['storage']['used'];
$total = $all[$quota_root]['storage']['total'];
$free = $total - $used;
// calculate lowest available space from all storage quotas
if ($free < $min_free) {
$min_free = $free;
$result['used'] = $used;
$result['total'] = $total;
$result['percent'] = min(100, round(($used/max(1,$total))*100));
$result['free'] = 100 - $result['percent'];
}
}
if (!empty($result)) {
$result['all'] = $all;
}
return $result;
}
/**
* Send the SETACL command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
* @param mixed $acl ACL string or array
*
* @return bool True on success, False on failure
*
* @since 0.5-beta
*/
public function setACL($mailbox, $user, $acl)
{
if (is_array($acl)) {
$acl = implode('', $acl);
}
$result = $this->execute('SETACL',
[$this->escape($mailbox), $this->escape($user), strtolower($acl)],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
/**
* Send the DELETEACL command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
*
* @return bool True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteACL($mailbox, $user)
{
$result = $this->execute('DELETEACL',
[$this->escape($mailbox), $this->escape($user)],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
/**
* Send the GETACL command (RFC4314)
*
* @param string $mailbox Mailbox name
*
* @return array User-rights array on success, NULL on error
* @since 0.5-beta
*/
public function getACL($mailbox)
{
list($code, $response) = $this->execute('GETACL', [$this->escape($mailbox)], 0, '/^\* ACL /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* ACL ")
$response = substr($response, 6);
$ret = $this->tokenizeResponse($response);
$mbox = array_shift($ret);
$size = count($ret);
// Create user-rights hash array
// @TODO: consider implementing fixACL() method according to RFC4314.2.1.1
// so we could return only standard rights defined in RFC4314,
// excluding 'c' and 'd' defined in RFC2086.
if ($size % 2 == 0) {
for ($i=0; $i<$size; $i++) {
$ret[$ret[$i]] = str_split($ret[++$i]);
unset($ret[$i-1]);
unset($ret[$i]);
}
return $ret;
}
$this->setError(self::ERROR_COMMAND, "Incomplete ACL response");
}
}
/**
* Send the LISTRIGHTS command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
*
* @return array List of user rights
* @since 0.5-beta
*/
public function listRights($mailbox, $user)
{
list($code, $response) = $this->execute('LISTRIGHTS',
[$this->escape($mailbox), $this->escape($user)], 0, '/^\* LISTRIGHTS /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* LISTRIGHTS ")
$response = substr($response, 13);
$ret_mbox = $this->tokenizeResponse($response, 1);
$ret_user = $this->tokenizeResponse($response, 1);
$granted = $this->tokenizeResponse($response, 1);
$optional = trim($response);
return [
'granted' => str_split($granted),
'optional' => explode(' ', $optional),
];
}
}
/**
* Send the MYRIGHTS command (RFC4314)
*
* @param string $mailbox Mailbox name
*
* @return array MYRIGHTS response on success, NULL on error
* @since 0.5-beta
*/
public function myRights($mailbox)
{
list($code, $response) = $this->execute('MYRIGHTS', [$this->escape($mailbox)], 0, '/^\* MYRIGHTS /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* MYRIGHTS ")
$response = substr($response, 11);
$ret_mbox = $this->tokenizeResponse($response, 1);
$rights = $this->tokenizeResponse($response, 1);
return str_split($rights);
}
}
/**
* Send the SETMETADATA command (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entry-value array (use NULL value as NIL)
*
* @return bool True on success, False on failure
* @since 0.5-beta
*/
public function setMetadata($mailbox, $entries)
{
if (!is_array($entries) || empty($entries)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
return false;
}
foreach ($entries as $name => $value) {
$entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true);
}
$entries = implode(' ', $entries);
$result = $this->execute('SETMETADATA',
[$this->escape($mailbox), '(' . $entries . ')'],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
/**
* Send the SETMETADATA command with NIL values (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entry names array
*
* @return bool True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteMetadata($mailbox, $entries)
{
if (!is_array($entries) && !empty($entries)) {
$entries = explode(' ', $entries);
}
if (empty($entries)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
return false;
}
$data = [];
foreach ($entries as $entry) {
$data[$entry] = null;
}
return $this->setMetadata($mailbox, $data);
}
/**
* Send the GETMETADATA command (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entries
* @param array $options Command options (with MAXSIZE and DEPTH keys)
*
* @return array GETMETADATA result on success, NULL on error
*
* @since 0.5-beta
*/
public function getMetadata($mailbox, $entries, $options = [])
{
if (!is_array($entries)) {
$entries = [$entries];
}
$args = [];
// create options string
if (is_array($options)) {
$options = array_change_key_case($options, CASE_UPPER);
$opts = [];
if (!empty($options['MAXSIZE'])) {
$opts[] = 'MAXSIZE '.intval($options['MAXSIZE']);
}
if (isset($options['DEPTH'])) {
$opts[] = 'DEPTH ' . $this->escape($options['DEPTH']);
}
if (!empty($opts)) {
$args[] = $opts;
}
}
$args[] = $this->escape($mailbox);
$args[] = array_map([$this, 'escape'], $entries);
list($code, $response) = $this->execute('GETMETADATA', $args);
if ($code == self::ERROR_OK) {
$result = [];
$data = $this->tokenizeResponse($response);
// The METADATA response can contain multiple entries in a single
// response or multiple responses for each entry or group of entries
for ($i = 0, $size = count($data); $i < $size; $i++) {
if ($data[$i] === '*'
&& $data[++$i] === 'METADATA'
&& is_string($mbox = $data[++$i])
&& is_array($data[++$i])
) {
for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) {
if ($data[$i][$x+1] !== null) {
$result[$mbox][$data[$i][$x]] = $data[$i][$x+1];
}
}
}
}
return $result;
}
}
/**
* Send the SETANNOTATION command (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $data Data array where each item is an array with
* three elements: entry name, attribute name, value
*
* @return bool True on success, False on failure
* @since 0.5-beta
*/
public function setAnnotation($mailbox, $data)
{
if (!is_array($data) || empty($data)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
return false;
}
foreach ($data as $entry) {
// ANNOTATEMORE drafts before version 08 require quoted parameters
$entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true),
$this->escape($entry[1], true), $this->escape($entry[2], true));
}
$entries = implode(' ', $entries);
$result = $this->execute('SETANNOTATION', [$this->escape($mailbox), $entries], self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $data Data array where each item is an array with
* two elements: entry name and attribute name
*
* @return bool True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteAnnotation($mailbox, $data)
{
if (!is_array($data) || empty($data)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
return false;
}
return $this->setAnnotation($mailbox, $data);
}
/**
* Send the GETANNOTATION command (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $entries Entries names
* @param array $attribs Attribs names
*
* @return array Annotations result on success, NULL on error
*
* @since 0.5-beta
*/
public function getAnnotation($mailbox, $entries, $attribs)
{
if (!is_array($entries)) {
$entries = [$entries];
}
// create entries string
// ANNOTATEMORE drafts before version 08 require quoted parameters
foreach ($entries as $idx => $name) {
$entries[$idx] = $this->escape($name, true);
}
$entries = '(' . implode(' ', $entries) . ')';
if (!is_array($attribs)) {
$attribs = [$attribs];
}
// create attributes string
foreach ($attribs as $idx => $name) {
$attribs[$idx] = $this->escape($name, true);
}
$attribs = '(' . implode(' ', $attribs) . ')';
list($code, $response) = $this->execute('GETANNOTATION', [$this->escape($mailbox), $entries, $attribs]);
if ($code == self::ERROR_OK) {
$result = [];
$data = $this->tokenizeResponse($response);
$last_entry = null;
// Here we returns only data compatible with METADATA result format
if (!empty($data) && ($size = count($data))) {
for ($i=0; $i<$size; $i++) {
$entry = $data[$i];
if (isset($mbox) && is_array($entry)) {
$attribs = $entry;
$entry = $last_entry;
}
else if ($entry == '*') {
if ($data[$i+1] == 'ANNOTATION') {
$mbox = $data[$i+2];
unset($data[$i]); // "*"
unset($data[++$i]); // "ANNOTATION"
unset($data[++$i]); // Mailbox
}
// get rid of other untagged responses
else {
unset($mbox);
unset($data[$i]);
}
continue;
}
else if (isset($mbox)) {
$attribs = $data[++$i];
}
else {
unset($data[$i]);
continue;
}
if (!empty($attribs)) {
for ($x=0, $len=count($attribs); $x<$len;) {
$attr = $attribs[$x++];
$value = $attribs[$x++];
if ($attr == 'value.priv' && $value !== null) {
$result[$mbox]['/private' . $entry] = $value;
}
else if ($attr == 'value.shared' && $value !== null) {
$result[$mbox]['/shared' . $entry] = $value;
}
}
}
$last_entry = $entry;
unset($data[$i]);
}
}
return $result;
}
}
/**
* Send the STORE X ANNOTATION command (RFC5257)
*
* @param string $mailbox Mailbox name
* @param array $entries
*
* @return bool True on success, False on failure
*
* @since 1.6.10
*/
public function storeMessageAnnotation($mailbox, $uids, $entries)
{
if (!$this->hasCapability('ANNOTATE-EXPERIMENT-1')) {
return false;
}
if (empty($entries) || empty($uids)) {
$this->setError(self::ERROR_COMMAND, 'Wrong argument for STORE ANNOTATION command');
return false;
}
if (!$this->select($mailbox)) {
return false;
}
/* Example input compatible with rcube_message_header::$annotations:
$entries = [
'/comment' => [
'value.priv' => 'test1',
'value.shared' => null,
],
];
*/
$request = [];
foreach ($entries as $name => $annotation) {
if (!empty($annotation)) {
foreach ($annotation as $key => $value) {
$annotation[$key] = $this->escape($key) . ' ' . $this->escape($value, true);
}
$request[] = $this->escape($name);
$request[] = $annotation;
}
}
$result = $this->execute(
'UID STORE',
[$this->compressMessageSet($uids), 'ANNOTATION', $request],
self::COMMAND_NORESPONSE
);
return $result == self::ERROR_OK;
}
/**
* Returns BODYSTRUCTURE for the specified message.
*
* @param string $mailbox Folder name
* @param int $id Message sequence number or UID
* @param bool $is_uid True if $id is an UID
*
* @return array|bool Body structure array or False on error.
* @since 0.6
*/
public function getStructure($mailbox, $id, $is_uid = false)
{
$result = $this->fetch($mailbox, $id, $is_uid, ['BODYSTRUCTURE']);
if (is_array($result) && !empty($result)) {
$result = array_first($result);
return $result->bodystructure;
}
return false;
}
/**
* Returns data of a message part according to specified structure.
*
* @param array $structure Message structure (getStructure() result)
* @param string $part Message part identifier
*
* @return array Part data as hash array (type, encoding, charset, size)
*/
public static function getStructurePartData($structure, $part)
{
$part_a = self::getStructurePartArray($structure, $part);
$data = [];
if (empty($part_a)) {
return $data;
}
// content-type
if (is_array($part_a[0])) {
$data['type'] = 'multipart';
}
else {
$data['type'] = strtolower($part_a[0]);
$data['subtype'] = strtolower($part_a[1]);
$data['encoding'] = strtolower($part_a[5]);
// charset
if (is_array($part_a[2])) {
foreach ($part_a[2] as $key => $val) {
if (strcasecmp($val, 'charset') == 0) {
$data['charset'] = $part_a[2][$key+1];
break;
}
}
}
}
// size
$data['size'] = intval($part_a[6]);
return $data;
}
public static function getStructurePartArray($a, $part)
{
if (!is_array($a)) {
return false;
}
if (empty($part)) {
return $a;
}
$ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : '';
if (strcasecmp($ctype, 'message/rfc822') == 0) {
$a = $a[8];
}
if (strpos($part, '.') > 0) {
$orig_part = $part;
$pos = strpos($part, '.');
$rest = substr($orig_part, $pos+1);
$part = substr($orig_part, 0, $pos);
return self::getStructurePartArray($a[$part-1], $rest);
}
else if ($part > 0) {
return is_array($a[$part-1]) ? $a[$part-1] : $a;
}
}
/**
* Creates next command identifier (tag)
*
* @return string Command identifier
* @since 0.5-beta
*/
public function nextTag()
{
$this->cmd_num++;
$this->cmd_tag = sprintf('A%04d', $this->cmd_num);
return $this->cmd_tag;
}
/**
* Sends IMAP command and parses result
*
* @param string $command IMAP command
* @param array $arguments Command arguments
* @param int $options Execution options
* @param string $filter Line filter (regexp)
*
* @return mixed Response code or list of response code and data
* @since 0.5-beta
*/
public function execute($command, $arguments = [], $options = 0, $filter = null)
{
$tag = $this->nextTag();
$query = $tag . ' ' . $command;
$noresp = ($options & self::COMMAND_NORESPONSE);
$response = $noresp ? null : '';
if (!empty($arguments)) {
foreach ($arguments as $arg) {
$query .= ' ' . self::r_implode($arg);
}
}
// Send command
if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) {
preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches);
$cmd = $matches[1] ?: 'UNKNOWN';
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return $noresp ? self::ERROR_COMMAND : [self::ERROR_COMMAND, ''];
}
// Parse response
do {
$line = $this->readFullLine(4096);
if ($response !== null) {
if (!$filter || preg_match($filter, $line)) {
$response .= $line;
}
}
// parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851)
if ($line && $command == 'UID MOVE') {
if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) {
$this->data['COPYUID'] = [$m[1], $m[2]];
}
}
}
while (!$this->startsWith($line, $tag . ' ', true, true));
$code = $this->parseResult($line, $command . ': ');
// Remove last line from response
if ($response) {
if (!$filter) {
$line_len = min(strlen($response), strlen($line));
$response = substr($response, 0, -$line_len);
}
$response = rtrim($response, "\r\n");
}
// optional CAPABILITY response
if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK
&& preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)
) {
$this->parseCapability($matches[1], true);
}
// return last line only (without command tag, result and response code)
if ($line && ($options & self::COMMAND_LASTLINE)) {
$response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line));
}
return $noresp ? $code : [$code, $response];
}
/**
* Splits IMAP response into string tokens
*
* @param string &$str The IMAP's server response
* @param int $num Number of tokens to return
*
* @return mixed Tokens array or string if $num=1
* @since 0.5-beta
*/
public static function tokenizeResponse(&$str, $num=0)
{
$result = [];
while (!$num || count($result) < $num) {
// remove spaces from the beginning of the string
$str = ltrim($str);
// empty string
if ($str === '' || $str === null) {
break;
}
switch ($str[0]) {
// String literal
case '{':
if (($epos = strpos($str, "}\r\n", 1)) == false) {
// error
}
if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) {
// error
}
$result[] = $bytes ? substr($str, $epos + 3, $bytes) : '';
$str = substr($str, $epos + 3 + $bytes);
break;
// Quoted string
case '"':
$len = strlen($str);
for ($pos=1; $pos<$len; $pos++) {
if ($str[$pos] == '"') {
break;
}
if ($str[$pos] == "\\") {
if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
$pos++;
}
}
}
// we need to strip slashes for a quoted string
$result[] = stripslashes(substr($str, 1, $pos - 1));
$str = substr($str, $pos + 1);
break;
// Parenthesized list
case '(':
$str = substr($str, 1);
$result[] = self::tokenizeResponse($str);
break;
case ')':
$str = substr($str, 1);
return $result;
// String atom, number, astring, NIL, *, %
default:
// excluded chars: SP, CTL, ), DEL
// we do not exclude [ and ] (#1489223)
if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) {
$result[] = $m[1] == 'NIL' ? null : $m[1];
$str = substr($str, strlen($m[1]));
}
break;
}
}
return $num == 1 ? ($result[0] ?? '') : $result;
}
/**
* Joins IMAP command line elements (recursively)
*/
protected static function r_implode($element)
{
if (!is_array($element)) {
return $element;
}
reset($element);
$string = '';
foreach ($element as $value) {
$string .= ' ' . self::r_implode($value);
}
return '(' . trim($string) . ')';
}
/**
* Converts message identifiers array into sequence-set syntax
*
* @param array $messages Message identifiers
* @param bool $force Forces compression of any size
*
* @return string Compressed sequence-set
*/
public static function compressMessageSet($messages, $force = false)
{
// given a comma delimited list of independent mid's,
// compresses by grouping sequences together
if (!is_array($messages)) {
// if less than 255 bytes long, let's not bother
if (!$force && strlen($messages) < 255) {
return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
}
// see if it's already been compressed
if (strpos($messages, ':') !== false) {
return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
}
// separate, then sort
$messages = explode(',', $messages);
}
sort($messages);
$result = [];
$start = $prev = $messages[0];
foreach ($messages as $id) {
$incr = $id - $prev;
if ($incr > 1) { // found a gap
if ($start == $prev) {
$result[] = $prev; // push single id
}
else {
$result[] = $start . ':' . $prev; // push sequence as start_id:end_id
}
$start = $id; // start of new sequence
}
$prev = $id;
}
// handle the last sequence/id
if ($start == $prev) {
$result[] = $prev;
}
else {
$result[] = $start.':'.$prev;
}
// return as comma separated string
$result = implode(',', $result);
return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result;
}
/**
* Converts message sequence-set into array
*
* @param string $messages Message identifiers
*
* @return array List of message identifiers
*/
public static function uncompressMessageSet($messages)
{
if (empty($messages)) {
return [];
}
$result = [];
$messages = explode(',', $messages);
foreach ($messages as $idx => $part) {
$items = explode(':', $part);
if (!empty($items[1]) && $items[1] > $items[0]) {
$max = $items[1];
}
else {
$max = $items[0];
}
for ($x = $items[0]; $x <= $max; $x++) {
$result[] = (int) $x;
}
unset($messages[$idx]);
}
return $result;
}
/**
* Clear internal status cache
*/
protected function clear_status_cache($mailbox)
{
unset($this->data['STATUS:' . $mailbox]);
$keys = ['EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP'];
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
/**
* Clear internal cache of the current mailbox
*/
protected function clear_mailbox_cache()
{
$this->clear_status_cache($this->selected);
$keys = ['UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ',
'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE'];
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
/**
* Converts flags array into string for inclusion in IMAP command
*
* @param array $flags Flags (see self::flags)
*
* @return string Space-separated list of flags
*/
protected function flagsToStr($flags)
{
foreach ((array) $flags as $idx => $flag) {
if ($flag = $this->flags[strtoupper($flag)]) {
$flags[$idx] = $flag;
}
}
return implode(' ', (array) $flags);
}
/**
* CAPABILITY response parser
*/
protected function parseCapability($str, $trusted=false)
{
$str = preg_replace('/^\* CAPABILITY /i', '', $str);
$this->capability = explode(' ', strtoupper($str));
if (!empty($this->prefs['disabled_caps'])) {
$this->capability = array_diff($this->capability, $this->prefs['disabled_caps']);
}
if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) {
$this->prefs['literal+'] = true;
}
else if (!isset($this->prefs['literal-']) && in_array('LITERAL-', $this->capability)) {
$this->prefs['literal-'] = true;
}
if ($trusted) {
$this->capability_read = true;
}
}
/**
* Escapes a string when it contains special characters (RFC3501)
*
* @param string $string IMAP string
* @param bool $force_quotes Forces string quoting (for atoms)
*
* @return string String atom, quoted-string or string literal
* @todo lists
*/
public static function escape($string, $force_quotes = false)
{
if ($string === null) {
return 'NIL';
}
if ($string === '') {
return '""';
}
// atom-string (only safe characters)
if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) {
return $string;
}
// quoted-string
if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
return '"' . addcslashes($string, '\\"') . '"';
}
// literal-string
return sprintf("{%d}\r\n%s", strlen($string), $string);
}
/**
* Set the value of the debugging flag.
*
* @param bool $debug New value for the debugging flag.
* @param callable $handler Logging handler function
*
* @since 0.5-stable
*/
public function setDebug($debug, $handler = null)
{
$this->debug = $debug;
$this->debug_handler = $handler;
}
/**
* Write the given debug text to the current debug output handler.
*
* @param string $message Debug message text.
*
* @since 0.5-stable
*/
protected function debug($message)
{
if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) {
$diff = $len - self::DEBUG_LINE_LENGTH;
$message = substr($message, 0, self::DEBUG_LINE_LENGTH)
. "... [truncated $diff bytes]";
}
if ($this->resourceid) {
$message = sprintf('[%s] %s', $this->resourceid, $message);
}
if ($this->debug_handler) {
call_user_func_array($this->debug_handler, [$this, $message]);
}
else {
echo "DEBUG: $message\n";
}
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 4, 1:13 AM (1 w, 6 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18821865
Default Alt Text
(300 KB)

Event Timeline