1: <?php
2:
3: /**
4: * IMAP libs
5: * @package modules
6: * @subpackage imap
7: */
8:
9: use ZBateson\MailMimeParser\MailMimeParser;
10:
11: require_once('hm-imap-base.php');
12: require_once('hm-imap-parser.php');
13: require_once('hm-imap-cache.php');
14: require_once('hm-imap-bodystructure.php');
15: require_once('hm-jmap.php');
16: require_once('hm-ews.php');
17:
18: /**
19: * IMAP connection manager
20: * @subpackage imap/lib
21: */
22: class Hm_IMAP_List {
23:
24: use Hm_Server_List;
25:
26: public static $use_cache = false;
27: protected static $user_config;
28: protected static $session;
29:
30: public static function init($user_config, $session) {
31: self::initRepo('imap_servers', $user_config, $session, self::$server_list);
32: self::$user_config = $user_config;
33: self::$session = $session;
34: }
35:
36: public static function service_connect($id, $server, $user, $pass, $cache=false) {
37: $config = array(
38: 'server' => $server['server'],
39: 'port' => $server['port'],
40: 'tls' => $server['tls'],
41: 'type' => array_key_exists('type', $server) ? $server['type'] : 'imap',
42: 'username' => $user,
43: 'password' => $pass,
44: 'use_cache' => self::$use_cache
45: );
46:
47: if (array_key_exists('auth', $server)) {
48: $config['auth'] = $server['auth'];
49: }
50:
51: self::$server_list[$id]['object'] = new Hm_Mailbox($id, self::$user_config, self::$session, $config);
52: if (self::$use_cache && $cache && is_array($cache)) {
53: self::$server_list[$id]['object']->get_connection()->load_cache($cache, 'array');
54: }
55:
56: return self::$server_list[$id]['object']->connect();
57: }
58:
59: public static function get_cache($hm_cache, $id) {
60: if (!self::$use_cache) {
61: return false;
62: }
63: $res = $hm_cache->get('imap'.$id);
64: return $res;
65: }
66:
67: public static function get_connected_mailbox($id, $hm_cache = null) {
68: if ($hm_cache) {
69: $cache = self::get_cache($hm_cache, $id);
70: } else {
71: $cache = false;
72: }
73: return self::connect($id, $cache);
74: }
75:
76: public static function get_mailbox_without_connection($config) {
77: $config['type'] = array_key_exists('type', $config) ? $config['type'] : 'imap';
78: return new Hm_Mailbox($config['id'], self::$user_config, self::$session, $config);
79: }
80: }
81:
82: /* for testing */
83: if (!class_exists('Hm_IMAP')) {
84:
85: /**
86: * public interface to IMAP commands
87: * @subpackage imap/lib
88: */
89: class Hm_IMAP extends Hm_IMAP_Cache {
90:
91: /* config */
92:
93: /* Enable EIMS workarounds */
94: private $eims_tweaks = false;
95:
96: /* maximum characters to read in from a request */
97: public $max_read = false;
98:
99: /* SSL connection knobs */
100: public $verify_peer_name = false;
101: public $verify_peer = false;
102:
103: /* IMAP server IP address or hostname */
104: public $server = '127.0.0.1';
105:
106: /* IP port to connect to. Standard port is 143, TLS is 993 */
107: public $port = 143;
108:
109: /* enable TLS when connecting to the IMAP server */
110: public $tls = false;
111:
112: /* don't change the account state in any way */
113: public $read_only = false;
114:
115: /* convert folder names to utf7 */
116: public $utf7_folders = true;
117:
118: /* defaults to LOGIN, CRAM-MD5 also supported but experimental */
119: public $auth = false;
120:
121: /* search character set to use. can be US-ASCII, UTF-8, or '' */
122: public $search_charset = '';
123:
124: /* sort responses can _probably_ be parsed quickly. This is non-conformant however */
125: public $sort_speedup = true;
126:
127: /* use built in caching. strongly recommended */
128: public $use_cache = true;
129:
130: /* limit LIST/LSUB responses to this many characters */
131: public $folder_max = 50000;
132:
133: /* number of commands and responses to keep in memory. */
134: public $max_history = 1000;
135:
136: /* default IMAP folder delimiter. Only used if NAMESPACE is not supported */
137: public $default_delimiter = '/';
138:
139: /* defailt IMAP mailbox prefix. Only used if NAMESPACE is not supported */
140: public $default_prefix = '';
141:
142: /* list of supported IMAP extensions to ignore */
143: public $blacklisted_extensions = array();
144:
145: /* maximum number of IMAP commands to cache */
146: public $cache_limit = 100;
147:
148: /* query the server for it's CAPABILITY response */
149: public $no_caps = false;
150:
151: /* server type */
152: public $server_type = 'IMAP';
153:
154: /* IMAP ID client information */
155: public $app_name = 'Hm_IMAP';
156: public $app_version = '3.0';
157: public $app_vendor = 'Cypht Development Group';
158: public $app_support_url = 'https://cypht.org/#contact';
159:
160: /* connect error info */
161: public $con_error_msg = '';
162: public $con_error_num = 0;
163:
164: public $banner = '';
165:
166: /* holds information about the currently selected mailbox */
167: public $selected_mailbox = false;
168:
169: /* special folders defined by the IMAP SPECIAL-USE extension */
170: public $special_use_mailboxes = array(
171: '\All' => false,
172: '\Archive' => false,
173: '\Drafts' => false,
174: '\Flagged' => false,
175: '\Junk' => false,
176: '\Sent' => false,
177: '\Trash' => false
178: );
179:
180: /* holds the current IMAP connection state */
181: private $state = 'disconnected';
182:
183: /* used for message part content streaming */
184: private $stream_size = 0;
185:
186: /* current selected mailbox status */
187: public $folder_state = false;
188: private $scramAuthenticator;
189: private $namespace_count = 0;
190:
191: protected $list_sub_folders = [];
192: /**
193: * constructor
194: */
195: public function __construct() {
196: $this->scramAuthenticator = new ScramAuthenticator();
197: }
198:
199: /* ------------------ CONNECT/AUTH ------------------------------------- */
200:
201: /**
202: * connect to the imap server
203: * @param array $config list of configuration options for this connections
204: * @return bool true on connection sucess
205: */
206: public function connect($config) {
207: if (isset($config['username']) && isset($config['password'])) {
208: $this->commands = array();
209: $this->debug = array();
210: $this->capability = false;
211: $this->responses = array();
212: $this->current_command = false;
213: $this->apply_config($config);
214: if ($this->tls) {
215: $this->server = 'tls://'.$this->server;
216: }
217: else {
218: $this->server = 'tcp://'.$this->server;
219: }
220: $this->debug[] = 'Connecting to '.$this->server.' on port '.$this->port;
221: $ctx = stream_context_create();
222:
223: stream_context_set_option($ctx, 'ssl', 'verify_peer_name', $this->verify_peer_name);
224: stream_context_set_option($ctx, 'ssl', 'verify_peer', $this->verify_peer);
225:
226: $timeout = 10;
227: $this->handle = Hm_Functions::stream_socket_client($this->server, $this->port, $errorno, $errorstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
228: if (is_resource($this->handle)) {
229: $this->debug[] = 'Successfully opened port to the IMAP server';
230: $this->state = 'connected';
231: return $this->authenticate($config['username'], $config['password']);
232: }
233: else {
234: $this->debug[] = 'Could not connect to the IMAP server';
235: $this->debug[] = 'fsockopen errors #'.$errorno.'. '.$errorstr;
236: $this->con_error_msg = $errorstr;
237: $this->con_error_num = $errorno;
238: return false;
239: }
240: }
241: else {
242: $this->debug[] = 'username and password must be set in the connect() config argument';
243: return false;
244: }
245: }
246:
247: /**
248: * close the IMAP connection
249: * @return void
250: */
251: public function disconnect() {
252: $command = "LOGOUT\r\n";
253: $this->state = 'disconnected';
254: $this->selected_mailbox = false;
255: $this->send_command($command);
256: $result = $this->get_response();
257: if (is_resource($this->handle)) {
258: fclose($this->handle);
259: }
260: }
261: /**
262: * Authenticate the username/password
263: * @param string $username IMAP login name
264: * @param string $password IMAP password
265: * @return bool true on successful login
266: */
267: public function authenticate($username, $password) {
268: $this->get_capability();
269: if (!$this->tls) {
270: $this->starttls();
271: }
272: $scramMechanisms = [
273: 'scram-sha-1', 'scram-sha-1-plus',
274: 'scram-sha-256', 'scram-sha-256-plus',
275: 'scram-sha-224', 'scram-sha-224-plus',
276: 'scram-sha-384', 'scram-sha-384-plus',
277: 'scram-sha-512', 'scram-sha-512-plus'
278: ];
279: if (in_array(mb_strtolower($this->auth), $scramMechanisms)) {
280: $scramAlgorithm = mb_strtoupper($this->auth);
281: if ($this->scramAuthenticator->authenticateScram(
282: $scramAlgorithm,
283: $username,
284: $password,
285: [$this, 'get_response'],
286: [$this, 'send_command']
287: )) {
288: return true; // Authentication successful
289: }
290: }
291: switch (mb_strtolower($this->auth)) {
292: case 'cram-md5':
293: $this->banner = $this->fgets(1024);
294: $cram1 = 'AUTHENTICATE CRAM-MD5' . "\r\n";
295: $this->send_command($cram1);
296: $response = $this->get_response();
297: $challenge = base64_decode(substr(trim($response[0]), 1));
298: $pass = str_repeat(chr(0x00), (64-strlen($password)));
299: $ipad = str_repeat(chr(0x36), 64);
300: $opad = str_repeat(chr(0x5c), 64);
301: $digest = bin2hex(pack("H*", md5(($pass ^ $opad) . pack("H*", md5(($pass ^ $ipad) . $challenge)))));
302: $challenge_response = base64_encode($username . ' ' . $digest);
303: fputs($this->handle, $challenge_response . "\r\n");
304: break;
305: case 'xoauth2':
306: $challenge = 'user=' . $username . chr(1) . 'auth=Bearer ' . $password . chr(1) . chr(1);
307: $command = 'AUTHENTICATE XOAUTH2 ' . base64_encode($challenge) . "\r\n";
308: $this->send_command($command);
309: break;
310: default:
311: $login = 'LOGIN "' . str_replace(array('\\', '"'), array('\\\\', '\"'), $username) . '" "' . str_replace(array('\\', '"'), array('\\\\', '\"'), $password) . "\"\r\n";
312: $this->send_command($login);
313: break;
314: }
315: $res = $this->get_response();
316: $authed = false;
317: if (is_array($res) && !empty($res)) {
318: $response = array_pop($res);
319: if (!$this->auth) {
320: if (isset($res[1])) {
321: $this->banner = $res[1];
322: }
323: if (isset($res[0])) {
324: $this->banner = $res[0];
325: }
326: }
327: if (mb_stristr($response, 'A' . $this->command_count . ' OK')) {
328: $authed = true;
329: $this->state = 'authenticated';
330: } elseif (mb_strtolower($this->auth) == 'xoauth2' && preg_match("/^\+ ([a-zA-Z0-9=]+)$/", $response, $matches)) {
331: $this->send_command("\r\n", true);
332: $this->get_response();
333: }
334: }
335: if ($authed) {
336: $this->debug[] = 'Logged in successfully as ' . $username;
337: $this->get_capability();
338: $this->enable();
339: } else {
340: $this->debug[] = 'Log in for ' . $username . ' FAILED';
341: }
342: return $authed;
343: }
344:
345: /**
346: * attempt starttls
347: * @return void
348: */
349: public function starttls() {
350: if ($this->is_supported('STARTTLS')) {
351: $command = "STARTTLS\r\n";
352: $this->send_command($command);
353: $response = $this->get_response();
354: if (!empty($response)) {
355: $end = array_pop($response);
356: if (mb_substr($end, 0, mb_strlen('A'.$this->command_count.' OK')) == 'A'.$this->command_count.' OK') {
357: Hm_Functions::stream_socket_enable_crypto($this->handle, get_tls_stream_type());
358: }
359: else {
360: $this->debug[] = 'Unexpected results from STARTTLS: '.implode(' ', $response);
361: }
362: }
363: else {
364: $this->debug[] = 'No response from STARTTLS command';
365: }
366: }
367: }
368:
369: /* ------------------ UNSELECTED STATE COMMANDS ------------------------ */
370:
371: /**
372: * fetch IMAP server capability response
373: * @return string capability response
374: */
375: public function get_capability() {
376: if (!$this->no_caps) {
377: $command = "CAPABILITY\r\n";
378: $this->send_command($command);
379: $response = $this->get_response();
380: foreach ($response as $line) {
381: if (mb_stristr($line, '* CAPABILITY')) {
382: $this->capability = $line;
383: break;
384: }
385: }
386: $this->debug['CAPS'] = $this->capability;
387: $this->parse_extensions_from_capability();
388: }
389: return $this->capability;
390: }
391:
392: /**
393: * Sets the Access Control List (ACL) for a specified mailbox.
394: *
395: * This function sends the `SETACL` command to the IMAP server to modify
396: * the access rights of a user or identifier for a given mailbox. The access rights
397: * can either be granted or revoked based on the rights modification string.
398: *
399: * The third argument can either:
400: * - Add rights (using a `+` prefix),
401: * - Remove rights (using a `-` prefix),
402: * - Completely replace existing rights (no prefix).
403: *
404: * For more information on ACLs, see RFC 4314:
405: * [Access Control Lists (ACLs) in Internet Message Access Protocol (IMAP)](https://tools.ietf.org/html/rfc4314).
406: *
407: * @param string $mailbox_name The name of the mailbox for which the ACL is to be set.
408: * @param string $identifier The user or identifier whose permissions are being modified.
409: * @param string $rights_modification The modification to the user's rights (e.g., "+rw" to add read and write access,
410: * "-w" to remove write access, or "rw" to set read and write access explicitly).
411: *
412: * @return bool True if the ACL was successfully set, false if an error occurred.
413: */
414: public function set_acl($mailbox_name, $identifier, $rights_modification) {
415: $command = "SETACL \"$mailbox_name\" \"$identifier\" \"$rights_modification\"\r\n";
416:
417: $this->send_command($command);
418: $response = $this->get_response();
419:
420: foreach ($response as $line) {
421: if (mb_strpos($line, 'OK') !== false) {
422: Hm_Msgs::add('Permissions added successfully');
423: return true;
424: }
425: elseif (mb_strpos($line, 'NO') !== false || mb_strpos($line, 'BAD') !== false) {
426: $this->debug[] = 'SETACL failed: ' . $line;
427: Hm_Msgs::add('SETACL failed:' . $line, 'danger');
428: return false;
429: }
430: }
431: return false;
432: }
433:
434: /**
435: * Deletes an access control list (ACL) entry for a specified identifier on a given mailbox.
436: *
437: * This function sends a DELETEACL command to remove any <identifier, rights> pair
438: * for the specified identifier from the access control list for the specified mailbox.
439: *
440: * For more information on ACLs, see RFC 4314:
441: * [Access Control Lists (ACLs) in Internet Message Access Protocol (IMAP)](https://tools.ietf.org/html/rfc4314).
442: *
443: * @param string $mailbox The name of the mailbox from which to delete the ACL entry.
444: * @param string $identifier The authentication identifier whose rights are to be removed.
445: *
446: * @throws Exception If the delete operation fails or if invalid arguments are provided.
447: *
448: * @return bool Returns true on successful deletion of the ACL entry; otherwise, false.
449: */
450: public function delete_acl($mailbox_name, $identifier) {
451: $command = "DELETEACL \"$mailbox_name\" \"$identifier\"\r\n";
452: $this->send_command($command);
453: $response = $this->get_response();
454:
455: foreach ($response as $line) {
456: if (strpos($line, 'OK') !== false) {
457: Hm_Msgs::add('Permissions removed successfully');
458: return true;
459: } else {
460: $this->debug[] = 'DELETEACL failed: ' . $line;
461: Hm_Msgs::add('DELETEACL failed: can\'t delete acl', 'danger');
462: return false;
463: }
464: }
465: return false;
466: }
467:
468: /**
469: * Retrieves the Access Control List (ACL) for a specified mailbox.
470: *
471: * This function sends a `GETACL` command to the IMAP server to fetch the list
472: * of users and their respective access rights for the given mailbox. It then
473: * parses the server's response and maps the raw rights to human-readable
474: * permissions using the `map_permissions` function.
475: *
476: * For more information on ACLs, see RFC 4314:
477: * [Access Control Lists (ACLs) in Internet Message Access Protocol (IMAP)](https://tools.ietf.org/html/rfc4314).
478: *
479: * @param string $mailbox_name The name of the mailbox for which to retrieve the ACL.
480: *
481: * @return array An associative array where the keys are email addresses (or user identifiers),
482: * and the values are human-readable permissions (e.g., 'Read, Write').
483: */
484: public function get_acl($mailbox_name) {
485: $acl_list = [];
486: $command = "GETACL \"$mailbox_name\"\r\n";
487: $this->send_command($command);
488: $response = $this->get_response();
489:
490: foreach ($response as $line) {
491: if (preg_match('/^\* ACL ([^\s]+) (.+)$/', $line, $matches)) {
492: $mailbox = $matches[1];
493: $acl_string = $matches[2];
494:
495: $acl_parts = explode(' ', $acl_string);
496: for ($i = 1; $i < count($acl_parts); $i += 2) {
497: $user = $acl_parts[$i - 1];
498: $rights = $acl_parts[$i];
499: $acl_list[$user] = $this->map_permissions($rights);
500: }
501: }
502: }
503: return $acl_list;
504: }
505:
506: /**
507: * special version of LIST to return just special use mailboxes
508: * @param string $type type of special folder to return (sent, all, trash, flagged, junk)
509: * @return array list of special use folders
510: */
511: public function get_special_use_mailboxes($type=false) {
512: $folders = array();
513: $types = array('trash', 'sent', 'flagged', 'all', 'junk', 'archive', 'drafts');
514: $command = 'LIST (SPECIAL-USE) "" "*"'."\r\n";
515: $this->send_command($command);
516: $res = $this->get_response(false, true);
517: foreach ($res as $row) {
518: foreach ($row as $atom) {
519: if (in_array(mb_strtolower(mb_substr($atom, 1)), $types, true)) {
520: $folder = array_pop($row);
521: $name = mb_strtolower(mb_substr($atom, 1));
522: if ($type && $type == $name) {
523: return array($name => $folder);
524: }
525: $folders[$name] = $folder;
526: break;
527: }
528: }
529: }
530: return $folders;
531: }
532:
533: /**
534: * get a list of mailbox folders
535: * @param bool $lsub flag to limit results to subscribed folders only
536: * @return array associative array of folder details
537: */
538: public function get_mailbox_list($lsub=false, $mailbox='', $keyword='*', $children_capability=true) {
539: /* defaults */
540: $folders = array();
541: $excluded = array();
542: $parents = array();
543: $delim = false;
544: $inbox = false;
545: $commands = $this->build_list_commands($lsub, $mailbox, $keyword, $children_capability);
546:
547: $cache_command = implode('', array_map(function($v) { return $v[0]; }, $commands)).(string)$mailbox.(string)$keyword;
548: $cache = $this->check_cache($cache_command);
549: if ($cache !== false) {
550: return $cache;
551: }
552:
553: foreach($commands as $vals) {
554: $command = $vals[0];
555: $namespace = $vals[1];
556:
557: $this->send_command($command);
558: $result = $this->get_response($this->folder_max, true);
559:
560: if (!$children_capability) {
561: $delim = $result[0][count($result[0]) - 2];
562: $result = $this->preprocess_folders($result, $mailbox, $delim);
563: }
564:
565: /* loop through the "parsed" response. Each iteration is one folder */
566: foreach ($result as $vals) {
567:
568: if (in_array('STATUS', $vals)) {
569: $status_values = $this->parse_status_response(array($vals));
570: $this->check_mailbox_state_change($status_values);
571: continue;
572: }
573: /* break at the end of the list */
574: if (!isset($vals[0]) || $vals[0] == 'A'.$this->command_count) {
575: continue;
576: }
577:
578: /* defaults */
579: $flags = false;
580: $flag = false;
581: $delim_flag = false;
582: $parent = '';
583: $base_name = '';
584: $folder_parts = array();
585: $no_select = false;
586: $can_have_kids = true;
587: $has_kids = false;
588: $marked = false;
589: $special = false;
590: $folder_sort_by = 'ARRIVAL';
591: $check_for_new = false;
592:
593: /* full folder name, includes an absolute path of parent folders */
594: if ($lsub && in_array("\HasChildren", $vals)) {
595: $folder = $this->utf7_decode($vals[array_search(".", $vals) + 1]);
596: } else {
597: $folder = $this->utf7_decode($vals[(count($vals) - 1)]);
598: }
599:
600: /* sometimes LIST responses have dupes */
601: if (isset($folders[$folder]) || !$folder || $folder === '*') {
602: continue;
603: }
604:
605: /* folder flags */
606: foreach ($vals as $v) {
607: if ($v == '(') {
608: $flag = true;
609: }
610: elseif ($v == ')') {
611: $flag = false;
612: $delim_flag = true;
613: }
614: else {
615: if ($flag) {
616: $flags .= ' '.$v;
617: }
618: if ($delim_flag && !$delim) {
619: $delim = $v;
620: $delim_flag = false;
621: }
622: }
623: }
624:
625: /* get each folder name part of the complete hierarchy */
626: $folder_parts = array();
627: if ($delim && mb_strstr($folder, $delim)) {
628: $temp_parts = explode($delim, $folder);
629: foreach ($temp_parts as $g) {
630: if (trim($g)) {
631: $folder_parts[] = $g;
632: }
633: }
634: }
635: else {
636: $folder_parts[] = $folder;
637: }
638:
639: /* get the basename part of the folder name. For a folder named "inbox.sent.march"
640: * with a delimiter of "." the basename would be "march" */
641: $base_name = $folder_parts[(count($folder_parts) - 1)];
642:
643: /* determine the parent folder basename if it exists */
644: if (isset($folder_parts[(count($folder_parts) - 2)])) {
645: $parent = implode($delim, array_slice($folder_parts, 0, -1));
646: if ($parent.$delim == $namespace) {
647: $parent = '';
648: }
649: }
650:
651: /* special use mailbox extension */
652: if ($this->is_supported('SPECIAL-USE')) {
653: foreach ($this->special_use_mailboxes as $name => $value) {
654: if (mb_stristr($flags, $name)) {
655: $special = $name;
656: }
657: }
658: if ($special) {
659: $this->special_use_mailboxes[$special] = $folder;
660: }
661: }
662:
663: /* build properties from the flags string */
664: if (mb_stristr($flags, 'marked')) {
665: $marked = true;
666: }
667: if (mb_stristr($flags, 'noinferiors')) {
668: $can_have_kids = false;
669: }
670: if (($folder == $namespace && $namespace) || mb_stristr($flags, 'hashchildren') || mb_stristr($flags, 'haschildren')) {
671: $has_kids = true;
672: }
673: if ($folder != 'INBOX' && $folder != $namespace && mb_stristr($flags, 'noselect')) {
674: $no_select = true;
675: }
676: /* EIMS work-around */
677: if ($this->eims_tweaks && !mb_stristr($flags, 'noinferiors') && mb_stristr($flags, 'noselect')) {
678: $has_kids = true;
679: }
680:
681: /* store the results in the big folder list struct */
682: if (mb_strtolower($folder) == 'inbox') {
683: $inbox = true;
684: $special = true;
685: }
686: $folders[$folder] = array(
687: 'parent' => $parent,
688: 'delim' => $delim,
689: 'name' => $folder,
690: 'name_parts' => $folder_parts,
691: 'basename' => $base_name,
692: 'realname' => $folder,
693: 'namespace' => $namespace,
694: 'marked' => $marked,
695: 'noselect' => $no_select,
696: 'can_have_kids' => $can_have_kids,
697: 'has_kids' => $has_kids,
698: 'special' => (bool) $special
699: );
700:
701: /* store a parent list used below */
702: if ($parent && !in_array($parent, $parents)) {
703: $parents[$parent][] = $folders[$folder];
704: }
705: }
706: }
707:
708: /* ALL account need an inbox. If we did not find one manually add it to the results */
709: if (!$inbox && !$mailbox ) {
710: $folders = array_merge(array('INBOX' => array(
711: 'name' => 'INBOX', 'basename' => 'INBOX', 'realname' => 'INBOX', 'noselect' => false,
712: 'parent' => false, 'has_kids' => false, 'name_parts' => array(), 'delim' => $delim)), $folders);
713: }
714:
715: /* sort and return the list */
716: uksort($folders, array($this, 'fsort'));
717: return $this->cache_return_val($folders, $cache_command);
718: }
719:
720: /**
721: * Preprocess the folder list to determine if a folder has children
722: * @param array $result the folder list
723: * @param string $mailbox the mailbox to limit the results to
724: * @param string $delim the folder delimiter
725: * @return array the processed folder list
726: */
727: function preprocess_folders($result, $mailbox, $delim) {
728: $folderPaths = [];
729: $processedResult = [];
730:
731: // Step 1: Extract all folder paths from the array (using the last element in each sub-array)
732: foreach ($result as $entry) {
733: if (isset($entry[count($entry) - 1]) && is_string($entry[count($entry) - 1])) {
734: $folderPaths[] = $entry[count($entry) - 1];
735: }
736: }
737:
738: // Step 2: Process each folder to determine if it has subfolders
739: foreach ($result as $entry) {
740: if (isset($entry[count($entry) - 1]) && is_string($entry[count($entry) - 1])) {
741: $currentFolder = $entry[count($entry) - 1];
742: $hasChildren = false;
743:
744: // Check if any other folder starts with the current folder followed by the delimiter
745: foreach ($folderPaths as $path) {
746: if (strpos($path, $currentFolder . $delim) === 0) {
747: $hasChildren = true;
748: break;
749: }
750: }
751:
752: // Add the appropriate flag (\HasChildren or \HasNoChildren)
753: $entry = array_merge(
754: array_slice($entry, 0, 3),
755: [$hasChildren ? "\\HasChildren" : "\\HasNoChildren"],
756: array_slice($entry, 3)
757: );
758:
759: // Root folder processing
760: if (empty($mailbox)) {
761: if (strpos($currentFolder, $delim) === false) {
762: $processedResult[] = $entry;
763: }
764: } else {
765: // Process subfolders of the given mailbox
766: $expectedPrefix = $mailbox . $delim;
767: if (strpos($currentFolder, $expectedPrefix) === 0) {
768: $remainingPath = substr($currentFolder, strlen($expectedPrefix));
769: // Include only direct subfolders (no further delimiters in the remaining path)
770: if (strpos($remainingPath, $delim) === false) {
771: $processedResult[] = $entry;
772: }
773: }
774: }
775: } else {
776: $processedResult[] = $entry;
777: }
778: }
779: return $processedResult;
780: }
781:
782: /**
783: * Sort a folder list with the inbox at the top
784: */
785: function fsort($a, $b) {
786: if (mb_strtolower($a) == 'inbox') {
787: return -1;
788: }
789: if (mb_strtolower($b) == 'inbox') {
790: return 1;
791: }
792: return strcasecmp($a, $b);
793: }
794:
795: /**
796: * get IMAP folder namespaces
797: * @return array list of available namespace details
798: */
799: public function get_namespaces() {
800: if (!$this->is_supported('NAMESPACE')) {
801: return array(array(
802: 'prefix' => $this->default_prefix,
803: 'delim' => $this->default_delimiter,
804: 'class' => 'personal'
805: ));
806: }
807: $data = array();
808: $command = "NAMESPACE\r\n";
809: $cache = $this->check_cache($command);
810: if ($cache !== false) {
811: return $cache;
812: }
813: $this->send_command("NAMESPACE\r\n");
814: $res = $this->get_response();
815: $this->namespace_count = 0;
816: $status = $this->check_response($res);
817: if ($status) {
818: if (preg_match("/\* namespace (\(.+\)|NIL) (\(.+\)|NIL) (\(.+\)|NIL)/i", $res[0], $matches)) {
819: $classes = array(1 => 'personal', 2 => 'other_users', 3 => 'shared');
820: foreach ($classes as $i => $v) {
821: if (trim(mb_strtoupper($matches[$i])) == 'NIL') {
822: continue;
823: }
824: $list = str_replace(') (', '),(', mb_substr($matches[$i], 1, -1));
825: $prefix = '';
826: $delim = '';
827: foreach (explode(',', $list) as $val) {
828: $val = trim($val, ")(\r\n ");
829: if (mb_strlen($val) == 1) {
830: $delim = $val;
831: $prefix = '';
832: }
833: else {
834: $delim = mb_substr($val, -1);
835: $prefix = trim(mb_substr($val, 0, -1));
836: }
837: $this->namespace_count++;
838: $data[] = array('delim' => $delim, 'prefix' => $prefix, 'class' => $v);
839: }
840: }
841: }
842: return $this->cache_return_val($data, $command);
843: }
844: return $data;
845: }
846:
847: /**
848: * select a mailbox
849: * @param string $mailbox the mailbox to attempt to select
850: */
851: public function select_mailbox($mailbox) {
852: if (isset($this->selected_mailbox['name']) && $this->selected_mailbox['name'] == $mailbox) {
853: return $this->poll();
854: }
855: $this->folder_state = $this->get_mailbox_status($mailbox);
856: $box = $this->utf7_encode(str_replace('"', '\"', $mailbox));
857: if (!$this->is_clean($box, 'mailbox')) {
858: return false;
859: }
860: if (!$this->read_only) {
861: $command = "SELECT \"$box\"";
862: }
863: else {
864: $command = "EXAMINE \"$box\"";
865: }
866: if ($this->is_supported('QRESYNC')) {
867: $command .= $this->build_qresync_params();
868: }
869: elseif ($this->is_supported('CONDSTORE')) {
870: $command .= ' (CONDSTORE)';
871: }
872: $cached_state = $this->check_cache($command);
873: $this->send_command($command."\r\n");
874: $res = $this->get_response(false, true);
875: $status = $this->check_response($res, true);
876: $result = array();
877: if ($status) {
878: list($qresync, $attributes) = $this->parse_untagged_responses($res);
879: if (!$qresync) {
880: $this->check_mailbox_state_change($attributes, $cached_state, $mailbox);
881: }
882: else {
883: $this->debug[] = sprintf('Cache bust avoided on %s with QRESYNC!', $this->selected_mailbox['name']);
884: }
885: $result = array(
886: 'selected' => $status,
887: 'uidvalidity' => $attributes['uidvalidity'],
888: 'exists' => $attributes['exists'],
889: 'first_unseen' => $attributes['unseen'],
890: 'uidnext' => $attributes['uidnext'],
891: 'flags' => $attributes['flags'],
892: 'permanentflags' => $attributes['pflags'],
893: 'recent' => $attributes['recent'],
894: 'nomodseq' => $attributes['nomodseq'],
895: 'modseq' => $attributes['modseq'],
896: );
897: $this->state = 'selected';
898: $this->selected_mailbox = array('name' => $box, 'detail' => $result);
899: return $this->cache_return_val($result, $command);
900:
901: }
902: return $result;
903: }
904:
905: /**
906: * issue IMAP status command on a mailbox
907: * @param string $mailbox IMAP mailbox to check
908: * @param array $args list of properties to fetch
909: * @return array list of attribute values discovered
910: */
911: public function get_mailbox_status($mailbox, $args=array('UNSEEN', 'UIDVALIDITY', 'UIDNEXT', 'MESSAGES', 'RECENT')) {
912: $command = 'STATUS "'.$this->utf7_encode($mailbox).'" ('.implode(' ', $args).")\r\n";
913: $this->send_command($command);
914: $attributes = array();
915: $response = $this->get_response(false, true);
916: if ($this->check_response($response, true)) {
917: $attributes = $this->parse_status_response($response);
918: $this->check_mailbox_state_change($attributes);
919: $attributes['id'] = $mailbox;
920: }
921: return $attributes;
922: }
923:
924: /**
925: * Subscribe/Unsubscribe folder
926: * @param string $mailbox IMAP mailbox to check
927: * @param string $action boolean
928: * @return boolean failure or success
929: */
930: public function mailbox_subscription($mailbox, $action) {
931: $command = ($action? 'SUBSCRIBE': 'UNSUBSCRIBE').' "'.$this->utf7_encode($mailbox).'"'."\r\n";
932: $this->send_command($command);
933: $response = $this->get_response(false, true);
934: return $this->check_response($response, true);
935: }
936:
937: /* ------------------ SELECTED STATE COMMANDS -------------------------- */
938:
939: /**
940: * use IMAP NOOP to poll for untagged server messages
941: * @return bool
942: */
943: public function poll() {
944: $command = "NOOP\r\n";
945: $this->send_command($command);
946: $res = $this->get_response(false, true);
947: if ($this->check_response($res, true)) {
948: list($qresync, $attributes) = $this->parse_untagged_responses($res);
949: if (!$qresync) {
950: $this->check_mailbox_state_change($attributes);
951: }
952: else {
953: $this->debug[] = sprintf('Cache bust avoided on %s with QRESYNC!', $this->selected_mailbox['name']);
954: }
955: return true;
956: }
957: return false;
958: }
959:
960: /**
961: * return a header list for the supplied message uids
962: * @todo refactor. abstract header line continuation parsing for re-use
963: * @param mixed $uids an array of uids or a valid IMAP sequence set as a string
964: * @param bool $raw flag to disable decoding header values
965: * @return array list of headers and values for the specified uids
966: */
967: public function get_message_list($uids, $raw=false, $include_content_body = false) {
968: if (is_array($uids)) {
969: sort($uids);
970: $sorted_string = implode(',', array_filter($uids));
971: }
972: else {
973: $sorted_string = $uids;
974: }
975: if (!$this->is_clean($sorted_string, 'uid_list')) {
976: return array();
977: }
978: $command = 'UID FETCH '.$sorted_string.' (FLAGS INTERNALDATE RFC822.SIZE ';
979: if ($this->is_supported( 'X-GM-EXT-1' )) {
980: $command .= 'X-GM-MSGID X-GM-THRID X-GM-LABELS ';
981: }
982: $command .= "BODY.PEEK[HEADER.FIELDS (SUBJECT X-AUTO-BCC FROM DATE CONTENT-TYPE X-PRIORITY TO LIST-ARCHIVE REFERENCES MESSAGE-ID IN-REPLY-TO X-SNOOZED X-SCHEDULE X-PROFILE-ID X-DELIVERY)]";
983: if ($include_content_body) {
984: $command .= " BODY.PEEK[TEXT]<0.500>";
985: }
986: $command .= ")\r\n";
987: $cache_command = $command.(string)$raw;
988: $cache = $this->check_cache($cache_command);
989: if ($cache !== false) {
990: return $cache;
991: }
992: $this->send_command($command);
993: $res = $this->get_response(false, true);
994: $status = $this->check_response($res, true);
995: $tags = array('X-GM-MSGID' => 'google_msg_id', 'X-GM-THRID' => 'google_thread_id', 'X-GM-LABELS' => 'google_labels', 'UID' => 'uid', 'FLAGS' => 'flags', 'RFC822.SIZE' => 'size', 'INTERNALDATE' => 'internal_date');
996: $junk = array('X-AUTO-BCC', 'MESSAGE-ID', 'IN-REPLY-TO', 'REFERENCES', 'X-SNOOZED', 'X-SCHEDULE', 'X-PROFILE-ID', 'X-DELIVERY', 'LIST-ARCHIVE', 'SUBJECT', 'FROM', 'CONTENT-TYPE', 'TO', '(', ')', ']', 'X-PRIORITY', 'DATE');
997: $flds = array('x-auto-bcc' => 'x_auto_bcc', 'message-id' => 'message_id', 'in-reply-to' => 'in_reply_to', 'references' => 'references', 'x-snoozed' => 'x_snoozed', 'x-schedule' => 'x_schedule', 'x-profile-id' => 'x_profile_id', 'x-delivery' => 'x_delivery', 'list-archive' => 'list_archive', 'date' => 'date', 'from' => 'from', 'to' => 'to', 'subject' => 'subject', 'content-type' => 'content_type', 'x-priority' => 'x_priority', 'body' => 'content_body');
998: $headers = array();
999:
1000: foreach ($res as $n => $vals) {
1001: if (isset($vals[0]) && $vals[0] == '*') {
1002: $uid = 0;
1003: $size = 0;
1004: $subject = '';
1005: $list_archive = '';
1006: $from = '';
1007: $references = '';
1008: $date = '';
1009: $message_id = '';
1010: $in_reply_to = '';
1011: $x_priority = 0;
1012: $content_type = '';
1013: $to = '';
1014: $flags = '';
1015: $internal_date = '';
1016: $google_msg_id = '';
1017: $google_thread_id = '';
1018: $google_labels = '';
1019: $x_auto_bcc = '';
1020: $x_snoozed = '';
1021: $x_schedule = '';
1022: $x_profile_id = '';
1023: $x_delivery = '';
1024: $count = count($vals);
1025: $header_founded = false;
1026: $body_founded = false;
1027: for ($i=0;$i<$count;$i++) {
1028: if ($vals[$i] == 'BODY[HEADER.FIELDS' && !$header_founded) {
1029: $header_founded = true;
1030: $i++;
1031: while(isset($vals[$i]) && in_array(mb_strtoupper($vals[$i]), $junk)) {
1032: $i++;
1033: }
1034: $last_header = false;
1035: $lines = explode("\r\n", $vals[$i]);
1036: foreach ($lines as $line) {
1037: $header = mb_strtolower(mb_substr($line, 0, mb_strpos($line, ':')));
1038: if ($last_header && (!$header || !isset($flds[$header]))) {
1039: ${$flds[$last_header]} .= str_replace("\t", " ", $line);
1040: }
1041: elseif (isset($flds[$header])) {
1042: ${$flds[$header]} = mb_substr($line, (mb_strpos($line, ':') + 1));
1043: $last_header = $header;
1044: }
1045: }
1046: }
1047: elseif ($vals[$i] == 'BODY[TEXT' && !$body_founded) {
1048: $body_founded = true;
1049: $content = '';
1050: $i++;
1051: $i++;
1052: while(isset($vals[$i]) && $vals[$i] != ')') {
1053: $content .= $vals[$i];
1054: $i++;
1055: }
1056: $i++;
1057: if (! empty($content)) {
1058: if (substr($content, 0, 3) == "<0>") {
1059: $content = substr($content, 3);
1060: }
1061: $parser = new MailMimeParser();
1062: $str_parser = $parser->parse($content, false);
1063: $content = $str_parser->getTextContent();
1064: }
1065: $flds['body'] = $content;
1066: if (!$header_founded) {
1067: $i = 0;
1068: }
1069: }
1070: elseif (isset($tags[mb_strtoupper($vals[$i])])) {
1071: if (isset($vals[($i + 1)])) {
1072: if (($tags[mb_strtoupper($vals[$i])] == 'flags' || $tags[mb_strtoupper($vals[$i])] == 'google_labels' ) && $vals[$i + 1] == '(') {
1073: $n = 2;
1074: while (isset($vals[$i + $n]) && $vals[$i + $n] != ')') {
1075: ${$tags[mb_strtoupper($vals[$i])]} .= $vals[$i + $n];
1076: $n++;
1077: }
1078: $i += $n;
1079: }
1080: else {
1081: ${$tags[mb_strtoupper($vals[$i])]} = $vals[($i + 1)];
1082: $i++;
1083: }
1084: }
1085: }
1086: }
1087: if ($uid) {
1088: $cset = '';
1089: if (mb_stristr($content_type, 'charset=')) {
1090: if (preg_match("/charset\=([^\s;]+)/", $content_type, $matches)) {
1091: $cset = trim(mb_strtolower(str_replace(array('"', "'"), '', $matches[1])));
1092: }
1093: }
1094: $headers[(string) $uid] = array('uid' => $uid, 'flags' => $flags, 'internal_date' => $internal_date, 'size' => $size,
1095: 'date' => $date, 'from' => $from, 'to' => $to, 'subject' => $subject, 'content-type' => $content_type,
1096: 'timestamp' => time(), 'charset' => $cset, 'x-priority' => $x_priority, 'google_msg_id' => $google_msg_id,
1097: 'google_thread_id' => $google_thread_id, 'google_labels' => $google_labels, 'list_archive' => $list_archive,
1098: 'references' => $references, 'message_id' => $message_id, 'in_reply_to' => $in_reply_to, 'x_auto_bcc' => $x_auto_bcc,
1099: 'x_snoozed' => $x_snoozed, 'x_schedule' => $x_schedule, 'x_profile_id' => $x_profile_id, 'x_delivery' => $x_delivery);
1100: $headers[$uid]['preview_msg'] = $flds['body'] != "content_body" ? $flds['body'] : "";
1101:
1102: if ($raw) {
1103: $headers[$uid] = array_map('trim', $headers[$uid]);
1104: }
1105: else {
1106: $headers[$uid] = array_map(array($this, 'decode_fld'), $headers[$uid]);
1107: }
1108:
1109:
1110: }
1111: }
1112: }
1113: if ($status) {
1114: return $this->cache_return_val($headers, $cache_command);
1115: }
1116: else {
1117: return $headers;
1118: }
1119: }
1120:
1121: /**
1122: * get the IMAP BODYSTRUCTURE of a message
1123: * @param int $uid IMAP UID of the message
1124: * @return array message structure represented as a nested array
1125: */
1126: public function get_message_structure($uid) {
1127: $result = $this->get_raw_bodystructure($uid);
1128: if (count($result) == 0) {
1129: return $result;
1130: }
1131: $struct = $this->parse_bodystructure_response($result);
1132: return $struct;
1133: }
1134:
1135: /**
1136: * This function maps raw IMAP rights to human-readable permissions.
1137: *
1138: * It takes the raw IMAP rights string, such as 'lrws' and converts it into a more
1139: * understandable format like 'Lookup, Read, Write, Write Seen'.
1140: *
1141: * @param string $rights The string of IMAP rights (e.g., 'lrws'), where each character
1142: * represents a specific permission.
1143: *
1144: * @return string A comma-separated string of human-readable permissions.
1145: */
1146: private function map_permissions($rights_string) {
1147: $permissions = [];
1148: $permission_map = [
1149: 'l' => 'Lookup',
1150: 'r' => 'Read',
1151: 's' => 'See',
1152: 'w' => 'Write',
1153: 'i' => 'Insert',
1154: 'p' => 'Post',
1155: 'k' => 'Administer',
1156: 'x' => 'Delete Mailbox',
1157: 't' => 'Take',
1158: 'e' => 'Examine',
1159: 'c' => 'Create',
1160: 'd' => 'Delete'
1161: ];
1162:
1163: foreach (str_split($rights_string) as $char) {
1164: if (isset($permission_map[$char])) {
1165: $permissions[] = $permission_map[$char];
1166: }
1167: }
1168:
1169: return implode(', ', $permissions);
1170: }
1171:
1172: /**
1173: * get the raw IMAP BODYSTRUCTURE response
1174: * @param int $uid IMAP UID of the message
1175: * @return array low-level parsed message structure
1176: */
1177: private function get_raw_bodystructure($uid) {
1178: if (!$this->is_clean($uid, 'uid')) {
1179: return array();
1180: }
1181: $part_num = 1;
1182: $struct = array();
1183: $command = "UID FETCH $uid BODYSTRUCTURE\r\n";
1184: $cache = $this->check_cache($command);
1185: if ($cache !== false) {
1186: return $cache;
1187: }
1188: $this->send_command($command);
1189: $result = $this->get_response(false, true);
1190: while (isset($result[0][0]) && isset($result[0][1]) && $result[0][0] == '*' && mb_strtoupper($result[0][1]) == 'OK') {
1191: array_shift($result);
1192: }
1193: $status = $this->check_response($result, true);
1194: if (!isset($result[0][4])) {
1195: $status = false;
1196: }
1197: if ($status) {
1198: return $this->cache_return_val($result, $command);
1199: }
1200: return $result;
1201: }
1202:
1203: /**
1204: * New BODYSTRUCTURE parsing routine
1205: * @param array $result low-level IMAP response
1206: * @return array
1207: */
1208: private function parse_bodystructure_response($result) {
1209: $response = array();
1210: if (array_key_exists(6, $result[0]) && mb_strtoupper($result[0][6]) == 'MODSEQ') {
1211: $response = array_slice($result[0], 11, -1);
1212: }
1213: elseif (array_key_exists(4, $result[0]) && mb_strtoupper($result[0][4]) == 'UID') {
1214: $response = array_slice($result[0], 7, -1);
1215: }
1216: else {
1217: $response = array_slice($result[0], 5, -1);
1218: }
1219:
1220: $this->struct_object = new Hm_IMAP_Struct($response, $this);
1221: $struct = $this->struct_object->data();
1222: return $struct;
1223: }
1224:
1225: /**
1226: * get content for a message part
1227: * @param int $uid a single IMAP message UID
1228: * @param string $message_part the IMAP message part number
1229: * @param int $max maximum read length to allow.
1230: * @param mixed $struct a message part structure array for decoding and
1231: * @return string message content
1232: */
1233: public function get_message_content($uid, $message_part, $max=false, $struct=true) {
1234: $message_part = preg_replace("/^0\.{1}/", '', $message_part);
1235: if (!$this->is_clean($uid, 'uid')) {
1236: return '';
1237: }
1238: if ($message_part == 0) {
1239: $command = "UID FETCH $uid BODY" . "[]\r\n";
1240: }
1241: else {
1242: if (!$this->is_clean($message_part, 'msg_part')) {
1243: return '';
1244: }
1245: $command = "UID FETCH $uid BODY" . "[$message_part]\r\n";
1246: }
1247: $cache_command = $command.(string)$max;
1248: if ($struct) {
1249: $cache_command .= '1';
1250: }
1251: $cache = $this->check_cache($cache_command);
1252: if ($cache !== false) {
1253: return $cache;
1254: }
1255: $this->send_command($command);
1256: $result = $this->get_response($max, true);
1257: $status = $this->check_response($result, true);
1258: $res = '';
1259: foreach ($result as $vals) {
1260: if ($vals[0] != '*') {
1261: continue;
1262: }
1263: $search = true;
1264: foreach ($vals as $v) {
1265: if ($v != ']' && !$search) {
1266: if ($v == 'NIL') {
1267: $res = '';
1268: break 2;
1269: }
1270: $res = trim(preg_replace("/\s*\)$/", '', $v));
1271: break 2;
1272: }
1273: if (mb_stristr(mb_strtoupper($v), 'BODY')) {
1274: $search = false;
1275: }
1276: }
1277: }
1278: if ($struct === true) {
1279: $full_struct = $this->get_message_structure($uid);
1280: $part_struct = $this->search_bodystructure( $full_struct, array('imap_part_number' => $message_part));
1281: if (isset($part_struct[$message_part])) {
1282: $struct = $part_struct[$message_part];
1283: }
1284: }
1285: if (is_array($struct)) {
1286: if (isset($struct['encoding']) && $struct['encoding']) {
1287: if (mb_strtolower($struct['encoding']) == 'quoted-printable') {
1288: $res = quoted_printable_decode($res);
1289: }
1290: elseif (mb_strtolower($struct['encoding']) == 'base64') {
1291: $res = base64_decode($res);
1292: }
1293: }
1294: if (isset($struct['attributes']['charset']) && $struct['attributes']['charset']) {
1295: if ($struct['attributes']['charset'] != 'us-ascii') {
1296: $res = mb_convert_encoding($res, 'UTF-8', $struct['attributes']['charset']);
1297: }
1298: }
1299: }
1300: if ($status) {
1301: return $this->cache_return_val($res, $cache_command);
1302: }
1303: return $res;
1304: }
1305:
1306: /**
1307: * use IMAP SEARCH or ESEARCH
1308: * @param string $target message types to search. can be ALL, UNSEEN, ANSWERED, etc
1309: * @param mixed $uids an array of uids or a valid IMAP sequence set as a string (or false for ALL)
1310: * @param string $fld optional field to search
1311: * @param string $term optional search term
1312: * @param bool $exclude_deleted extra argument to exclude messages with the deleted flag
1313: * @param bool $exclude_auto_bcc don't include auto-bcc'ed messages
1314: * @param bool $only_auto_bcc only include auto-bcc'ed messages
1315: * @return array list of IMAP message UIDs that match the search
1316: */
1317: public function search($target='ALL', $uids=false, $terms=array(), $esearch=array(), $exclude_deleted=true, $exclude_auto_bcc=true, $only_auto_bcc=false) {
1318: if (!$this->is_clean($this->search_charset, 'charset')) {
1319: return array();
1320: }
1321: if (is_array($target)) {
1322: foreach ($target as $val) {
1323: if (!$this->is_clean($val, 'keyword')) {
1324: return array();
1325: }
1326: }
1327: $target = implode(' ', $target);
1328: }
1329: elseif (!$this->is_clean($target, 'keyword')) {
1330: return array();
1331: }
1332: if (!empty($terms)) {
1333: foreach ($terms as $vals) {
1334: if (!$this->is_clean($vals[0], 'search_str') || !$this->is_clean($vals[1], 'search_str')) {
1335: return array();
1336: }
1337: }
1338: }
1339: $original_uids_order = $uids;
1340: if (!empty($uids)) {
1341: if (is_array($uids)) {
1342: $uids = implode(',', $uids);
1343: }
1344: if (!$this->is_clean($uids, 'uid_list')) {
1345: return array();
1346: }
1347: $uids = 'UID '.$uids;
1348: }
1349: else {
1350: $uids = 'ALL';
1351: }
1352: if ($this->search_charset) {
1353: $charset = 'CHARSET '.mb_strtoupper($this->search_charset).' ';
1354: }
1355: else {
1356: $charset = '';
1357: }
1358: if (!empty($terms)) {
1359: $flds = array();
1360: foreach ($terms as $vals) {
1361: if (mb_substr($vals[1], 0, 4) == 'NOT ') {
1362: $flds[] = 'NOT '.$vals[0].' "'.str_replace('"', '\"', mb_substr($vals[1], 4)).'"';
1363: }
1364: else {
1365: $flds[] = $vals[0].' "'.str_replace('"', '\"', $vals[1]).'"';
1366: }
1367: }
1368: $fld = ' '.implode(' ', $flds);
1369: }
1370: else {
1371: $fld = '';
1372: }
1373: if ($exclude_deleted) {
1374: $fld .= ' NOT DELETED';
1375: }
1376: if ($only_auto_bcc) {
1377: $fld .= ' HEADER X-Auto-Bcc cypht';
1378: }
1379: if ($exclude_auto_bcc && !mb_strstr($this->server, 'yahoo') && $this->server_supports_custom_headers()) {
1380: $fld .= ' NOT HEADER X-Auto-Bcc cypht';
1381: }
1382: $esearch_enabled = false;
1383: $command = 'UID SEARCH ';
1384: if (!empty($esearch) && $this->is_supported('ESEARCH')) {
1385: $valid = array_filter($esearch, function($v) { return in_array($v, array('MIN', 'MAX', 'COUNT', 'ALL')); });
1386: if (!empty($valid)) {
1387: $esearch_enabled = true;
1388: $command .= 'RETURN ('.implode(' ', $valid).') ';
1389: }
1390: }
1391: $cache_command = $command.$charset.'('.$target.') '.$uids.$fld."\r\n";
1392: $cache = $this->check_cache($cache_command);
1393: if ($cache !== false) {
1394: return $cache;
1395: }
1396: $command .= $charset.'('.$target.') '.$uids.$fld."\r\n";
1397: $this->send_command($command);
1398: $result = $this->get_response(false, true);
1399: $status = $this->check_response($result, true);
1400: $res = array();
1401: $esearch_res = array();
1402: if ($status) {
1403: array_pop($result);
1404: foreach ($result as $vals) {
1405: if (in_array('ESEARCH', $vals)) {
1406: $esearch_res = $this->parse_esearch_response($vals);
1407: continue;
1408: }
1409: elseif (in_array('SEARCH', $vals)) {
1410: foreach ($vals as $v) {
1411: if (ctype_digit((string) $v)) {
1412: $res[] = $v;
1413: }
1414: }
1415: }
1416: }
1417: if ($esearch_enabled) {
1418: $res = $esearch_res;
1419: }
1420: // keep original sort order of UIDS as fetch command might not return in requested order
1421: // this is needed for pagination to work
1422: if (! empty($original_uids_order)) {
1423: $unordered = $res;
1424: $res = [];
1425: foreach ($original_uids_order as $uid) {
1426: if (in_array($uid, $unordered)) {
1427: $res[] = $uid;
1428: }
1429: }
1430: }
1431: return $this->cache_return_val($res, $cache_command);
1432: }
1433: return $res;
1434: }
1435:
1436: /**
1437: * get the headers for the selected message
1438: * @param int $uid IMAP message UID
1439: * @param string $message_part IMAP message part number
1440: * @return array associate array of message headers
1441: */
1442: public function get_message_headers($uid, $message_part=false, $raw=false) {
1443: if (!$this->is_clean($uid, 'uid')) {
1444: return array();
1445: }
1446: if ($message_part == 1 || !$message_part) {
1447: $command = "UID FETCH $uid (FLAGS INTERNALDATE BODY[HEADER])\r\n";
1448: }
1449: else {
1450: if (!$this->is_clean($message_part, 'msg_part')) {
1451: return array();
1452: }
1453: $command = "UID FETCH $uid (FLAGS INTERNALDATE BODY[$message_part.HEADER])\r\n";
1454: }
1455: $cache_command = $command.(string)$raw;
1456: $cache = $this->check_cache($cache_command);
1457: if ($cache !== false) {
1458: return $cache;
1459: }
1460: $this->send_command($command);
1461: $result = $this->get_response(false, true);
1462: $status = $this->check_response($result, true);
1463: $headers = array();
1464: $flags = array();
1465: $internal_date = '';
1466: if ($status) {
1467: foreach ($result as $vals) {
1468: if ($vals[0] != '*') {
1469: continue;
1470: }
1471: $search = true;
1472: $flag_search = false;
1473: for ($j = 0; $j < count($vals); $j++) {
1474: $v = $vals[$j];
1475: if (mb_stristr(mb_strtoupper($v), 'INTERNALDATE')) {
1476: $internal_date = $vals[$j+1];
1477: $j++;
1478: continue;
1479: }
1480: if ($flag_search) {
1481: if ($v == ')') {
1482: $flag_search = false;
1483: }
1484: elseif ($v == '(') {
1485: continue;
1486: }
1487: else {
1488: $flags[] = $v;
1489: }
1490: }
1491: elseif ($v != ']' && !$search) {
1492: $v = preg_replace("/(?!\r)\n/", "\r\n", $v);
1493: $parts = explode("\r\n", $v);
1494: if (is_array($parts) && !empty($parts)) {
1495: $i = 0;
1496: foreach ($parts as $line) {
1497: $split = mb_strpos($line, ':');
1498: if (preg_match("/^from /i", $line)) {
1499: continue;
1500: }
1501: if (isset($headers[$i]) && trim($line) && ($line[0] == "\t" || $line[0] == ' ')) {
1502: $headers[$i][1] .= str_replace("\t", " ", $line);
1503: }
1504: elseif ($split) {
1505: $i++;
1506: $last = mb_substr($line, 0, $split);
1507: $headers[$i] = array($last, trim(mb_substr($line, ($split + 1))));
1508: }
1509: }
1510: }
1511: break;
1512: }
1513: if (mb_stristr(mb_strtoupper($v), 'BODY')) {
1514: $search = false;
1515: }
1516: elseif (mb_stristr(mb_strtoupper($v), 'FLAGS')) {
1517: $flag_search = true;
1518: }
1519: }
1520: }
1521: if (!empty($flags)) {
1522: $headers[] = array('Flags', implode(' ', $flags));
1523: }
1524: if (!empty($internal_date)) {
1525: $headers[] = array('Arrival Date', $internal_date);
1526: }
1527: }
1528: $results = array();
1529: foreach ($headers as $vals) {
1530: if (!$raw) {
1531: $vals[1] = $this->decode_fld($vals[1]);
1532: }
1533: if (array_key_exists($vals[0], $results)) {
1534: if (!is_array($results[$vals[0]])) {
1535: $results[$vals[0]] = array($results[$vals[0]]);
1536: }
1537: $results[$vals[0]][] = $vals[1];
1538: }
1539: else {
1540: $results[$vals[0]] = $vals[1];
1541: }
1542: }
1543: if ($flags && is_array($results['Flags'])) {
1544: $results['Flags'] = array_unique($results['Flags']);
1545: $results['Flags'] = implode(' ', $results['Flags']);
1546: }
1547: if ($status) {
1548: return $this->cache_return_val($results, $cache_command);
1549: }
1550: return $results;
1551: }
1552:
1553: /**
1554: * start streaming a message part. returns the number of characters in the message
1555: * @param int $uid IMAP message UID
1556: * @param string $message_part IMAP message part number
1557: * @return int the size of the message queued up to stream
1558: */
1559: public function start_message_stream($uid, $message_part) {
1560: if (!$this->is_clean($uid, 'uid')) {
1561: return false;
1562: }
1563: if ($message_part == 0) {
1564: $command = "UID FETCH $uid BODY[]\r\n";
1565: }
1566: else {
1567: if (!$this->is_clean($message_part, 'msg_part')) {
1568: return false;
1569: }
1570: $command = "UID FETCH $uid BODY[$message_part]\r\n";
1571: }
1572: $this->send_command($command);
1573: $result = $this->fgets(1024);
1574: $size = false;
1575: if (preg_match("/\{(\d+)\}\r\n/", $result, $matches)) {
1576: $size = $matches[1];
1577: $this->stream_size = $size;
1578: $this->current_stream_size = 0;
1579: }
1580: return $size;
1581: }
1582:
1583: /**
1584: * read a line from a message stream. Called until it returns
1585: * false will "stream" a message part content one line at a time.
1586: * useful for avoiding memory consumption when dealing with large
1587: * attachments
1588: * @param int $size chunk size to read using fgets
1589: * @return string chunk of the streamed message
1590: */
1591: public function read_stream_line($size=1024) {
1592: if ($this->stream_size) {
1593: $res = $this->fgets(1024);
1594: while(mb_substr($res, -2) != "\r\n") {
1595: $res .= $this->fgets($size);
1596: }
1597: if ($res && $this->check_response(array($res), false, false)) {
1598: $res = false;
1599: }
1600: if ($res) {
1601: $this->current_stream_size += mb_strlen($res);
1602: }
1603: if ($this->current_stream_size >= $this->stream_size) {
1604: $this->stream_size = 0;
1605: }
1606: }
1607: else {
1608: $res = false;
1609: }
1610: return $res;
1611: }
1612:
1613: /**
1614: * use FETCH to sort a list of uids when SORT is not available
1615: * @param string $sort the sort field
1616: * @param bool $reverse flag to reverse the results
1617: * @param string $filter IMAP message type (UNSEEN, ANSWERED, DELETED, etc)
1618: * @param string $uid_str IMAP sequence set string or false
1619: * @return array list of UIDs in the sort order
1620: */
1621: public function sort_by_fetch($sort, $reverse, $filter, $uid_str=false) {
1622: if (!$this->is_clean($sort, 'keyword')) {
1623: return false;
1624: }
1625: if ($uid_str) {
1626: $command1 = 'UID FETCH '.$uid_str.' (FLAGS ';
1627: }
1628: else {
1629: $command1 = 'UID FETCH 1:* (FLAGS ';
1630: }
1631: switch ($sort) {
1632: case 'DATE':
1633: $command2 = "BODY.PEEK[HEADER.FIELDS (DATE)])\r\n";
1634: $key = "BODY[HEADER.FIELDS";
1635: break;
1636: case 'SIZE':
1637: $command2 = "RFC822.SIZE)\r\n";
1638: $key = "RFC822.SIZE";
1639: break;
1640: case 'TO':
1641: $command2 = "BODY.PEEK[HEADER.FIELDS (TO)])\r\n";
1642: $key = "BODY[HEADER.FIELDS";
1643: break;
1644: case 'CC':
1645: $command2 = "BODY.PEEK[HEADER.FIELDS (CC)])\r\n";
1646: $key = "BODY[HEADER.FIELDS";
1647: break;
1648: case 'FROM':
1649: $command2 = "BODY.PEEK[HEADER.FIELDS (FROM)])\r\n";
1650: $key = "BODY[HEADER.FIELDS";
1651: break;
1652: case 'SUBJECT':
1653: $command2 = "BODY.PEEK[HEADER.FIELDS (SUBJECT)])\r\n";
1654: $key = "BODY[HEADER.FIELDS";
1655: break;
1656: case 'ARRIVAL':
1657: default:
1658: $command2 = "INTERNALDATE)\r\n";
1659: $key = "INTERNALDATE";
1660: break;
1661: }
1662: $command = $command1.$command2;
1663: $cache_command = $command.(string)$reverse;
1664: $this->set_fetch_command($cache_command);
1665: $cache = $this->check_cache($cache_command);
1666: if ($cache !== false) {
1667: return $cache;
1668: }
1669: $this->send_command($command);
1670: $res = $this->get_response(false, true);
1671: $status = $this->check_response($res, true);
1672: $uids = array();
1673: $sort_keys = array();
1674: foreach ($res as $vals) {
1675: if (!isset($vals[0]) || $vals[0] != '*') {
1676: continue;
1677: }
1678: $uid = 0;
1679: $sort_key = 0;
1680: $body = false;
1681: foreach ($vals as $i => $v) {
1682: if ($body) {
1683: if ($v == ']' && isset($vals[$i + 1])) {
1684: if ($command2 == "BODY.PEEK[HEADER.FIELDS (DATE)])\r\n") {
1685: $sort_key = strtotime(trim(mb_substr($vals[$i + 1], 5)));
1686: }
1687: else {
1688: $sort_key = $vals[$i + 1];
1689: }
1690: $body = false;
1691: }
1692: }
1693: if (mb_strtoupper($v) == 'FLAGS') {
1694: $index = $i + 2;
1695: $flag_string = '';
1696: while (isset($vals[$index]) && $vals[$index] != ')') {
1697: $flag_string .= $vals[$index];
1698: $index++;
1699: }
1700: if ($filter && $filter != 'ALL' && !$this->flag_match($filter, $flag_string)) {
1701: continue 2;
1702: }
1703: }
1704: if (mb_strtoupper($v) == 'UID') {
1705: if (isset($vals[($i + 1)])) {
1706: $uid = $vals[$i + 1];
1707: }
1708: }
1709: if ($key == mb_strtoupper($v)) {
1710: if (mb_substr($key, 0, 4) == 'BODY') {
1711: $body = 1;
1712: }
1713: elseif (isset($vals[($i + 1)])) {
1714: if ($key == "INTERNALDATE") {
1715: $sort_key = strtotime($vals[$i + 1]);
1716: }
1717: else {
1718: $sort_key = $vals[$i + 1];
1719: }
1720: }
1721: }
1722: }
1723: if ($sort_key && $uid) {
1724: $sort_keys[$uid] = $sort_key;
1725: $uids[] = $uid;
1726: }
1727: }
1728: if (count($sort_keys) != count($uids)) {
1729: if (count($sort_keys) < count($uids)) {
1730: foreach ($uids as $v) {
1731: if (!isset($sort_keys[$v])) {
1732: $sort_keys[$v] = false;
1733: }
1734: }
1735: }
1736: }
1737: natcasesort($sort_keys);
1738: $uids = array_keys($sort_keys);
1739: if ($reverse) {
1740: $uids = array_reverse($uids);
1741: }
1742: if ($status) {
1743: return $this->cache_return_val($uids, $cache_command);
1744: }
1745: return $uids;
1746: }
1747:
1748: /* ------------------ WRITE COMMANDS ----------------------------------- */
1749:
1750: /**
1751: * delete an existing mailbox
1752: * @param string $mailbox IMAP mailbox name to delete
1753: *
1754: * @return bool tru if the mailbox was deleted
1755: */
1756: public function delete_mailbox($mailbox) {
1757: if (!$this->is_clean($mailbox, 'mailbox')) {
1758: return false;
1759: }
1760: if ($this->read_only) {
1761: $this->debug[] = 'Delete mailbox not permitted in read only mode';
1762: return false;
1763: }
1764: $this->list_sub_folders[] = $mailbox;
1765: $this->get_recursive_subfolders($mailbox, true);
1766: $this->list_sub_folders = array_reverse($this->list_sub_folders);
1767: foreach ($this->list_sub_folders as $key => $del_folder) {
1768: $command = 'DELETE "'.str_replace('"', '\"', $this->utf7_encode($del_folder))."\"\r\n";
1769: $this->send_command($command);
1770: $result = $this->get_response(false);
1771: $status = $this->check_response($result, false);
1772: if ($status) {
1773: unset($this->list_sub_folders[$key]);
1774: } else {
1775: $this->debug[] = str_replace('A'.$this->command_count, '', $result[0]);
1776: return false;
1777: }
1778: }
1779: return true;
1780: }
1781:
1782: public function get_recursive_subfolders($parentFolder, $is_delete_action) {
1783: $infoFolder = $this->get_folder_list_by_level($parentFolder, false, false, $is_delete_action);
1784: if ($infoFolder) {
1785: foreach (array_keys($infoFolder) as $folder) {
1786: $this->list_sub_folders[] = $folder;
1787: $this->get_recursive_subfolders($folder, $is_delete_action);
1788: }
1789: }
1790: }
1791:
1792: /**
1793: * rename and existing mailbox
1794: * @param string $mailbox IMAP mailbox to rename
1795: * @param string $new_mailbox new name for the mailbox
1796: * @return bool true if the rename operation worked
1797: */
1798: public function rename_mailbox($mailbox, $new_mailbox) {
1799: if (!$this->is_clean($mailbox, 'mailbox') || !$this->is_clean($new_mailbox, 'mailbox')) {
1800: return false;
1801: }
1802: if ($this->read_only) {
1803: $this->debug[] = 'Rename mailbox not permitted in read only mode';
1804: return false;
1805: }
1806: $command = 'RENAME "'.$this->utf7_encode($mailbox).'" "'.$this->utf7_encode($new_mailbox).'"'."\r\n";
1807: $this->send_command($command);
1808: $result = $this->get_response(false);
1809: $status = $this->check_response($result, false);
1810: if ($status) {
1811: return true;
1812: }
1813: else {
1814: $this->debug[] = str_replace('A'.$this->command_count, '', $result[0]);
1815: return false;
1816: }
1817: }
1818:
1819: /**
1820: * create a new mailbox
1821: * @param string $mailbox IMAP mailbox name
1822: * @return bool true if the mailbox was created
1823: */
1824: public function create_mailbox($mailbox) {
1825: if (!$this->is_clean($mailbox, 'mailbox')) {
1826: return false;
1827: }
1828: if ($this->read_only) {
1829: $this->debug[] = 'Create mailbox not permitted in read only mode';
1830: return false;
1831: }
1832: $command = 'CREATE "'.$this->utf7_encode($mailbox).'"'."\r\n";
1833: $this->send_command($command);
1834: $result = $this->get_response(false);
1835: $status = $this->check_response($result, false);
1836: if ($status) {
1837: return true;
1838: }
1839: else {
1840: $this->debug[] = str_replace('A'.$this->command_count, '', $result[0]);
1841: return false;
1842: }
1843: }
1844:
1845: /**
1846: * perform an IMAP action on a message
1847: * @param string $action action to perform, can be one of READ, UNREAD, FLAG,
1848: * UNFLAG, ANSWERED, DELETE, UNDELETE, EXPUNGE, or COPY
1849: * @param mixed $uids an array of uids or a valid IMAP sequence set as a string
1850: * @param string $mailbox destination IMAP mailbox name for operations the require one
1851: * @param string $keyword optional custom keyword flag
1852: */
1853: public function message_action($action, $uids, $mailbox=false, $keyword=false) {
1854: $status = false;
1855: $command = false;
1856: $uid_strings = [];
1857: $responses = [];
1858: $parseResponseFn = function($response) {};
1859: if (is_array($uids)) {
1860: if (count($uids) > 1000) {
1861: while (count($uids) > 1000) {
1862: $uid_strings[] = implode(',', array_splice($uids, 0, 1000));
1863: }
1864: if (count($uids)) {
1865: $uid_strings[] = implode(',', $uids);
1866: }
1867: }
1868: else {
1869: $uid_strings[] = implode(',', $uids);
1870: }
1871: }
1872: else {
1873: $uid_strings[] = $uids;
1874: }
1875: foreach ($uid_strings as $uid_string) {
1876: if ($uid_string) {
1877: if (!$this->is_clean($uid_string, 'uid_list')) {
1878: break;
1879: }
1880: }
1881: switch ($action) {
1882: case 'READ':
1883: $command = "UID STORE $uid_string +FLAGS (\Seen)\r\n";
1884: break;
1885: case 'ARCHIVE':
1886: $command = "UID STORE $uid_string +FLAGS (\Archive)\r\n";
1887: break;
1888: case 'JUNK':
1889: $command = "UID STORE $uid_string +FLAGS (\Junk)\r\n";
1890: break;
1891: case 'FLAG':
1892: $command = "UID STORE $uid_string +FLAGS (\Flagged)\r\n";
1893: break;
1894: case 'UNFLAG':
1895: $command = "UID STORE $uid_string -FLAGS (\Flagged)\r\n";
1896: break;
1897: case 'ANSWERED':
1898: $command = "UID STORE $uid_string +FLAGS (\Answered)\r\n";
1899: break;
1900: case 'UNREAD':
1901: $command = "UID STORE $uid_string -FLAGS (\Seen)\r\n";
1902: break;
1903: case 'DELETE':
1904: $command = "UID STORE $uid_string +FLAGS (\Deleted)\r\n";
1905: break;
1906: case 'UNDELETE':
1907: $command = "UID STORE $uid_string -FLAGS (\Deleted)\r\n";
1908: break;
1909: case 'CUSTOM':
1910: /* TODO: check permanentflags of the selected mailbox to
1911: * make sure custom keywords are supported */
1912: if ($keyword && $this->is_clean($keyword, 'mailbox')) {
1913: $command = "UID STORE $uid_string +FLAGS ($keyword)\r\n";
1914: }
1915: break;
1916: case 'EXPUNGE':
1917: $command = "EXPUNGE\r\n";
1918: break;
1919: case 'COPY':
1920: if (!$this->is_clean($mailbox, 'mailbox')) {
1921: break;
1922: }
1923: $command = "UID COPY $uid_string \"".$this->utf7_encode($mailbox)."\"\r\n";
1924: break;
1925: case 'MOVE':
1926: if (!$this->is_clean($mailbox, 'mailbox')) {
1927: break;
1928: }
1929:
1930: $parseResponseFn = function($response) use ($uid_string, &$responses) {
1931: if (strpos($uid_string, ',') !== false) {
1932: preg_match('/.*COPYUID \d+ (\d+[:|,]\d+) (\d+[:|,]\d+).*/', $response[0], $matches);
1933: $oldUids = preg_split('/[:|,]/', $matches[1]);
1934: $newUids = preg_split('/[:|,]/', $matches[2]);
1935: foreach ($oldUids as $key => $oldUid) {
1936: $responses[] = ['oldUid' => $oldUid, 'newUid' => $newUids[$key]];
1937: }
1938: } else {
1939: preg_match('/.*COPYUID \d+ (\d+) (\d+).*/', $response[0], $matches);
1940: $responses[] = ['oldUid' => $matches[1], 'newUid' => $matches[2]];
1941: }
1942: };
1943:
1944: if ($this->is_supported('MOVE')) {
1945: $command = "UID MOVE $uid_string \"".$this->utf7_encode($mailbox)."\"\r\n";
1946: }
1947: else {
1948: if ($this->message_action('COPY', $uids, $mailbox, $keyword)['status']) {
1949: if ($this->message_action('DELETE', $uids, $mailbox, $keyword)['status']) {
1950: $command = "EXPUNGE\r\n";
1951: }
1952: }
1953: }
1954: break;
1955: }
1956: if ($command) {
1957: $this->send_command($command);
1958: $res = $this->get_response();
1959: $status = $this->check_response($res);
1960: }
1961: if ($status) {
1962: $parseResponseFn($res);
1963: if (is_array($this->selected_mailbox)) {
1964: $this->bust_cache($this->selected_mailbox['name']);
1965: }
1966: if ($mailbox) {
1967: $this->bust_cache($mailbox);
1968: }
1969: }
1970: }
1971:
1972: return ['status' => $status, 'responses' => $responses];
1973: }
1974:
1975: /**
1976: * start writing a message to a folder with IMAP APPEND
1977: * @param string $mailbox IMAP mailbox name
1978: * @param int $size size of the message to be written
1979: * @param bool $seen flag to mark the message seen
1980: * $return bool true on success
1981: */
1982: public function append_start($mailbox, $size, $seen=true, $draft=false) {
1983: if (!$this->is_clean($mailbox, 'mailbox') || !$this->is_clean($size, 'uid')) {
1984: return false;
1985: }
1986: if ($seen) {
1987: $command = 'APPEND "'.$this->utf7_encode($mailbox).'" (\Seen) {'.$size."}\r\n";
1988: }
1989: else {
1990: $command = 'APPEND "'.$this->utf7_encode($mailbox).'" () {'.$size."}\r\n";
1991: }
1992: if ($draft) {
1993: $command = 'APPEND "'.$this->utf7_encode($mailbox).'" (\Draft) {'.$size."}\r\n";
1994: }
1995: $this->send_command($command);
1996: $result = $this->fgets();
1997: if (mb_substr($result, 0, 1) == '+') {
1998: return true;
1999: }
2000: else {
2001: return false;
2002: }
2003: }
2004:
2005: /**
2006: * write a line to an active IMAP APPEND operation
2007: * @param string $string line to write
2008: * @return int length written
2009: */
2010: public function append_feed($string) {
2011: return fputs($this->handle, $string);
2012: }
2013:
2014: /**
2015: * finish an IMAP APPEND operation
2016: * @return bool true on success
2017: */
2018: public function append_end() {
2019: $result = $this->get_response(false, true);
2020: if ($this->check_response($result, true)) {
2021: $res = preg_grep('/APPENDUID/', array_map('json_encode', $result));
2022: if ($res) {
2023: $line = json_decode(reset($res), true);
2024: return $line[5];
2025: }
2026: }
2027: return $result;
2028: }
2029:
2030: /* ------------------ HELPERS ------------------------------------------ */
2031:
2032: /**
2033: * convert a sequence string to an array
2034: * @param string $sequence an IMAP sequence string
2035: *
2036: * @return $array list of ids
2037: */
2038: public function convert_sequence_to_array($sequence) {
2039: $res = array();
2040: foreach (explode(',', $sequence) as $atom) {
2041: if (mb_strstr($atom, ':')) {
2042: $markers = explode(':', $atom);
2043: if (ctype_digit($markers[0]) && ctype_digit($markers[1])) {
2044: $res = array_merge($res, range($markers[0], $markers[1]));
2045: }
2046: }
2047: elseif (ctype_digit($atom)) {
2048: $res[] = $atom;
2049: }
2050: }
2051: return array_unique($res);
2052: }
2053:
2054: /**
2055: * convert an array into a sequence string
2056: * @param array $array list of ids
2057: *
2058: * @return string an IMAP sequence string
2059: */
2060: public function convert_array_to_sequence($array) {
2061: $res = '';
2062: $seq = false;
2063: $max = count($array) - 1;
2064: foreach ($array as $index => $value) {
2065: if (!isset($array[$index - 1])) {
2066: $res .= $value;
2067: }
2068: elseif ($seq) {
2069: $last_val = $array[$index - 1];
2070: if ($index == $max) {
2071: $res .= $value;
2072: break;
2073: }
2074: elseif ($last_val == $value - 1) {
2075: continue;
2076: }
2077: else {
2078: $res .= $last_val.','.$value;
2079: $seq = false;
2080: }
2081:
2082: }
2083: else {
2084: $last_val = $array[$index - 1];
2085: if ($last_val == $value - 1) {
2086: $seq = true;
2087: $res .= ':';
2088: }
2089: else {
2090: $res .= ','.$value;
2091: }
2092: }
2093: }
2094: return $res;
2095: }
2096:
2097: /**
2098: * decode mail fields to human readable text
2099: * @param string $string field to decode
2100: * @return string decoded field
2101: */
2102: public function decode_fld($string) {
2103: return decode_fld($string);
2104: }
2105:
2106: /**
2107: * check if an IMAP extension is supported by the server
2108: * @param string $extension name of an extension
2109: * @return bool true if the extension is supported
2110: */
2111: public function is_supported( $extension ) {
2112: return in_array(mb_strtolower($extension), array_diff($this->supported_extensions, $this->blacklisted_extensions));
2113: }
2114:
2115: /**
2116: * returns current IMAP state
2117: * @return string one of:
2118: * disconnected = no IMAP server TCP connection
2119: * connected = an IMAP server TCP connection exists
2120: * authenticated = successfully authenticated to the IMAP server
2121: * selected = a mailbox has been selected
2122: */
2123: public function get_state() {
2124: return $this->state;
2125: }
2126:
2127: /**
2128: * output IMAP session debug info
2129: * @param bool $full flag to enable full IMAP response display
2130: * @param bool $return flag to return the debug results instead of printing them
2131: * @param bool $list flag to return array
2132: * @return void/string
2133: */
2134: public function show_debug($full=false, $return=false, $list=false) {
2135: if ($list) {
2136: if ($full) {
2137: return array(
2138: 'debug' => $this->debug,
2139: 'commands' => $this->commands,
2140: 'responses' => $this->responses
2141: );
2142: }
2143: else {
2144: return array_merge($this->debug, $this->commands);
2145: }
2146: }
2147: $res = sprintf("\nDebug %s\n", print_r(array_merge($this->debug, $this->commands), true));
2148: if ($full) {
2149: $res .= sprintf("Response %s", print_r($this->responses, true));
2150: }
2151: if (!$return) {
2152: echo $res;
2153: }
2154: return $res;
2155: }
2156:
2157: /**
2158: * search a nested BODYSTRUCTURE response for a specific part
2159: * @param array $struct the structure to search
2160: * @param string $search_term the search term
2161: * @param array $search_flds list of fields to search for the term
2162: * @return array array of all matching parts from the message
2163: */
2164: public function search_bodystructure($struct, $search_flds, $all=true, $res=array()) {
2165: return $this->struct_object->recursive_search($struct, $search_flds, $all, $res);
2166: }
2167:
2168: /* ------------------ EXTENSIONS --------------------------------------- */
2169:
2170: /**
2171: * use the IMAP GETQUOTA command to fetch quota information
2172: * @param string $quota_root named quota root to fetch
2173: * @return array list of quota details
2174: */
2175: public function get_quota($quota_root='') {
2176: $quotas = array();
2177: if ($this->is_supported('QUOTA')) {
2178: $command = 'GETQUOTA "'.$quota_root."\"\r\n";
2179: $this->send_command($command);
2180: $res = $this->get_response(false, true);
2181: if ($this->check_response($res, true)) {
2182: foreach($res as $vals) {
2183: list($name, $max, $current) = $this->parse_quota_response($vals);
2184: if ($max) {
2185: $quotas[] = array('name' => $name, 'max' => $max, 'current' => $current);
2186: }
2187: }
2188: }
2189: }
2190: return $quotas;
2191: }
2192:
2193: /**
2194: * use the IMAP GETQUOTAROOT command to fetch quota information about a mailbox
2195: * @param string $mailbox IMAP folder to check
2196: * @return array list of quota details
2197: */
2198: public function get_quota_root($mailbox) {
2199: $quotas = array();
2200: if ($this->is_supported('QUOTA') && $this->is_clean($mailbox, 'mailbox')) {
2201: $command = 'GETQUOTAROOT "'. $this->utf7_encode($mailbox).'"'."\r\n";
2202: $this->send_command($command);
2203: $res = $this->get_response(false, true);
2204: if ($this->check_response($res, true)) {
2205: foreach($res as $vals) {
2206: list($name, $max, $current) = $this->parse_quota_response($vals);
2207: if ($max) {
2208: $quotas[] = array('name' => $name, 'max' => $max, 'current' => $current);
2209: }
2210: }
2211: }
2212: }
2213: return $quotas;
2214: }
2215:
2216: /**
2217: * use the ENABLE extension to tell the IMAP server what extensions we support
2218: * @return array list of supported extensions that can be enabled
2219: */
2220: public function enable() {
2221: $extensions = array();
2222: if ($this->is_supported('ENABLE')) {
2223: $supported = array_diff($this->declared_extensions, $this->blacklisted_extensions);
2224: if ($this->is_supported('QRESYNC')) {
2225: $extension_string = implode(' ', array_filter($supported, function($val) { return $val != 'CONDSTORE'; }));
2226: }
2227: else {
2228: $extension_string = implode(' ', $supported);
2229: }
2230: if (!$extension_string) {
2231: return array();
2232: }
2233: $command = 'ENABLE '.$extension_string."\r\n";
2234: $this->send_command($command);
2235: $res = $this->get_response(false, true);
2236: if ($this->check_response($res, true)) {
2237: foreach($res as $vals) {
2238: if (in_array('ENABLED', $vals)) {
2239: $extensions[] = $this->get_adjacent_response_value($vals, -1, 'ENABLED');
2240: }
2241: }
2242: }
2243: $this->enabled_extensions = $extensions;
2244: $this->debug[] = sprintf("Enabled extensions: ".implode(', ', $extensions));
2245: }
2246: return $extensions;
2247: }
2248:
2249: /**
2250: * unselect the selected mailbox
2251: * @return bool true on success
2252: */
2253: public function unselect_mailbox() {
2254: $this->send_command("UNSELECT\r\n");
2255: $res = $this->get_response(false, true);
2256: $status = $this->check_response($res, true);
2257: if ($status) {
2258: $this->selected_mailbox = false;
2259: }
2260: return $status;
2261: }
2262:
2263: /**
2264: * use the ID extension
2265: * @return array list of server properties on success
2266: */
2267: public function id() {
2268: $server_id = array();
2269: if ($this->is_supported('ID')) {
2270: $params = array(
2271: 'name' => $this->app_name,
2272: 'version' => $this->app_version,
2273: 'vendor' => $this->app_vendor,
2274: 'support-url' => $this->app_support_url,
2275: );
2276: $param_parts = array();
2277: foreach ($params as $name => $value) {
2278: $param_parts[] = '"'.$name.'" "'.$value.'"';
2279: }
2280: if (!empty($param_parts)) {
2281: $command = 'ID ('.implode(' ', $param_parts).")\r\n";
2282: $this->send_command($command);
2283: $result = $this->get_response(false, true);
2284: if ($this->check_response($result, true)) {
2285: foreach ($result as $vals) {
2286: if (in_array('name', $vals)) {
2287: $server_id['name'] = $this->get_adjacent_response_value($vals, -1, 'name');
2288: }
2289: if (in_array('vendor', $vals)) {
2290: $server_id['vendor'] = $this->get_adjacent_response_value($vals, -1, 'vendor');
2291: }
2292: if (in_array('version', $vals)) {
2293: $server_id['version'] = $this->get_adjacent_response_value($vals, -1, 'version');
2294: }
2295: if (in_array('support-url', $vals)) {
2296: $server_id['support-url'] = $this->get_adjacent_response_value($vals, -1, 'support-url');
2297: }
2298: if (in_array('remote-host', $vals)) {
2299: $server_id['remote-host'] = $this->get_adjacent_response_value($vals, -1, 'remote-host');
2300: }
2301: }
2302: $this->server_id = $server_id;
2303: $res = true;
2304: }
2305: }
2306: }
2307: return $server_id;
2308: }
2309:
2310: /**
2311: * use the SORT extension to get a sorted UID list and also perform term search if available
2312: * @param string $sort sort order. can be one of ARRIVAL, DATE, CC, TO, SUBJECT, FROM, or SIZE
2313: * @param bool $reverse flag to reverse the sort order
2314: * @param string $filter can be one of ALL, SEEN, UNSEEN, ANSWERED, UNANSWERED, DELETED, UNDELETED, FLAGGED, or UNFLAGGED
2315: * @return array list of IMAP message UIDs
2316: */
2317: public function get_message_sort_order($sort='ARRIVAL', $reverse=true, $filter='ALL', $terms=array(), $exclude_deleted=true, $exclude_auto_bcc=false, $only_auto_bcc=false) {
2318: if (!$this->is_clean($sort, 'keyword') || !$this->is_clean($filter, 'keyword') || !$this->is_supported('SORT')) {
2319: return [];
2320: }
2321: if (!empty($terms)) {
2322: foreach ($terms as $vals) {
2323: if (!$this->is_clean($vals[0], 'search_str') || !$this->is_clean($vals[1], 'search_str')) {
2324: return [];
2325: }
2326: }
2327: }
2328: if ($this->search_charset) {
2329: $charset = mb_strtoupper($this->search_charset).' ';
2330: }
2331: else {
2332: $charset = 'US-ASCII ';
2333: }
2334: if (!empty($terms)) {
2335: $flds = array();
2336: foreach ($terms as $vals) {
2337: if (mb_substr($vals[1], 0, 4) == 'NOT ') {
2338: $flds[] = 'NOT '.$vals[0].' "'.str_replace('"', '\"', mb_substr($vals[1], 4)).'"';
2339: }
2340: else {
2341: $flds[] = $vals[0].' "'.str_replace('"', '\"', $vals[1]).'"';
2342: }
2343: }
2344: $fld = ' '.implode(' ', $flds);
2345: }
2346: else {
2347: $fld = '';
2348: }
2349: if ($exclude_deleted) {
2350: $fld .= ' NOT DELETED';
2351: }
2352: if ($only_auto_bcc) {
2353: $fld .= ' HEADER X-Auto-Bcc cypht';
2354: }
2355: if ($exclude_auto_bcc && !mb_strstr($this->server, 'yahoo') && $this->server_supports_custom_headers()) {
2356: $fld .= ' NOT HEADER X-Auto-Bcc cypht';
2357: }
2358: if ($filter == 'ALL') {
2359: $filter = '';
2360: $charset = trim($charset);
2361: }
2362: $command = 'UID SORT ';
2363: $command .= '('.$sort.') '.$charset.$filter.$fld."\r\n";
2364: $cache_command = $command.(string)$reverse;
2365: $cache = $this->check_cache($cache_command);
2366: if ($cache !== false) {
2367: return $cache;
2368: }
2369: $this->send_command($command);
2370: if ($this->sort_speedup) {
2371: $speedup = true;
2372: }
2373: else {
2374: $speedup = false;
2375: }
2376: $res = $this->get_response(false, true, 8192, $speedup);
2377: $status = $this->check_response($res, true);
2378: $uids = array();
2379: foreach ($res as $vals) {
2380: if ($vals[0] == '*' && mb_strtoupper($vals[1]) == 'SORT') {
2381: array_shift($vals);
2382: array_shift($vals);
2383: $uids = array_merge($uids, $vals);
2384: }
2385: else {
2386: if (ctype_digit((string) $vals[0])) {
2387: $uids = array_merge($uids, $vals);
2388: }
2389: }
2390: }
2391: if ($reverse) {
2392: $uids = array_reverse($uids);
2393: }
2394: if ($status) {
2395: return $this->cache_return_val($uids, $cache_command);
2396: }
2397: return $uids;
2398: }
2399:
2400: /**
2401: * search using the Google X-GM-RAW IMAP extension
2402: * @param string $start_str formatted search string like "has:attachment in:unread"
2403: * @return array list of IMAP UIDs that match the search
2404: */
2405: public function google_search($search_str) {
2406: $uids = array();
2407: if ($this->is_supported('X-GM-EXT-1')) {
2408: $search_str = str_replace('"', '', $search_str);
2409: if ($this->is_clean($search_str, 'search_str')) {
2410: $command = "UID SEARCH X-GM-RAW \"".$search_str."\"\r\n";
2411: $this->send_command($command);
2412: $res = $this->get_response(false, true);
2413: $uids = array();
2414: foreach ($res as $vals) {
2415: foreach ($vals as $v) {
2416: if (ctype_digit((string) $v)) {
2417: $uids[] = $v;
2418: }
2419: }
2420: }
2421: }
2422: }
2423: return $uids;
2424: }
2425:
2426: /**
2427: * attempt enable IMAP COMPRESS extension
2428: * @todo: currently does not work ...
2429: * @return void
2430: */
2431: public function enable_compression() {
2432: if ($this->is_supported('COMPRESS=DEFLATE')) {
2433: $this->send_command("COMPRESS DEFLATE\r\n");
2434: $res = $this->get_response(false, true);
2435: if ($this->check_response($res, true)) {
2436: $params = array('level' => 6, 'window' => 15, 'memory' => 9);
2437: stream_filter_prepend($this->handle, 'zlib.inflate', STREAM_FILTER_READ);
2438: stream_filter_append($this->handle, 'zlib.deflate', STREAM_FILTER_WRITE, $params);
2439: $this->debug[] = 'DEFLATE compression extension activated';
2440: return true;
2441: }
2442: }
2443: return false;
2444: }
2445:
2446: /* ------------------ HIGH LEVEL --------------------------------------- */
2447:
2448: /**
2449: * return the formatted message content of the first part that matches the supplied MIME type
2450: * @param int $uid IMAP UID value for the message
2451: * @param string $type Primary MIME type like "text"
2452: * @param string $subtype Secondary MIME type like "plain"
2453: * @param array $struct message structure array
2454: * @return string formatted message content, bool false if no matching part is found
2455: */
2456: public function get_first_message_part($uid, $type, $subtype=false, $struct=false) {
2457: if (!$subtype) {
2458: $flds = array('type' => $type);
2459: }
2460: else {
2461: $flds = array('type' => $type, 'subtype' => $subtype);
2462: }
2463: if (!$struct) {
2464: $struct = $this->get_message_structure($uid);
2465: }
2466: $matches = $this->search_bodystructure($struct, $flds, false);
2467: if (!empty($matches)) {
2468:
2469: $subset = array_slice(array_keys($matches), 0, 1);
2470: $msg_part_num = $subset[0];
2471: $struct = array_slice($matches, 0, 1);
2472:
2473: if (isset($struct[$msg_part_num])) {
2474: $struct = $struct[$msg_part_num];
2475: }
2476: elseif (isset($struct[0])) {
2477: $struct = $struct[0];
2478: }
2479:
2480: return array($msg_part_num, $this->get_message_content($uid, $msg_part_num, false, $struct));
2481: }
2482: return array(false, false);
2483: }
2484:
2485: /**
2486: * return a list of headers and UIDs for a page of a mailbox
2487: * @param string $mailbox the mailbox to access
2488: * @param string $sort sort order. can be one of ARRIVAL, DATE, CC, TO, SUBJECT, FROM, or SIZE
2489: * @param string $filter type of messages to include (UNSEEN, ANSWERED, ALL, etc)
2490: * @param int $limit max number of messages to return
2491: * @param int $offset offset from the first message in the list
2492: * @param string $keyword optional keyword to filter the results by
2493: * @return array list of headers
2494: */
2495:
2496: public function get_mailbox_page($mailbox, $sort, $rev, $filter, $offset=0, $limit=0, $keyword=false, $trusted_senders=array(), $include_preview = false) {
2497: $result = array();
2498:
2499: /* select the mailbox if need be */
2500: if (!$this->selected_mailbox || $this->selected_mailbox['name'] != $mailbox) {
2501: $this->select_mailbox($mailbox);
2502: }
2503:
2504: /* use the SORT extension if we can */
2505: if ($this->is_supported( 'SORT' )) {
2506: $uids = $this->get_message_sort_order($sort, $rev, $filter);
2507: }
2508:
2509: /* fall back to using FETCH and manually sorting */
2510: else {
2511: $uids = $this->sort_by_fetch($sort, $rev, $filter);
2512: }
2513: $terms = array();
2514: if ($keyword) {
2515: $terms[] = array('TEXT', $keyword);
2516: }
2517: if ($trusted_senders && is_array($trusted_senders)) {
2518: foreach ($trusted_senders as $sender) {
2519: $terms[] = array('FROM', 'NOT '. $sender);
2520: }
2521:
2522: }
2523: // Perform a single search call with the combined terms
2524: if (!empty($terms)) {
2525: $uids = $this->search($filter, $uids, $terms);
2526: }
2527: $total = count($uids);
2528:
2529: /* reduce to one page */
2530: if ($limit) {
2531: $uids = array_slice($uids, $offset, $limit, true);
2532: }
2533:
2534: /* get the headers and build a result array by UID */
2535: if (!empty($uids)) {
2536: $headers = $this->get_message_list($uids, false, $include_preview);
2537: foreach($uids as $uid) {
2538: if (isset($headers[$uid])) {
2539: $result[$uid] = $headers[$uid];
2540: }
2541: }
2542: }
2543: return array($total, $result);
2544: }
2545:
2546: /**
2547: * return all the folders contained at a hierarchy level, and if possible, if they have sub-folders
2548: * @param string $level mailbox name or empty string for the top level
2549: * @return array list of matching folders
2550: */
2551: public function get_folder_list_by_level($level='', $only_subscribed=false, $with_input = false, $count_children = false) {
2552: $result = array();
2553: $folders = array();
2554: if ($this->server_support_children_capability()) {
2555: $folders = $this->get_mailbox_list($only_subscribed, $level, '%');
2556: } else {
2557: $folders = $this->get_mailbox_list($only_subscribed, $level, "*", false);
2558: }
2559:
2560: foreach ($folders as $name => $folder) {
2561: $result[$name] = array(
2562: 'name' => $folder['name'],
2563: 'delim' => $folder['delim'],
2564: 'basename' => $folder['basename'],
2565: 'children' => $folder['has_kids'],
2566: 'noselect' => $folder['noselect'],
2567: 'id' => bin2hex($folder['basename']),
2568: 'name_parts' => $folder['name_parts'],
2569: 'clickable' => !$with_input,
2570: );
2571: if ($with_input) {
2572: $result[$name]['special'] = $folder['special'];
2573: }
2574: if ($folder['has_kids'] && $count_children) {
2575: $result[$name]['number_of_children'] = count($this->get_folder_list_by_level($folder['name'], false, false));
2576: }
2577: }
2578: if ($only_subscribed || $with_input) {
2579: $subscribed_folders = array_column($this->get_mailbox_list(true, children_capability:$this->server_support_children_capability()), 'name');
2580: foreach ($result as $key => $folder) {
2581: $result[$key]['subscribed'] = in_array($folder['name'], $subscribed_folders);
2582: if (!$with_input) {
2583: $result[$key]['clickable'] = $result[$key]['subscribed'];
2584: }
2585: }
2586: }
2587: return $result;
2588: }
2589:
2590: /**
2591: * Test if the server supports searching by custom headers.
2592: *
2593: * This function sends a test search command to check if the server supports
2594: * searching by custom headers (e.g., X-Auto-Bcc). If the server does not support
2595: * this feature, it will return false.
2596: *
2597: * Reference: Stalwart's current limitation on searching by custom headers
2598: * discussed in the following GitHub thread:
2599: * https://github.com/stalwartlabs/mail-server/discussions/477
2600: *
2601: * Note: This function should be removed once Stalwart starts supporting custom headers.
2602: *
2603: * @return boolean true if the server supports searching by custom headers.
2604: */
2605: protected function server_supports_custom_headers() {
2606: $test_command = 'UID SEARCH HEADER "X-NonExistent-Header" "test"'."\r\n";
2607: $this->send_command($test_command);
2608: $response = $this->get_response(false, true);
2609: $status = $this->check_response($response, true);
2610:
2611: // Keywords that indicate the header search is not supported
2612: $keywords = ['is', 'not', 'supported.'];
2613:
2614: if (!$status) {
2615: return false;
2616: }
2617:
2618: // Flatten the response array to a single array of strings
2619: $flattened_response = array_reduce($response, 'array_merge', []);
2620:
2621: // Check if all keywords are present in the flattened response
2622: $sequence_match = true;
2623: foreach ($keywords as $keyword) {
2624: if (!in_array($keyword, $flattened_response)) {
2625: $sequence_match = false;
2626: break;
2627: }
2628: }
2629:
2630: // If all keywords are found, the header search is not supported
2631: if ($sequence_match) {
2632: return false;
2633: }
2634:
2635: return true;
2636: }
2637:
2638: public function server_support_children_capability() {
2639: $test_command = 'CAPABILITY'."\r\n";
2640: $this->send_command($test_command);
2641: $response = $this->get_response(false, true);
2642: $status = $this->check_response($response, true);
2643:
2644: // Keywords that indicate the header search is not supported
2645: $keywords = ['CHILDREN'];
2646:
2647: if (!$status) {
2648: return false;
2649: }
2650:
2651: // Flatten the response array to a single array of strings
2652: $flattened_response = array_reduce($response, 'array_merge', []);
2653:
2654: // Check if all keywords are present in the flattened response
2655: $sequence_match = true;
2656: foreach ($keywords as $keyword) {
2657: if (!in_array($keyword, $flattened_response)) {
2658: $sequence_match = false;
2659: break;
2660: }
2661: }
2662:
2663: // If all keywords are found, the header search is not supported
2664: if ($sequence_match) {
2665: return true;
2666: }
2667:
2668: return false;
2669: }
2670:
2671: }
2672: }
2673: