1: <?php
2:
3: /**
4: * JMAP lib
5: * @package modules
6: * @subpackage imap
7: *
8: * This is intended to be a "drop in" replacement for the hm-imap.php class.
9: * It mimics the public interface of that class to minimize changes required
10: * to the modules.php code. Because of this a number of methods have unused
11: * arguments that must be left in place to maintain compatible behavior. JMAP
12: * simply does not need those arguments. They will be denoted with (ignored)
13: * in the doc string for that method.
14: *
15: * There is a lot of room for improvement by "chaining" JMAP methods together
16: * into a single API request and using "back reference" suppport. Once this
17: * is working solidly it's definitely something we should look into.
18: *
19: */
20:
21:
22: /**
23: * TODO:
24: * - support multiple accounts per JMAP connection.
25: * - Update move/copy for multiple mailboxId refs (patch)
26: * - mailbox state handling
27: * - pipeline where we can with back refs
28: * - disable download of multipart types
29: * - recurse into nested multipart types for bodystruct
30: */
31:
32: /**
33: * public interface to JMAP commands
34: * @subpackage imap/lib
35: */
36: class Hm_JMAP {
37:
38: private $api;
39: private $session;
40: private $api_url;
41: private $download_url;
42: private $upload_url;
43: private $account_id;
44: private $headers;
45: private $delim = '.';
46: private $state = 'disconnected';
47: private $requests = array();
48: private $responses = array();
49: private $folder_list = array();
50: private $streaming_msg = '';
51: private $msg_part_id = 0;
52: private $append_mailbox = false;
53: private $append_seen = false;
54: private $append_result = false;
55: private $sorts = array(
56: 'ARRIVAL' => 'receivedAt',
57: 'FROM' => 'from',
58: 'TO' => 'to',
59: 'SUBJECT' => 'subject',
60: 'DATE' => 'sentAt'
61: );
62: private $mod_map = array(
63: 'READ' => array('$seen', true),
64: 'UNREAD' => array('$seen', NULL),
65: 'FLAG' => array('$flagged', true),
66: 'UNFLAG' => array('$flagged', NULL),
67: 'ANSWERED' => array('$answered', true),
68: 'UNANSWERED' => array('$answered', NULL)
69: );
70: private $default_caps = array(
71: 'urn:ietf:params:jmap:core',
72: 'urn:ietf:params:jmap:mail',
73: 'urn:ietf:params:jmap:quota'
74: );
75:
76: public $selected_mailbox;
77: public $folder_state = array();
78: public $use_cache = true;
79: public $read_only = false;
80: public $server_type = 'JMAP';
81:
82: /**
83: * PUBLIC INTERFACE
84: */
85:
86: public function __construct() {
87: $this->api = new Hm_API_Curl();
88: }
89:
90: /**
91: * Looks for special use folders like sent or trash
92: * @param string $type folder type
93: * @return array
94: */
95: public function get_special_use_mailboxes($type=false) {
96: $res = array();
97: foreach ($this->folder_list as $name => $vals) {
98: if ($type && mb_strtolower($vals['role']) == mb_strtolower($type)) {
99: return array($type => $name);
100: }
101: elseif ($vals['role']) {
102: $res[$type] = $name;
103: }
104: }
105: return $res;
106: }
107:
108: /**
109: * Get the status of a mailbox
110: * @param string $mailbox mailbox name
111: * @param array $args values to check for (ignored)
112: * @return
113: */
114: public function get_mailbox_status($mailbox, $args=array('UNSEEN', 'UIDVALIDITY', 'UIDNEXT', 'MESSAGES', 'RECENT')) {
115: $methods = array(array(
116: 'Mailbox/get',
117: array(
118: 'accountId' => $this->account_id,
119: 'ids' => array($this->folder_name_to_id($mailbox))
120: ),
121: 'gms'
122: ));
123: $res = $this->send_and_parse($methods, array(0, 1, 'list', 0), array());
124: $this->folder_state = array(
125: 'messages' => $res['totalEmails'],
126: 'unseen' => $res['unreadEmails'],
127: 'uidvalidity' => false,
128: 'uidnext' => false,
129: 'recent' => false,
130: );
131: return $this->folder_state;
132: }
133:
134: /**
135: * Fake start to IMAP APPEND
136: * @param string $mailbox the mailbox to add a message to
137: * @param integer $size the size of the message (ignored)
138: * @param boolean $seen flag to mark the new message as read
139: * @return true
140: */
141: public function append_start($mailbox, $size, $seen=true) {
142: $this->append_mailbox = $mailbox;
143: $this->append_seen = $seen;
144: return true;
145: }
146:
147: /**
148: * Normally this would be a single line of data to feed to an IMAP APPEND
149: * command. For JMAP it is the whole message which we first upload to the
150: * server then import into the mailbox
151: * @param string $string the raw message to append
152: * @return boolean
153: */
154: public function append_feed($string) {
155: $blob_id = $this->upload_file($string);
156: if (!$blob_id) {
157: return false;
158: }
159: $emails = array(
160: 'mailboxIds' => array($this->folder_name_to_id($this->append_mailbox) => true),
161: 'blobId' => $blob_id
162: );
163: if ($this->append_seen) {
164: $emails['keywords'] = array('$seen' => true);
165: }
166: $methods = array(array(
167: 'Email/import',
168: array(
169: 'accountId' => $this->account_id,
170: 'emails' => array(NULL => $emails)
171: ),
172: 'af'
173: ));
174: $res = $this->send_and_parse($methods, array(0, 1, 'created', NULL), array());
175: $this->append_result = is_array($res) && array_key_exists('id', $res);
176: }
177:
178: /**
179: * Fake end of IMAP APPEND command.
180: * @return boolean
181: */
182: public function append_end() {
183: $res = $this->append_result;
184: $this->append_result = false;
185: $this->append_mailbox = false;
186: $this->append_seen = false;
187: return $res;
188: }
189:
190: /**
191: * Normally whould start streaming data for an IMAP message part, but with
192: * JMAP we donwload the whole thing into $this->streaming_msg
193: * @param string $uid message uid
194: * @param string $message_part message part id
195: * @return integer
196: */
197: public function start_message_stream($uid, $message_part) {
198: list($blob_id, $name) = $this->download_details($uid, $message_part);
199: if (!$name || !$blob_id) {
200: return 0;
201: }
202: $this->streaming_msg = $this->get_raw_message_content($blob_id, $name);
203: return strlen($this->streaming_msg);
204: }
205:
206: /**
207: * Normally would stream one line from IMAP at a time, with JMAP it's the
208: * whole thing
209: * @param integer $size line size (ignored)
210: * @return string
211: */
212: public function read_stream_line($size=1024) {
213: $res = $this->streaming_msg;
214: $this->streaming_msg = false;
215: return $res;
216: }
217:
218: /**
219: * Create a new mailbox
220: * @param string $mailbox the mailbox name to create
221: * @return boolean
222: */
223: public function create_mailbox($mailbox) {
224: list($name, $parent) = $this->split_name_and_parent($mailbox);
225: $methods = array(array(
226: 'Mailbox/set',
227: array(
228: 'accountId' => $this->account_id,
229: 'create' => array(NULL => array('parentId' => $parent, 'name' => $name))
230: ),
231: 'cm'
232: ));
233: $created = $this->send_and_parse($methods, array(0, 1, 'created'), array());
234: $this->reset_folders();
235: return $created && count($created) > 0;
236: }
237:
238: /**
239: * Delete an existing mailbox
240: * @param string $mailbox the mailbox name to delete
241: * @return boolean
242: */
243: public function delete_mailbox($mailbox) {
244: $ids = array($this->folder_name_to_id($mailbox));
245: $methods = array(array(
246: 'Mailbox/set',
247: array(
248: 'accountId' => $this->account_id,
249: 'destroy' => $ids
250: ),
251: 'dm'
252: ));
253: $destroyed = $this->send_and_parse($methods, array(0, 1, 'destroyed'), array());
254: $this->reset_folders();
255: return $destroyed && count($destroyed) > 0;
256: }
257:
258: /**
259: * Rename a mailbox
260: * @param string $mailbox mailbox to rename
261: * @param string $new_mailbox new name
262: * @return boolean
263: */
264: public function rename_mailbox($mailbox, $new_mailbox) {
265: $id = $this->folder_name_to_id($mailbox);
266: list($name, $parent) = $this->split_name_and_parent($new_mailbox);
267: $methods = array(array(
268: 'Mailbox/set',
269: array(
270: 'accountId' => $this->account_id,
271: 'update' => array($id => array('parentId' => $parent, 'name' => $name))
272: ),
273: 'rm'
274: ));
275: $updated = $this->send_and_parse($methods, array(0, 1, 'updated'), array());
276: $this->reset_folders();
277: return $updated && count($updated) > 0;
278: }
279:
280: /**
281: * Return the IMAP connection state (authenticated, connected, etc)
282: * @return string
283: */
284: public function get_state() {
285: return $this->state;
286: }
287:
288: /**
289: * Return debug info about the JMAP session requests and responses
290: * @return array
291: */
292: public function show_debug() {
293: return array(
294: 'commands' => $this->requests,
295: 'responses' => $this->responses
296: );
297: }
298:
299: /**
300: * Fetch the first viewable message part of an E-mail
301: * @param string $uid message uid
302: * @param string $type the primary mime type
303: * @param string $subtype the secondary mime type
304: * @param array $struct The message structure
305: * @return array
306: */
307: public function get_first_message_part($uid, $type, $subtype=false, $struct=false) {
308: if (!$subtype) {
309: $flds = array('type' => $type);
310: }
311: else {
312: $flds = array('type' => $type, 'subtype' => $subtype);
313: }
314: $matches = $this->search_bodystructure($struct, $flds, false);
315: if (empty($matches)) {
316: return array(false, false);
317: }
318:
319: $subset = array_slice(array_keys($matches), 0, 1);
320: $msg_part_num = $subset[0];
321: return array($msg_part_num, $this->get_message_content($uid, $msg_part_num));
322: }
323:
324: /**
325: * Return a list of headers and UIDs for a page of a mailbox
326: * @param string $mailbox the mailbox to access
327: * @param string $sort sort order. can be one of ARRIVAL, DATE, CC, TO, SUBJECT, FROM, or SIZE
328: * @param string $filter type of messages to include (UNSEEN, ANSWERED, ALL, etc)
329: * @param int $limit max number of messages to return
330: * @param int $offset offset from the first message in the list
331: * @param string $keyword optional keyword to filter the results by
332: * @return array list of headers
333: */
334: public function get_mailbox_page($mailbox, $sort, $rev, $filter, $offset=0, $limit=0, $keyword=false) {
335: $this->select_mailbox($mailbox);
336: $mailbox_id = $this->folder_name_to_id($mailbox);
337: $filter = array('inMailbox' => $mailbox_id);
338: if ($keyword) {
339: $filter['hasKeyword'] = $keyword;
340: }
341: $methods = array(
342: array(
343: 'Email/query',
344: array(
345: 'accountId' => $this->account_id,
346: 'filter' => $filter,
347: 'sort' => array(array(
348: 'property' => $this->sorts[$sort],
349: 'isAscending' => $rev ? false : true
350: )),
351: 'position' => $offset,
352: 'limit' => $limit,
353: 'calculateTotal' => true
354: ),
355: 'gmp'
356: )
357: );
358: $res = $this->send_command($this->api_url, $methods);
359: $total = $this->search_response($res, array(0, 1, 'total'), 0);
360: $msgs = $this->get_message_list($this->search_response($res, array(0, 1, 'ids'), array()));
361: $this->setup_selected_mailbox($mailbox, $total);
362: return array($total, $msgs);
363: }
364:
365: /**
366: * Return all the folders contained at a hierarchy level, and if possible, if they have sub-folders
367: * @param string $level mailbox name or empty string for the top level
368: * @return array list of matching folders
369: */
370: public function get_folder_list_by_level($level=false) {
371: if (!$level) {
372: $level = '';
373: }
374: if (count($this->folder_list) == 0) {
375: $this->reset_folders();
376: }
377: return $this->parse_folder_list_by_level($level);
378: }
379:
380: /**
381: * Use the JMAP Quota/get method to fetch quota information for a specific quota root.
382: *
383: * This method sends a request to the JMAP server to retrieve quota information
384: * for a specified quota root (e.g., a specific folder like INBOX or Sent).
385: * The response is parsed to extract details about the quota, including the used space and hard limit.
386: *
387: * @param array $quota_root from get_quota_root
388: * @return array list of quota details
389: */
390: public function get_quota($quota_root='') {
391: if (!is_array($this->session)) {
392: throw new Exception("Not authenticated. Please authenticate first.");
393: }
394: $quotas = array();
395: $methods = [
396: [
397: "Quota/get",
398: [
399: "accountId"=> (string)$this->account_id,
400: "name" => $this->session['username'],
401: "ids" => [$quota_root]
402: ],
403: "0"
404: ]
405: ];
406: $response = $this->send_command($this->session['apiUrl'], $methods, 'POST');
407: if (!empty($response["methodResponses"][0][1]["list"])) {
408: $quota = $response["methodResponses"][0][1]["list"][0];
409: if (isset($quota['used']) && isset($quota['hardLimit'])) {
410: $quotas[] = [
411: 'name' => $quota['id'],
412: 'max' => floatval($quota['hardLimit']),
413: 'current' => floatval($quota['used']),
414: ];
415: }
416: }
417:
418:
419: foreach($quota_root as $key => $value) {
420: $quotas[$key] = $value;
421: }
422: return $quotas;
423: }
424:
425: /**
426: * Use the JMAP Quota/get method to fetch quota root information for all available quotas.
427: *
428: * This method sends a request to the JMAP server to retrieve quota information
429: * for all quotas associated with the account. The response is parsed to extract details about each quota,
430: * including the used space and hard limit.
431: *
432: * @param string $mailbox The mailbox identifier for which the quota information is being fetched.
433: * @return array An array of quota details
434: */
435: public function get_quota_root($mailbox) {
436: if (!is_array($this->session)) {
437: throw new Exception("Not authenticated. Please authenticate first.");
438: }
439: $quotas = array();
440: $methods = [
441: [
442: "Quota/get",
443: [
444: "accountId"=> (string)$this->account_id,
445: "name" => $this->session['username'],
446: "ids" => null,
447: "scope" => "folder",
448: "folder" => $mailbox
449: ],
450: "0"
451: ]
452: ];
453: $response = $this->send_command($this->session['apiUrl'], $methods, 'POST');
454: if (!empty($response["methodResponses"][0][1]["list"])) {
455: $quotasRes = $response["methodResponses"][0][1]["list"];
456: foreach($quotasRes as $quota) {
457: if (isset($quota['used']) && isset($quota['hardLimit'])) {
458: $quotas[] = [
459: 'name' => $quota['id'],
460: 'max' => floatval($quota['hardLimit']),
461: 'current' => floatval($quota['used']),
462: ];
463: }
464: }
465: }
466:
467: return $quotas;
468: }
469:
470: public function get_capability() {
471: //TODO: Implement
472: }
473: /**
474: * Return cached data
475: * @return array
476: */
477: public function dump_cache() {
478: return array(
479: $this->session,
480: $this->folder_list
481: );
482: }
483:
484: /**
485: * Load cached data
486: * @param array $data cache
487: * @return void
488: */
489: public function load_cache($data) {
490: $this->session = $data[0];
491: $this->folder_list = $data[1];
492: }
493:
494: /**
495: * "connect" to a JMAP server by testing an auth request
496: * @param array $cfg JMAP configuration
497: * @return boolean
498: */
499: public function connect($cfg) {
500: $this->build_headers($cfg['username'], $cfg['password']);
501: return $this->authenticate(
502: $cfg['username'],
503: $cfg['password'],
504: $cfg['server']
505: );
506: }
507:
508: /**
509: * Fake a disconnect. JMAP is stateless so there is no disconnect
510: * @return true
511: */
512: public function disconnect() {
513: return true;
514: }
515:
516: /**
517: * Attempt an auth to JMAP and record the session detail
518: * @param string $username user to login
519: * @param string $password user password
520: * @param string $url JMAP url
521: * @return boolean
522: */
523: public function authenticate($username, $password, $url) {
524: if (is_array($this->session)) {
525: $res = $this->session;
526: }
527: else {
528: $auth_url = $this->prep_url($url);
529: $res = $this->send_command($auth_url, array(), 'GET');
530: }
531: if (is_array($res) &&
532: array_key_exists('apiUrl', $res) &&
533: array_key_exists('accounts', $res)) {
534:
535: $this->init_session($res, $url);
536: return true;
537: }
538: return false;
539: }
540:
541: /**
542: * Fetch a list of all folders from JMAP
543: * @return array
544: */
545: public function get_folder_list() {
546: $methods = array(array(
547: 'Mailbox/get',
548: array(
549: 'accountId' => (string)$this->account_id,
550: 'ids' => NULL
551: ),
552: 'fl'
553: ));
554: return $this->send_and_parse($methods, array(0, 1, 'list'), array());
555: }
556:
557: /**
558: * Fake IMAP namespace support
559: * @return array
560: */
561: public function get_namespaces() {
562: return array(array(
563: 'class' => 'personal',
564: 'prefix' => false,
565: 'delim' => $this->delim
566: ));
567: }
568:
569: /**
570: * Fake selected a mailbox by getting it's current state
571: * @param string $mailbox mailbox to select
572: * @return true
573: */
574: public function select_mailbox($mailbox) {
575: $this->get_mailbox_status($mailbox);
576: $this->setup_selected_mailbox($mailbox, 0);
577: return true;
578: }
579:
580: /**
581: * Get a list of message headers for a set of uids
582: * @param array $uids list of uids
583: * @return array
584: */
585: public function get_message_list($uids) {
586: $result = array();
587: $body = array('size');
588: $flds = array('receivedAt', 'sender', 'replyTo', 'sentAt',
589: 'hasAttachment', 'size', 'keywords', 'id', 'subject', 'from', 'to', 'messageId');
590: $methods = array(array(
591: 'Email/get',
592: array(
593: 'accountId' => $this->account_id,
594: 'ids' => $uids,
595: 'properties' => $flds,
596: 'bodyProperties' => $body
597: ),
598: 'gml'
599: ));
600: foreach ($this->send_and_parse($methods, array(0, 1, 'list'), array()) as $msg) {
601: $result[] = $this->normalize_headers($msg);
602: }
603: return $result;
604: }
605:
606: /**
607: * Get the bodystructure of a message
608: * @param string $uid message uid
609: * @return array
610: */
611: public function get_message_structure($uid) {
612: $struct = $this->get_raw_bodystructure($uid);
613: $converted = $this->parse_bodystructure_response($struct);
614: return $converted;
615: }
616:
617: /**
618: * Get a message part or the raw message if the part is 0
619: * @param string $uid message uid
620: * @param string $message_part the IMAP messge part "number"
621: * @param int $max max size to return (ignored)
622: * @param array $struct message structure (ignored)
623: * @return string
624: */
625: public function get_message_content($uid, $message_part, $max=false, $struct=false) {
626: if ($message_part == 0) {
627: $methods = array(array(
628: 'Email/get',
629: array(
630: 'accountId' => $this->account_id,
631: 'ids' => array($uid),
632: 'properties' => array('blobId')
633: ),
634: 'gmc'
635: ));
636: $blob_id = $this->send_and_parse($methods, array(0, 1, 'list', 0, 'blobId'), false);
637: if (!$blob_id) {
638: return '';
639: }
640: return $this->get_raw_message_content($blob_id, 'message');
641: }
642: $methods = array(array(
643: 'Email/get',
644: array(
645: 'accountId' => $this->account_id,
646: 'ids' => array($uid),
647: 'fetchAllBodyValues' => true,
648: 'properties' => array('bodyValues')
649: ),
650: 'gmc'
651: ));
652: if (!$this->read_only) {
653: $this->message_action('READ', array($uid));
654: }
655: return $this->send_and_parse($methods, array(0, 1, 'list', 0, 'bodyValues', $message_part, 'value'));
656: }
657:
658: /**
659: * Search a field for a keyword
660: * @param string $target message types to search. can be ALL, UNSEEN, ANSWERED, etc
661: * @param mixed $uids an array of uids
662: * @param string $fld optional field to search
663: * @param string $term optional search term
664: * @param bool $exclude_deleted extra argument to exclude messages with the deleted flag (ignored)
665: * @param bool $exclude_auto_bcc don't include auto-bcc'ed messages (ignored)
666: * @param bool $only_auto_bcc only include auto-bcc'ed messages (ignored)
667: * @return array
668: */
669: public function search($target='ALL', $uids=false, $terms=array(), $esearch=array(), $exclude_deleted=true, $exclude_auto_bcc=true, $only_auto_bcc=false) {
670: $mailbox_id = $this->folder_name_to_id($this->selected_mailbox['detail']['name']);
671: $filter = array('inMailbox' => $mailbox_id);
672: if ($target == 'UNSEEN') {
673: $filter['notKeyword'] = '$seen';
674: }
675: elseif ($target == 'FLAGGED') {
676: $filter['hasKeyword'] = '$flagged';
677: }
678: if ($converted_terms = $this->process_imap_search_terms($terms)) {
679: $filter = $converted_terms + $filter;
680: }
681: $methods = array(
682: array(
683: 'Email/query',
684: array(
685: 'accountId' => $this->account_id,
686: 'filter' => $filter,
687: ),
688: 's'
689: )
690: );
691: return $this->send_and_parse($methods, array(0, 1, 'ids'), array());
692: }
693:
694: /**
695: * Get the full headers for an E-mail
696: * @param string $uid message uid
697: * @param string $message_part IMAP message part "number":
698: * @param boolean $raw (ignored)
699: * @return array
700: */
701: public function get_message_headers($uid, $message_part=false, $raw=false) {
702: $methods = array(array(
703: 'Email/get',
704: array(
705: 'accountId' => $this->account_id,
706: 'ids' => array($uid),
707: 'properties' => array('headers'),
708: 'bodyProperties' => array()
709: ),
710: 'gmh'
711: ));
712: $headers = $this->send_and_parse($methods, array(0, 1, 'list', 0, 'headers'), array());
713: $res = array();
714: if (is_array($headers)) {
715: foreach ($headers as $vals) {
716: if (array_key_exists($vals['name'], $res)) {
717: if (!is_array($res[$vals['name']])) {
718: $res[$vals['name']] = array($res[$vals['name']]);
719: }
720: $res[$vals['name']][] = $vals['value'];
721: }
722: else {
723: $res[$vals['name']] = $vals['value'];
724: }
725: }
726: }
727: return $res;
728: }
729:
730: /**
731: * Move, Copy, delete, or set a keyword on an E-mail
732: * @param string $action the actions to perform
733: * @param string $uid message uid
734: * @param string $mailbox the target mailbox
735: * @param string $keyword ignored)
736: * @return boolean
737: */
738: public function message_action($action, $uids, $mailbox=false, $keyword=false) {
739: $methods = array();
740: $key =false;
741: if (array_key_exists($action, $this->mod_map)) {
742: $methods = $this->modify_msg_methods($action, $uids);
743: $key = 'updated';
744: }
745: elseif ($action == 'DELETE') {
746: $methods = $this->delete_msg_methods($uids);
747: $key = 'destroyed';
748: }
749: elseif (in_array($action, array('MOVE', 'COPY'), true)) {
750: $methods = $this->move_copy_methods($action, $uids, $mailbox);
751: $key = 'updated';
752: }
753: if (!$key) {
754: return false;
755: }
756: $changed_uids = array_keys($this->send_and_parse($methods, array(0, 1, $key), array()));
757: return count($changed_uids) == count($uids);
758: }
759:
760: /**
761: * Search a bodystructure for a message part
762: * @param array $struct the structure to search
763: * @param string $search_term the search term
764: * @param array $search_flds list of fields to search for the term
765: * @return array
766: */
767: public function search_bodystructure($struct, $search_flds, $all=true, $res=array()) {
768: $this->struct_object = new Hm_IMAP_Struct(array(), $this);
769: $res = $this->struct_object->recursive_search($struct, $search_flds, $all, $res);
770: return $res;
771: }
772:
773: /**
774: * PRIVATE HELPER METHODS
775: */
776:
777: /**
778: * Build JMAP keyword args to move or copy messages
779: * @param string $action move or copy
780: * @param array $uids message uids to act on
781: * @param string $mailbox target mailbox
782: * @return array
783: */
784: private function move_copy_methods($action, $uids, $mailbox) {
785: if ($action == 'MOVE') {
786: $mailbox_ids = array('mailboxIds' => array($this->folder_name_to_id($mailbox) => true));
787: }
788: else {
789: $mailbox_ids = array('mailboxIds' => array(
790: $this->folder_name_to_id($this->selected_mailbox['detail']['name']) => true,
791: $this->folder_name_to_id($mailbox) => true));
792: }
793: $keywords = array();
794: foreach ($uids as $uid) {
795: $keywords[$uid] = $mailbox_ids;
796: }
797: return array(array(
798: 'Email/set',
799: array(
800: 'accountId' => $this->account_id,
801: 'update' => $keywords
802: ),
803: 'ma'
804: ));
805: }
806:
807: /**
808: * Build JMAP memthod for setting keywords
809: * @param string $action the keyword to set
810: * @param array $uids message uids
811: * @return array
812: */
813: private function modify_msg_methods($action, $uids) {
814: $keywords = array();
815: $jmap_keyword = $this->mod_map[$action];
816: foreach ($uids as $uid) {
817: $keywords[$uid] = array(sprintf('keywords/%s', $jmap_keyword[0]) => $jmap_keyword[1]);
818: }
819: return array(array(
820: 'Email/set',
821: array(
822: 'accountId' => $this->account_id,
823: 'update' => $keywords
824: ),
825: 'ma'
826: ));
827: }
828:
829: /**
830: * Build JMAP memthod for deleting an E-mail
831: * @param array $uids message uids
832: * @return array
833: */
834: private function delete_msg_methods($uids) {
835: return array(array(
836: 'Email/set',
837: array(
838: 'accountId' => $this->account_id,
839: 'destroy' => $uids
840: ),
841: 'ma'
842: ));
843: }
844:
845: /**
846: * Get the bodystructure from JMAP for a message
847: * @param string $uid message uid
848: * @return array
849: */
850: private function get_raw_bodystructure($uid) {
851: $methods = array(array(
852: 'Email/get',
853: array(
854: 'accountId' => $this->account_id,
855: 'ids' => array($uid),
856: 'properties' => array('bodyStructure')
857: ),
858: 'gbs'
859: ));
860: $res = $this->send_command($this->api_url, $methods);
861: return $this->search_response($res, array(0, 1, 'list', 0, 'bodyStructure'), array());
862: }
863:
864: /**
865: * Parse a bodystructure response and mimic the IMAP lib
866: * @param array $data raw bodstructure
867: * @return array
868: */
869: private function parse_bodystructure_response($data) {
870: $top = $this->translate_struct_keys($data);
871: if (array_key_exists('subParts', $data)) {
872: $top['subs'] = $this->parse_subs($data['subParts']);
873: }
874: if (array_key_exists('partId', $data)) {
875: return array($data['partId'] => $top);
876: }
877: return array($top);
878: }
879:
880: /**
881: * Recursive function to parse bodstructure sub parts
882: * @param array $data bodystructure
883: * @return array
884: */
885: private function parse_subs($data) {
886: $res = array();
887: foreach ($data as $sub) {
888: if ($sub['partId']) {
889: $this->msg_part_id = $sub['partId'];
890: }
891: else {
892: $sub['partId'] = $this->msg_part_id + 1;
893: }
894: $res[$sub['partId']] = $this->translate_struct_keys($sub);
895: if (array_key_exists('subParts', $sub)) {
896: $res[$sub['partId']]['subs'] = $this->parse_subs($sub['subParts']);
897: }
898: }
899: return $res;
900: }
901:
902: /**
903: * Translate bodystructure keys from JMAP to IMAP-ish
904: * @param array $part singe message part bodystructure
905: * @return array
906: */
907: private function translate_struct_keys($part) {
908: return array(
909: 'type' => explode('/', $part['type'])[0],
910: 'name' => $part['name'],
911: 'subtype' => explode('/', $part['type'])[1],
912: 'blob_id' => array_key_exists('blobId', $part) ? $part['blobId'] : false,
913: 'size' => $part['size'],
914: 'attributes' => array('charset' => $part['charset'])
915: );
916: }
917:
918: /**
919: * Convert JMAP headers for a message list to IMAP-ish
920: * @param array $msg headers for a message
921: * @return array
922: */
923: private function normalize_headers($msg) {
924: return array(
925: 'uid' => $msg['id'],
926: 'flags' => $this->keywords_to_flags($msg['keywords']),
927: 'internal_date' => $msg['receivedAt'],
928: 'size' => $msg['size'],
929: 'date' => $msg['sentAt'],
930: 'from' => (is_array($msg['from']) ? $this->combine_addresses($msg['from']) : $msg['from']),
931: 'to' => (is_array($msg['to']) ? $this->combine_addresses($msg['to']) : $msg['to']),
932: 'subject' => $msg['subject'],
933: 'content-type' => '',
934: 'timestamp' => strtotime($msg['receivedAt']),
935: 'charset' => '',
936: 'x-priority' => '',
937: 'type' => 'jmap',
938: 'references' => '',
939: 'message_id' => (is_array($msg['messageId']) ? implode(' ', $msg['messageId']) : ''),
940: 'x_auto_bcc' => ''
941: );
942: }
943:
944: /**
945: * Start a JMAP session
946: * @param array $data JMAP auth response
947: * @param string $url url to access JMAP
948: * @return void
949: */
950: private function init_session($data, $url) {
951: $this->state = 'authenticated';
952: $this->session = $data;
953: if (!str_contains($data['apiUrl'], $url)){
954: $this->api_url = sprintf(
955: '%s%s',
956: preg_replace("/\/$/", '', $url),
957: $data['apiUrl']
958: );
959: }else{
960: $this->api_url = $data['apiUrl'];
961: }
962: if (!str_contains($data['downloadUrl'], $url)){
963: $this->download_url = sprintf(
964: '%s%s',
965: preg_replace("/\/$/", '', $url),
966: $data['downloadUrl']
967: );
968: }else{
969: $this->download_url = $data['downloadUrl'];
970: }
971: if (!str_contains($data['uploadUrl'], $url)){
972: $this->upload_url = sprintf(
973: '%s%s',
974: preg_replace("/\/$/", '', $url),
975: $data['uploadUrl']
976: );
977: }else{
978: $this->upload_url = $data['uploadUrl'];
979: }
980: foreach ($data['accounts'] as $account) {
981: $this->account_id = array_keys($data['accounts'])[0];
982: }
983: if ($this->account_id && count($this->folder_list) == 0) {
984: $this->reset_folders();
985: }
986: }
987:
988: /**
989: * Convert JMAP keywords to an IMAP flag string
990: * @param array $keyworkds JMAP keywords
991: * @return string
992: */
993: private function keywords_to_flags($keywords) {
994: $flags = array();
995: if (array_key_exists('$seen', $keywords) && $keywords['$seen']) {
996: $flags[] = '\Seen';
997: }
998: if (array_key_exists('$flagged', $keywords) && $keywords['$flagged']) {
999: $flags[] = '\Flagged';
1000: }
1001: if (array_key_exists('$answered', $keywords) && $keywords['$answered']) {
1002: $flags[] = '\Answered';
1003: }
1004: return implode(' ', $flags);
1005: }
1006:
1007: /**
1008: * Combine parsed addresses
1009: * @param array $addr JMAP address field
1010: * @return string
1011: */
1012: private function combine_addresses($addrs) {
1013: $res = array();
1014: foreach ($addrs as $addr) {
1015: $res[] = implode(' ', $addr);
1016: }
1017: return implode(', ', $res);
1018: }
1019:
1020: /**
1021: * Allow callers to the JMAP API to override a default HTTP header
1022: * @param array $headers list of headers to override
1023: * @return array
1024: */
1025: private function merge_headers($headers) {
1026: $req_headers = $this->headers;
1027: foreach ($headers as $index => $val) {
1028: $req_headers[$index] = $val;
1029: }
1030: return $req_headers;
1031: }
1032:
1033: /**
1034: * Send a "command" or a set of methods to JMAP
1035: * @param string $url the JMAP url
1036: * @param array $methods the methods to run
1037: * @param string $method the HTTP method to use
1038: * @param array $post optional HTTP POST BOdy
1039: * @param array $headers custom HTTP headers
1040: * @return array
1041: */
1042: private function send_command($url, $methods=array(), $method='POST', $post=array(), $headers=array()) {
1043: $body = '';
1044: if (count($methods) > 0) {
1045: $body = $this->format_request($methods);
1046: }
1047: $headers = $this->merge_headers($headers);
1048: $this->requests[] = array($url, $headers, $body, $method, $post);
1049: return $this->api->command($url, $headers, $post, $body, $method);
1050: }
1051:
1052: /**
1053: * Search a JMAP response for what we care about
1054: * @param array $data the response
1055: * @param array $key_path the path to the key we want
1056: * @param mixed $default what to return if we don't find the key path
1057: * @return mixed
1058: */
1059: private function search_response($data, $key_path, $default=false) {
1060: array_unshift($key_path, 'methodResponses');
1061: foreach ($key_path as $key) {
1062: if (is_array($data) && array_key_exists($key, $data)) {
1063: $data = $data[$key];
1064: }
1065: else {
1066: Hm_Debug::add('Failed to find key path in response');
1067: Hm_Debug::add('key path: '.print_r($key_path, true));
1068: Hm_Debug::add('data: '.print_r($data, true));
1069: return $default;
1070: }
1071: }
1072: return $data;
1073: }
1074:
1075: /**
1076: * Send and parse a set of JMAP methods
1077: * @param array $methods JMAP methods to execute
1078: * @param array $key_path path to the response key we want
1079: * @param mixed $default what to return if we don't find the key path
1080: * @param string $method the HTTP method to use
1081: * @param array $post optional HTTP POST BOdy
1082: * @return mixed
1083: */
1084: private function send_and_parse($methods, $key_path, $default=false, $method='POST', $post=array()) {
1085: $res = $this->send_command($this->api_url, $methods, $method, $post);
1086: $this->responses[] = $res;
1087: return $this->search_response($res, $key_path, $default);
1088: }
1089:
1090: /**
1091: * Format a set of JMAP methods
1092: * @param array $methods methods to formamt
1093: * @param array $caps optional capability override
1094: * @return array
1095: */
1096: private function format_request($methods, $caps=array()) {
1097: return json_encode(array(
1098: 'using' => count($caps) == 0 ? $this->default_caps : $caps,
1099: 'methodCalls' => $methods
1100: ), JSON_UNESCAPED_SLASHES);
1101: }
1102:
1103: /**
1104: * Build default HTTP headers for a JMAP request
1105: * @param string $user username
1106: * @param string $pass password
1107: * @return void
1108: */
1109: private function build_headers($user, $pass) {
1110: $this->headers = array(
1111: 'Authorization: Basic '. base64_encode(sprintf('%s:%s', $user, $pass)),
1112: 'Cache-Control: no-cache, no-store, must-revalidate',
1113: 'Content-Type: application/json',
1114: 'Accept: application/json'
1115: );
1116: }
1117:
1118: /**
1119: * Prep a URL for JMAP discover
1120: * @param string $url JMAP url
1121: * @return string
1122: */
1123: private function prep_url($url) {
1124: $url = preg_replace("/\/$/", '', $url);
1125: return sprintf('%s/.well-known/jmap/', $url);
1126: }
1127:
1128: /**
1129: * Make a mailbox look "selected" like in IMAP
1130: * @param string $mailbox mailbox name
1131: * @param integer $total total messages in the mailbox
1132: * @return void
1133: */
1134: private function setup_selected_mailbox($mailbox, $total=0) {
1135: if ($total == 0 && count($this->folder_state) > 0) {
1136: $total = $this->folder_state['messages'];
1137: }
1138: $this->selected_mailbox = array('detail' => array(
1139: 'selected' => 1,
1140: 'name' => $mailbox,
1141: 'exists' => $total
1142: ));
1143: }
1144:
1145: /**
1146: * Filter a folder list by a parent folder
1147: * @param string $level parent folder
1148: * @return array
1149: */
1150: private function parse_folder_list_by_level($level) {
1151: $result = array();
1152: foreach ($this->folder_list as $name => $folder) {
1153: if ($folder['level'] == $level) {
1154: $result[$name] = $folder;
1155: }
1156: }
1157: return $result;
1158: }
1159:
1160: /**
1161: * Parse JMAP folders to make them more IMAP-ish
1162: * @param array $data folder list
1163: * @return array
1164: */
1165: private function parse_imap_folders($data) {
1166: $lookup = array();
1167: foreach ($data as $vals) {
1168: $vals['children'] = false;
1169: $lookup[$vals['id']] = $vals;
1170: }
1171: foreach ($data as $vals) {
1172: if ($vals['parentId']) {
1173: $parents = $this->get_parent_recursive($vals, $lookup, $parents=array());
1174: $level = implode($this->delim, $parents);
1175: $parents[] = $vals['name'];
1176: $lookup[$vals['parentId']]['children'] = true;
1177: }
1178: else {
1179: $parents = array($vals['name']);
1180: $level = '';
1181: }
1182: $lookup[$vals['id']]['basename'] = $vals['name'];
1183: $lookup[$vals['id']]['level'] = $level;
1184: $lookup[$vals['id']]['name_parts'] = $parents;
1185: $lookup[$vals['id']]['name'] = implode($this->delim, $parents);
1186: }
1187: return $this->build_imap_folders($lookup);
1188: }
1189:
1190: /**
1191: * Make JMAP folders more IMAP-ish
1192: * @param array $data modified JMAP folder list
1193: * @return array
1194: */
1195: private function build_imap_folders($data) {
1196: $result = array();
1197: foreach ($data as $vals) {
1198: if (mb_strtolower($vals['name']) == 'inbox') {
1199: $vals['name'] = 'INBOX';
1200: }
1201: $result[$vals['name']] = array(
1202: 'delim' => $this->delim,
1203: 'level' => $vals['level'],
1204: 'basename' => $vals['basename'],
1205: 'children' => $vals['children'],
1206: 'name' => $vals['name'],
1207: 'type' => 'jmap',
1208: 'noselect' => false,
1209: 'clickable' => true,
1210: 'id' => $vals['id'],
1211: 'role' => $vals['role'],
1212: 'name_parts' => $vals['name_parts'],
1213: 'messages' => $vals['totalEmails'],
1214: 'unseen' => $vals['unreadEmails']
1215: );
1216: }
1217: return $result;
1218: }
1219:
1220: /**
1221: * Recursively get all parents to a folder
1222: * @param array $vals folders
1223: * @param array $lookup easy lookup list
1224: * @param array $parents array of parents
1225: * @return array
1226: */
1227: private function get_parent_recursive($vals, $lookup, $parents) {
1228: $vals = $lookup[$vals['parentId']];
1229: $parents[] = $vals['name'];
1230: if ($vals['parentId']) {
1231: $parents = $this->get_parent_recursive($vals, $lookup, $parents);
1232: }
1233: return $parents;
1234: }
1235:
1236: /**
1237: * Convert an IMAP folder name to a JMAP ID
1238: * @param string $name folder name
1239: * @return string|false
1240: */
1241: private function folder_name_to_id($name) {
1242: if (count($this->folder_list) == 0 || !array_key_exists($name, $this->folder_list)) {
1243: $this->reset_folders();
1244: }
1245: if (array_key_exists($name, $this->folder_list)) {
1246: return $this->folder_list[$name]['id'];
1247: }
1248: return false;
1249: }
1250:
1251: /**
1252: * Re-fetch folders from JMAP
1253: * @return void
1254: */
1255: private function reset_folders() {
1256: $this->folder_list = $this->parse_imap_folders($this->get_folder_list());
1257: }
1258:
1259: /**
1260: * Convert IMAP search terms to JMAP
1261: * @param array $terms search terms
1262: * @return array|false
1263: */
1264: private function process_imap_search_terms($terms) {
1265: $converted_terms = array();
1266: $map = array(
1267: 'SINCE' => 'after',
1268: 'SENTSINCE' => 'after', // JMAP protocol does not seem to support searching by sentAt date, so we resort to receivedAt date
1269: 'SUBJECT' => 'subject',
1270: 'TO' => 'to',
1271: 'FROM' => 'from',
1272: 'BODY' => 'body',
1273: 'TEXT' => 'text'
1274: );
1275: foreach ($terms as $vals) {
1276: if (array_key_exists($vals[0], $map)) {
1277: if ($vals[0] == 'SINCE' || $vals[0] == 'SENTSINCE') {
1278: $vals[1] = gmdate("Y-m-d\TH:i:s\Z", strtotime($vals[1]));
1279: }
1280: $converted_terms[$map[$vals[0]]] = $vals[1];
1281: }
1282: }
1283: return count($converted_terms) > 0 ? $converted_terms : false;
1284: }
1285:
1286: /**
1287: * Upload data in memory as a file to JMAP (Used to replicate IMAP APPEND)
1288: * @param string $string file contents
1289: * @return string|false
1290: */
1291: private function upload_file($string) {
1292: $upload_url = str_replace('{accountId}', $this->account_id, $this->upload_url);
1293: $res = $this->send_command($upload_url, array(), 'POST', $string, array(2 => 'Content-Type: message/rfc822'));
1294: if (!is_array($res) || !array_key_exists('blobId', $res)) {
1295: return false;
1296: }
1297: return $res['blobId'];
1298: }
1299:
1300: /**
1301: * Split an IMAP style folder name into the parent and child
1302: * @param string $mailbox folder name
1303: * @return array
1304: */
1305: private function split_name_and_parent($mailbox) {
1306: $parent = NULL;
1307: $parts = explode($this->delim, $mailbox);
1308: $name = array_pop($parts);
1309: if (count($parts) > 0) {
1310: $parent = $this->folder_name_to_id(implode($this->delim, $parts));
1311: }
1312: return array($name, $parent);
1313: }
1314:
1315: /**
1316: * Get the detail needed to download a message or message part
1317: * @param string $uid message uid
1318: * @param string $message_part IMAP message "number"
1319: * @return array
1320: */
1321: private function download_details($uid, $message_part) {
1322: $blob_id = false;
1323: $name = false;
1324: $struct = $this->get_message_structure($uid);
1325: $part_struct = $this->search_bodystructure($struct, array('imap_part_number' => $message_part));
1326: if (is_array($part_struct) && array_key_exists($message_part, $part_struct)) {
1327: if (array_key_exists('blob_id', $part_struct[$message_part])) {
1328: $blob_id = $part_struct[$message_part]['blob_id'];
1329: }
1330: if (array_key_exists('name', $part_struct[$message_part]) && $part_struct[$message_part]['name']) {
1331: $name = $part_struct[$message_part]['name'];
1332: }
1333: else {
1334: $name = sprintf('message_%s', $message_part);
1335: }
1336: }
1337: return array($blob_id, $name);
1338: }
1339:
1340: /**
1341: * Download a raw message or raw message part
1342: * @param string $blob_id message blob id
1343: * @param string name for the downloaded file
1344: * @return string
1345: */
1346: private function get_raw_message_content($blob_id, $name) {
1347: $download_url = str_replace(
1348: array('{accountId}', '{blobId}', '{name}', '{type}'),
1349: array(
1350: urlencode($this->account_id),
1351: urlencode($blob_id),
1352: urlencode($name),
1353: urlencode('application/octet-stream')
1354: ),
1355: $this->download_url
1356: );
1357: $this->api->format = 'binary';
1358: $res = $this->send_command($download_url, array(), 'GET');
1359: $this->api->format = 'json';
1360: return $res;
1361: }
1362: }
1363: