1: <?php
2:
3: /**
4: * Mailbox bridge class
5: * @package modules
6: * @subpackage imap
7: *
8: * This class hides the implementation details of IMAP, JMAP, SMTP and EWS connections and provides
9: * a common interface to work with a mail account/mailbox. It acts as a bridge to more than one
10: * underlying connections/protocols.
11: */
12: class Hm_Mailbox {
13: const TYPE_IMAP = 1;
14: const TYPE_JMAP = 2;
15: const TYPE_EWS = 3;
16: const TYPE_SMTP = 4;
17:
18: protected $type;
19: protected $connection;
20: protected $selected_folder;
21: protected $folder_state;
22:
23: protected $server_id;
24: protected $user_config;
25: protected $session;
26: protected $config;
27:
28: public function __construct($server_id, $user_config, $session, $config) {
29: $this->server_id = $server_id;
30: $this->user_config = $user_config;
31: $this->session = $session;
32: $this->config = $config;
33: $type = $config['type'] ?? '';
34: if ($type == 'imap') {
35: $this->type = self::TYPE_IMAP;
36: $this->connection = new Hm_IMAP();
37: } elseif ($type == 'jmap') {
38: $this->type = self::TYPE_JMAP;
39: $this->connection = new Hm_JMAP();
40: } elseif ($type == 'ews') {
41: $this->type = self::TYPE_EWS;
42: $this->connection = new Hm_EWS();
43: } elseif ($type == 'smtp') {
44: $this->type = self::TYPE_SMTP;
45: $this->connection = new Hm_SMTP($config);
46: }
47: }
48:
49: public function connect() {
50: if (! $this->connection) {
51: return false;
52: }
53: return $this->connection->connect($this->config);
54: }
55:
56: public function get_connection() {
57: return $this->connection;
58: }
59:
60: public function is_imap() {
61: return $this->type === self::TYPE_IMAP || $this->type === self::TYPE_JMAP;
62: }
63:
64: public function is_smtp() {
65: return $this->type === self::TYPE_SMTP;
66: }
67:
68: public function server_type() {
69: switch ($this->type) {
70: case self::TYPE_IMAP:
71: return 'IMAP';
72: case self::TYPE_JMAP:
73: return 'JMAP';
74: case self::TYPE_EWS:
75: return 'EWS';
76: }
77: }
78:
79: public function authed() {
80: if (! $this->connection) {
81: return false;
82: }
83: if ($this->is_imap()) {
84: return $this->connection->get_state() == 'authenticated' || $this->connection->get_state() == 'selected';
85: } elseif ($this->is_smtp()) {
86: return $this->connection->state == 'authed';
87: } else {
88: return $this->connection->authed();
89: }
90: }
91:
92: public function state() {
93: if ($this->is_imap()) {
94: return $this->connection->get_state();
95: } elseif ($this->is_smtp()) {
96: return $this->connection->state;
97: } else {
98: return null;
99: }
100: }
101:
102: public function folder_exists($folder) {
103: $res = $this->get_folder_status($folder, false);
104: if (empty($res)) {
105: return false;
106: }
107: return true;
108: }
109:
110: public function get_folder_status($folder, $report_error = true) {
111: if (! $this->authed()) {
112: return;
113: }
114: if ($this->is_imap()) {
115: return $this->connection->get_mailbox_status($folder);
116: } else {
117: return $this->connection->get_folder_status($folder, $report_error);
118: }
119: }
120:
121: public function get_folder_name($folder) {
122: if ($this->is_imap()) {
123: return $folder;
124: } else {
125: $result = $this->connection->get_folder_name_quick($folder);
126: if ($result) {
127: return $result;
128: } else {
129: if (! $this->connection->authed()) {
130: $this->connect();
131: }
132: $result = $this->connection->get_folder_status($folder);
133: return $result['name'];
134: }
135: }
136: }
137:
138: public function create_folder($folder, $parent = null) {
139: if (! $this->authed()) {
140: return;
141: }
142: if ($this->is_imap()) {
143: $new_folder = prep_folder_name($this->connection, $folder, false, $parent);
144: if ($this->connection->create_mailbox($new_folder)) {
145: return $new_folder;
146: } else {
147: return false;
148: }
149: } else {
150: return $this->connection->create_folder($folder, $parent);
151: }
152: }
153:
154: public function rename_folder($folder, $new_name, $parent = null) {
155: if (! $this->authed()) {
156: return;
157: }
158: if ($this->is_imap()) {
159: $old_folder = prep_folder_name($this->connection, $folder, true);
160: if (! $parent) {
161: $parents = explode(".", $old_folder);
162: $parents = array_slice($parents, 0, -1);
163: if (count($parents)) {
164: $new_name = implode(".", $parents) .".". $new_name;
165: }
166: }
167: $new_folder = prep_folder_name($this->connection, $new_name, false, $parent);
168: return $this->connection->rename_mailbox($old_folder, $new_folder);
169: } else {
170: $folder = decode_folder_str($folder);
171: if ($parent) {
172: $parent = decode_folder_str($parent);
173: }
174: return $this->connection->rename_folder($folder, $new_name, $parent);
175: }
176: }
177:
178: public function delete_folder($folder) {
179: if (! $this->authed()) {
180: return;
181: }
182: if ($this->is_imap()) {
183: $del_folder = prep_folder_name($this->connection, $folder, true);
184: return $this->connection->delete_mailbox($del_folder);
185: } else {
186: $del_folder = decode_folder_str($folder);
187: return $this->connection->delete_folder($del_folder);
188: }
189: }
190:
191: public function prep_folder_name($folder) {
192: if ($this->is_imap()) {
193: return prep_folder_name($this->connection, $folder, true);
194: } else {
195: if (substr_count($folder, '_') >= 2) {
196: return decode_folder_str($folder);
197: } else {
198: return $folder;
199: }
200: }
201: }
202:
203: public function folder_subscription($folder, $action) {
204: if (! $this->authed()) {
205: return;
206: }
207: if ($this->is_imap()) {
208: return $this->connection->mailbox_subscription($folder, $action);
209: } else {
210: // emulate folder subscription via settings
211: $config = $this->user_config->get('unsubscribed_folders');
212: if (! isset($config[$this->server_id])) {
213: $config[$this->server_id] = [];
214: }
215: if ($action) {
216: $index = array_search($folder, $config[$this->server_id]);
217: if ($index !== false) {
218: unset($config[$this->server_id][$index]);
219: }
220: } else {
221: if (! in_array($folder, $config[$this->server_id])) {
222: $config[$this->server_id][] = $folder;
223: }
224: }
225: $this->user_config->set('unsubscribed_folders', $config);
226: $this->session->set('user_data', $this->user_config->dump());
227: $this->session->record_unsaved('Folder subscription updated');
228: return true;
229: }
230: }
231:
232: public function get_folders($only_subscribed = false) {
233: if (! $this->authed()) {
234: return;
235: }
236: if ($this->is_imap()) {
237: return $this->connection->get_mailbox_list($only_subscribed, children_capability: $this->connection->server_support_children_capability());
238: } else {
239: return $this->connection->get_folders(null, $only_subscribed, $this->user_config->get('unsubscribed_folders')[$this->server_id] ?? []);
240: }
241: }
242:
243: public function get_subfolders($folder, $only_subscribed = false, $with_input = false, $count_children = false) {
244: if (! $this->authed()) {
245: return;
246: }
247: if ($this->is_imap()) {
248: return $this->connection->get_folder_list_by_level($folder, $only_subscribed, $with_input, $count_children);
249: } else {
250: return $this->connection->get_folders($folder, $only_subscribed, $this->user_config->get('unsubscribed_folders')[$this->server_id] ?? [], $with_input);
251: }
252: }
253:
254: public function get_folder_state() {
255: if ($this->is_imap()) {
256: return $this->connection->folder_state;
257: } else {
258: return $this->folder_state;
259: }
260: }
261:
262: public function get_selected_folder() {
263: if ($this->is_imap()) {
264: return $this->connection->selected_mailbox;
265: } else {
266: return $this->selected_folder;
267: }
268: }
269:
270: public function get_special_use_mailboxes($folder = false) {
271: if (! $this->authed()) {
272: return;
273: }
274: if ($this->is_imap()) {
275: return $this->connection->get_special_use_mailboxes($folder);
276: } else {
277: return $this->connection->get_special_use_folders($folder);
278: }
279: }
280:
281: /**
282: * Get messages in a folder applying filters, sorting and pagination
283: * @return array - [total results found, results for a single page]
284: */
285: public function get_messages($folder, $sort, $reverse, $flag_filter, $offset=0, $limit=50, $keyword=false, $trusted_senders=[], $include_preview = false) {
286: if (! $this->select_folder($folder)) {
287: return [0, []];
288: }
289: if ($this->is_imap()) {
290: $messages = $this->connection->get_mailbox_page($folder, $sort, $reverse, $flag_filter, $offset, $limit, $keyword, $trusted_senders, $include_preview);
291: } else {
292: $messages = $this->connection->get_messages($folder, $sort, $reverse, $flag_filter, $offset, $limit, $keyword, $trusted_senders, $include_preview);
293: $folder = $this->selected_folder['id'];
294: }
295: foreach ($messages[1] as &$msg) {
296: $msg['folder'] = bin2hex($folder);
297: }
298: return $messages;
299: }
300:
301: public function get_message_headers($folder, $msg_id) {
302: if (! $this->select_folder($folder)) {
303: return;
304: }
305: if ($this->is_imap()) {
306: return $this->connection->get_message_headers($msg_id);
307: } else {
308: return $this->connection->get_message_headers($msg_id);
309: }
310:
311: }
312:
313: public function get_message_content($folder, $msg_id, $part = 0) {
314: if (! $this->authed()) {
315: return;
316: }
317: if (! $this->select_folder($folder)) {
318: return;
319: }
320: if ($this->is_imap()) {
321: return $this->connection->get_message_content($msg_id, $part);
322: } else {
323: return $this->connection->get_message_content($msg_id, $part);
324: }
325: }
326:
327: public function get_structured_message($folder, $msg_id, $part, $text_only) {
328: if (! $this->select_folder($folder)) {
329: return;
330: }
331: if ($this->is_imap()) {
332: $msg_struct = $this->connection->get_message_structure($msg_id);
333: if ($part !== false) {
334: if ($part == 0) {
335: $max = 500000;
336: }
337: else {
338: $max = false;
339: }
340: $struct = $this->connection->search_bodystructure($msg_struct, array('imap_part_number' => $part));
341: $msg_struct_current = array_shift($struct);
342: $msg_text = $this->connection->get_message_content($msg_id, $part, $max, $msg_struct_current);
343: }
344: else {
345: if (! $text_only) {
346: list($part, $msg_text) = $this->connection->get_first_message_part($msg_id, 'text', 'html', $msg_struct);
347: if (!$part) {
348: list($part, $msg_text) = $this->connection->get_first_message_part($msg_id, 'text', false, $msg_struct);
349: }
350: }
351: else {
352: list($part, $msg_text) = $this->connection->get_first_message_part($msg_id, 'text', false, $msg_struct);
353: }
354: $struct = $this->connection->search_bodystructure($msg_struct, array('imap_part_number' => $part));
355: $msg_struct_current = array_shift($struct);
356: if (! trim($msg_text)) {
357: if (is_array($msg_struct_current) && array_key_exists('subtype', $msg_struct_current)) {
358: if ($msg_struct_current['subtype'] == 'plain') {
359: $subtype = 'html';
360: }
361: else {
362: $subtype = 'plain';
363: }
364: list($part, $msg_text) = $this->connection->get_first_message_part($msg_id, 'text', $subtype, $msg_struct);
365: $struct = $this->connection->search_bodystructure($msg_struct, array('imap_part_number' => $part));
366: $msg_struct_current = array_shift($struct);
367: }
368: }
369: }
370: if (isset($msg_struct_current['subtype']) && mb_strtolower($msg_struct_current['subtype'] == 'html')) {
371: $msg_text = add_attached_images($msg_text, $msg_id, $msg_struct, $this->connection);
372: }
373: return [$msg_struct, $msg_struct_current, $msg_text, $part];
374: } else {
375: return $this->connection->get_structured_message($msg_id, $part, $text_only);
376: }
377: }
378:
379: public function store_message($folder, $msg, $seen = true, $draft = false) {
380: if (! $this->authed()) {
381: return false;
382: }
383: if ($this->is_imap()) {
384: if ($this->connection->append_start($folder, mb_strlen($msg), $seen, $draft)) {
385: $this->connection->append_feed($msg."\r\n");
386: return $this->connection->append_end();
387: }
388: } else {
389: return $this->connection->store_message($folder, $msg, $seen, $draft);
390: }
391: return false;
392: }
393:
394: public function delete_message($folder, $msg_id, $trash_folder) {
395: if (! $this->select_folder($folder)) {
396: return;
397: }
398: if ($this->is_imap() && $trash_folder && $trash_folder != $folder) {
399: if ($this->connection->message_action('MOVE', [$msg_id], $trash_folder)['status']) {
400: return true;
401: }
402: }
403: else {
404: if ($this->connection->message_action('DELETE', array($msg_id))['status']) {
405: $this->connection->message_action('EXPUNGE', array($msg_id));
406: return true;
407: }
408: }
409: return false;
410: }
411:
412: public function message_action($folder, $action, $uids, $mailbox=false, $keyword=false) {
413: if (! $this->select_folder($folder)) {
414: return ['status' => false, 'responses' => []];
415: }
416: return $this->connection->message_action($action, $uids, $mailbox, $keyword);
417: }
418:
419: public function stream_message_part($folder, $msg_id, $part_id, $start_cb) {
420: if (! $this->select_folder($folder)) {
421: return;
422: }
423: if ($this->is_imap()) {
424: $msg_struct = $this->connection->get_message_structure($msg_id);
425: $struct = $this->connection->search_bodystructure($msg_struct, array('imap_part_number' => $part_id));
426: if (! empty($struct)) {
427: $part_struct = array_shift($struct);
428: $encoding = false;
429: if (array_key_exists('encoding', $part_struct)) {
430: $encoding = trim(mb_strtolower($part_struct['encoding']));
431: }
432: $stream_size = $this->connection->start_message_stream($msg_id, $part_id);
433: if ($stream_size > 0) {
434: $part_name = get_imap_part_name($part_struct, $msg_id, $part_id);
435: $charset = '';
436: if (array_key_exists('attributes', $part_struct)) {
437: if (is_array($part_struct['attributes']) && array_key_exists('charset', $part_struct['attributes'])) {
438: $charset = '; charset='.$part_struct['attributes']['charset'];
439: }
440: }
441: $start_cb($part_struct['type'] . '/' . $part_struct['subtype'] . $charset, $part_name);
442: $output_line = '';
443: while($line = $this->connection->read_stream_line()) {
444: if ($encoding == 'quoted-printable') {
445: $line = quoted_printable_decode($line);
446: }
447: elseif ($encoding == 'base64') {
448: $line = base64_decode($line);
449: }
450: echo $output_line;
451: $output_line = $line;
452: }
453: if ($part_struct['type'] == 'text') {
454: $output_line = preg_replace("/\)(\r\n)$/m", '$1', $output_line);
455: }
456: echo $output_line;
457: }
458: }
459: } else {
460: return $this->connection->stream_message_part($msg_id, $part_id, $start_cb);
461: }
462: }
463:
464: public function remove_attachment($folder, $msg_id, $part_id) {
465: if (! $this->select_folder($folder)) {
466: return;
467: }
468: if ($this->is_imap()) {
469: $msg = $this->connection->get_message_content($msg_id, 0, false, false);
470: if ($msg) {
471: $struct = $this->connection->get_message_structure($msg_id);
472: $attachment_id = get_attachment_id_for_mail_parser($struct, $part_id);
473: if ($attachment_id !== false) {
474: $msg = remove_attachment($attachment_id, $msg);
475: if ($this->connection->append_start($folder, mb_strlen($msg))) {
476: $this->connection->append_feed($msg."\r\n");
477: if ($this->connection->append_end()) {
478: if ($this->connection->message_action('DELETE', array($uid))['status']) {
479: $this->connection->message_action('EXPUNGE', array($uid));
480: return true;
481: }
482: }
483: }
484: }
485: }
486: } else {
487: $message = $this->connection->get_mime_message_by_id($msg_id);
488: $result = $this->connection->get_structured_message($msg_id, false, false);
489: $struct = $result[0];
490: $attachment_id = get_attachment_id_for_mail_parser($struct, $part_id);
491: if ($attachment_id !== false) {
492: $message->removeAttachmentPart($attachment_id);
493: if ($this->connection->store_message($folder, (string) $message)) {
494: $this->connection->message_action('HARDDELETE', [$msg_id]);
495: return true;
496: }
497: }
498: }
499: }
500:
501: public function get_quota($folder, $root = false) {
502: if ($this->is_imap()) {
503: if ($root) {
504: return $this->connection->get_quota_root($folder);
505: } else {
506: return $this->connection->get_quota($folder);
507: }
508: } else {
509: // not supported by EWS
510: return [];
511: }
512: }
513:
514: public function get_debug() {
515: if ($this->is_imap()) {
516: return $this->connection->show_debug(true, true, true);
517: } else {
518: return [];
519: }
520: }
521:
522: public function use_cache() {
523: if ($this->is_imap()) {
524: return $this->connection->use_cache;
525: } else {
526: return false;
527: }
528: }
529:
530: public function dump_cache($type = 'string') {
531: if ($this->is_imap()) {
532: return $this->connection->dump_cache($type);
533: } else {
534: return null;
535: }
536: }
537:
538: public function get_state() {
539: if ($this->is_imap()) {
540: return $this->connection->get_state();
541: } else {
542: return $this->authed() ? 'authenticated' : 'disconnected';
543: }
544: }
545:
546: public function get_capability() {
547: return $this->connection->get_capability();
548: }
549:
550: public function set_read_only($read_only) {
551: if ($this->is_imap()) {
552: $this->connection->read_only = $read_only;
553: }
554: }
555:
556: public function set_search_charset($charset) {
557: if ($this->is_imap()) {
558: $this->connection->search_charset = $charset;
559: }
560: }
561:
562: public function search($folder, $target='ALL', $terms=array(), $sort=null, $reverse=null, $exclude_deleted=true, $exclude_auto_bcc=true, $only_auto_bcc=false) {
563: if (! $this->select_folder($folder)) {
564: return [];
565: }
566: if ($this->is_imap()) {
567: if ($sort) {
568: if ($this->connection->is_supported('SORT')) {
569: // use fast sort extension and search simultanously
570: $uids = $this->connection->get_message_sort_order($sort, $reverse, $target, $terms, $exclude_deleted, $exclude_auto_bcc, $only_auto_bcc);
571: } else {
572: // search first and then sort only the found ones by fetch
573: $uids = $this->connection->search($target, false, $terms, [], $exclude_deleted, $exclude_auto_bcc, $only_auto_bcc);
574: if ($uids) {
575: $uids = $this->connection->sort_by_fetch($sort, $reverse, $target, implode(',', $uids));
576: }
577: }
578: } else {
579: // just search with default sort order
580: $uids = $this->connection->search($target, false, $terms, [], $exclude_deleted, $exclude_auto_bcc, $only_auto_bcc);
581: }
582: return $uids;
583: } else {
584: // deleted flag, auto-bcc feature - not supported by EWS
585: list($total, $itemIds) = $this->connection->search($folder, $sort, $reverse, $target, 0, 9999, $terms, []);
586: return $itemIds;
587: }
588: }
589:
590: public function get_message_list($folder, $msg_ids) {
591: if (! $this->select_folder($folder)) {
592: return [];
593: }
594: if ($this->is_imap()) {
595: return $this->connection->get_message_list($msg_ids);
596: } else {
597: return $this->connection->get_message_list($msg_ids);
598: }
599: }
600:
601: public function send_message($from, $recipients, $message, $delivery_receipt = false) {
602: if ($this->is_smtp()) {
603: if ($delivery_receipt) {
604: $from_params = 'RET=HDRS';
605: $recipients_params = 'NOTIFY=SUCCESS,FAILURE';
606: } else {
607: $from_params = '';
608: $recipients_params = '';
609: }
610: return $this->connection->send_message($from, $recipients, $message, $from_params, $recipients_params);
611: } else {
612: return $this->connection->send_message($from, $recipients, $message, $delivery_receipt);
613: }
614: }
615:
616: public function select_folder($folder) {
617: if ($this->is_imap()) {
618: if (isset($this->connection->selected_mailbox['name']) && $this->connection->selected_mailbox['name'] == $folder) {
619: return true;
620: }
621: if (! $this->connection->select_mailbox($folder)) {
622: return false;
623: }
624: } else {
625: $this->folder_state = $this->get_folder_status($folder);
626: if (! $this->folder_state) {
627: return false;
628: }
629: $this->selected_folder = ['id' => $folder, 'name' => $this->folder_state['name'], 'detail' => []];
630: }
631: return true;
632: }
633:
634: public function is_archive_folder($id, $user_config, $current_folder) {
635: if ($this->is_imap()) {
636: return is_imap_archive_folder($id, $user_config, $current_folder);
637: }
638: return false;
639: }
640:
641: public function get_config() {
642: return $this->config;
643: }
644: }
645: