1: <?php
2:
3: /**
4: * EWS integration
5: * @package modules
6: * @subpackage core
7: *
8: * This is a drop-in replacment of IMAP, JMAP and SMTP classes that allows usage of Exchange Web Services (EWS)
9: * in all functions provided in imap and smtp modules - accessing mailbox, folders, reading messages,
10: * attachments, moving, copying, read/unread, flags, sending messages.
11: * Connection to EWS is handled by garethp/php-ews package handling NLTM auth and SOAP calls.
12: */
13:
14: use garethp\ews\API\Enumeration;
15: use garethp\ews\API\Enumeration\DistinguishedFolderIdNameType;
16: use garethp\ews\API\Exception;
17: use garethp\ews\API\ExchangeWebServices;
18: use garethp\ews\API\ItemUpdateBuilder;
19: use garethp\ews\API\Type;
20: use garethp\ews\MailAPI;
21: use garethp\ews\Utilities;
22:
23: use ZBateson\MailMimeParser\MailMimeParser;
24:
25: /**
26: * public interface to EWS mailboxes
27: * @subpackage imap/lib
28: */
29: class Hm_EWS {
30: protected $ews;
31: protected $api;
32: protected $authed = false;
33:
34: // Extended property tags and their values defined in MS-OXOFLAG, MS-OXPROPS, MS-OXOMSG, MS-OXCMSG specs
35: const PID_TAG_FLAG_STATUS = 0x1090;
36: const PID_TAG_FLAG_FLAGGED = 0x00000002;
37: const PID_TAG_ICON_INDEX = 0x1080;
38: const PID_TAG_ICON_REPLIED = 0x00000105;
39: const PID_TAG_MESSAGE_FLAGS = 0x0E07;
40: const PID_TAG_MESSAGE_READ = 0x00000001;
41: const PID_TAG_MESSAGE_DRAFT = 0x00000008;
42:
43: public function connect(array $config) {
44: try {
45: $this->ews = ExchangeWebServices::fromUsernameAndPassword($config['server'], $config['username'], $config['password'], ['version' => ExchangeWebServices::VERSION_2016, 'trace' => 1]);
46: $this->api = new MailAPI($this->ews);
47: $this->api->getFolderByDistinguishedId(Enumeration\DistinguishedFolderIdNameType::INBOX);
48: $this->authed = true;
49: return true;
50: } catch (Exception\UnauthorizedException | \SoapFault $e) {
51: return false;
52: }
53: }
54:
55: public function authed() {
56: return $this->authed;
57: }
58:
59: public function get_capability() {
60: // IMAP extra capabilities not supported here
61: return '';
62: }
63:
64: public function get_folders($folder = null, $only_subscribed = false, $unsubscribed_folders = [], $with_input = false) {
65: $result = [];
66: if (empty($folder)) {
67: $folder = new Type\DistinguishedFolderIdType(Enumeration\DistinguishedFolderIdNameType::MESSAGE_ROOT);
68: } else {
69: $folder = new Type\FolderIdType($folder);
70: }
71: $request = array(
72: 'Traversal' => 'Shallow',
73: 'FolderShape' => array(
74: 'BaseShape' => 'AllProperties',
75: ),
76: 'ParentFolderIds' => $folder->toArray(true)
77: );
78: $resp = $this->ews->FindFolder($request);
79: $folders = $resp->getFolders()->getFolder();
80: if ($folders) {
81: $special = $this->get_special_use_folders();
82: if ($folders instanceof Type\FolderType) {
83: $folders = [$folders];
84: }
85: foreach($folders as $folder) {
86: $id = $folder->getFolderId()->getId();
87: $parentId = $folder->getParentFolderId()->getId();
88: $name = $folder->getDisplayName();
89: if ($only_subscribed && in_array($id, $unsubscribed_folders)) {
90: continue;
91: }
92: $result[$id] = array(
93: 'id' => $id,
94: 'parent' => $parentId,
95: 'delim' => false,
96: 'name' => $name,
97: 'name_parts' => [],
98: 'basename' => $name,
99: 'realname' => $name,
100: 'namespace' => '',
101: 'marked' => false, // doesn't seem to be used anywhere but imap returns it
102: 'noselect' => false, // all EWS folders are selectable
103: 'can_have_kids' => true,
104: 'has_kids' => $folder->getChildFolderCount() > 0,
105: 'children' => $folder->getChildFolderCount(),
106: 'special' => in_array($id, $special),
107: 'clickable' => ! $with_input && ! in_array($id, $unsubscribed_folders),
108: 'subscribed' => ! in_array($id, $unsubscribed_folders),
109: );
110: }
111: }
112: return $result;
113: }
114:
115: public function get_special_use_folders($folder = false) {
116: $special = [
117: 'trash' => Enumeration\DistinguishedFolderIdNameType::DELETED,
118: 'sent' => Enumeration\DistinguishedFolderIdNameType::SENT,
119: 'flagged' => false,
120: 'all' => false,
121: 'junk' => Enumeration\DistinguishedFolderIdNameType::JUNKEMAIL,
122: 'archive' => false,
123: 'drafts' => Enumeration\DistinguishedFolderIdNameType::DRAFTS,
124: ];
125: foreach ($special as $type => $folderId) {
126: if ($folderId) {
127: try {
128: $distinguishedFolder = $this->api->getFolderByDistinguishedId($folderId);
129: if ($distinguishedFolder) {
130: $special[$type] = $distinguishedFolder->getFolderId()->getId();
131: }
132: } catch (\Exception $e) {
133: Hm_Msgs::add($e->getMessage(), 'danger');
134: }
135: }
136: }
137: $special = array_filter($special);
138: if (isset($special[$folder])) {
139: return [$folder => $special[$folder]];
140: } else {
141: return $special;
142: }
143: }
144:
145: public function get_folder_name_quick($folder) {
146: if ($this->is_distinguished_folder($folder)) {
147: return $folder;
148: } else {
149: return false;
150: }
151: }
152:
153: public function get_folder_status($folder, $report_error = true) {
154: try {
155: if ($this->is_distinguished_folder($folder)) {
156: $folderObj = new Type\DistinguishedFolderIdType($folder);
157: $result = $this->api->getFolder($folderObj->toArray(true));
158: } else {
159: $folderObj = new Type\FolderIdType($folder);
160: $result = $this->api->getFolder($folderObj->toArray(true));
161: }
162: return [
163: 'id' => $result->getFolderId()->getId(),
164: 'name' => $result->getDisplayName(),
165: 'messages' => $result->getTotalCount(),
166: 'uidvalidity' => false,
167: 'uidnext' => false,
168: 'recent' => false,
169: 'unseen' => $result->getUnreadCount(),
170: ];
171: } catch (Exception\ExchangeException $e) {
172: // since this is used for missing folders check, we skip error reporting
173: return [];
174: } catch (\Exception $e) {
175: if ($report_error && $e->getMessage()) {
176: Hm_Msgs::add($e->getMessage(), 'danger');
177: }
178: return [];
179: }
180: }
181:
182: public function create_folder($folder, $parent = null) {
183: if (empty($parent)) {
184: $parent = new Type\DistinguishedFolderIdType(Enumeration\DistinguishedFolderIdNameType::MESSAGE_ROOT);
185: } else {
186: $parent = new Type\FolderIdType($parent);
187: }
188: try {
189: $request = [
190: 'Folders' => ['Folder' => [
191: 'DisplayName' => $folder
192: ]],
193: 'ParentFolderId' => $parent->toArray(true),
194: ];
195: $result = $this->ews->CreateFolder($request);
196: return $result->getId();
197: } catch(\Exception $e) {
198: Hm_Msgs::add($e->getMessage(), 'danger');
199: return false;
200: }
201: }
202:
203: public function rename_folder($folder, $new_name, $parent = null) {
204: $result = [];
205: if ($this->is_distinguished_folder($folder)) {
206: $folderObj = new Type\DistinguishedFolderIdType($folder);
207: } else {
208: $folderObj = new Type\FolderIdType(hex2bin($folder));
209: }
210: $new_folder = new Type\FolderType();
211: $new_folder->displayName = $new_name;
212: $request = [
213: 'FolderChanges' => [
214: 'FolderChange' => [
215: 'FolderId' => $folderObj->toArray(false),
216: 'Updates' => [
217: 'SetFolderField' => [
218: 'FieldURI' => [
219: 'FieldURI' => 'folder:DisplayName',
220: ],
221: 'Folder' => $new_folder,
222: ],
223: ],
224: ],
225: ],
226: ];
227: try {
228: $request = Type::buildFromArray($request);
229: $this->ews->UpdateFolder($request);
230: } catch (\Exception $e) {
231: Hm_Msgs::add($e->getMessage(), 'danger');
232: return false;
233: }
234: if ($parent) {
235: if ($this->is_distinguished_folder($parent)) {
236: $parentObj = new Type\DistinguishedFolderIdType($parent);
237: } else {
238: // Convert hex ID to binary for EWS
239: $parentObj = new Type\FolderIdType(hex2bin($parent));
240: }
241: $request = [
242: 'FolderIds' => Utilities\getFolderIds([$folderObj]),
243: 'ToFolderId' => $parentObj->toArray(true),
244: ];
245: try {
246: $request = Type::buildFromArray($request);
247: $this->ews->MoveFolder($request);
248: } catch (\Exception $e) {
249: Hm_Msgs::add($e->getMessage(), 'danger');
250: return false;
251: }
252: }
253: return true;
254: }
255:
256: public function delete_folder($folder) {
257: try {
258: return $this->api->deleteFolder(new Type\FolderIdType(hex2bin($folder)));
259: } catch(\Exception $e) {
260: Hm_Msgs::add($e->getMessage(), 'danger');
261: return false;
262: }
263: }
264:
265: public function send_message($from, $recipients, $message, $delivery_receipt = false) {
266: try {
267: $msg = new Type\MessageType();
268: $msg->setFrom($from);
269: $msg->setToRecipients($recipients);
270: $mimeContent = Type\MimeContentType::buildFromArray([
271: 'CharacterSet' => 'UTF-8',
272: '_' => base64_encode($message)
273: ]);
274: $msg->setMimeContent($mimeContent);
275:
276: if ($delivery_receipt) {
277: $msg->setIsDeliveryReceiptRequested($delivery_receipt);
278: }
279: $this->api->sendMail($msg, [
280: 'MessageDisposition' => 'SendOnly', // saving to sent items is handled by the imap module depending on the chosen sent folder
281: ]);
282: return;
283: } catch (\Exception $e) {
284: return $e->getMessage();
285: }
286: }
287:
288: public function store_message($folder, $message, $seen = true, $draft = false) {
289: try {
290:
291: if ($this->is_distinguished_folder($folder)) {
292: $folderObj = new Type\DistinguishedFolderIdType($folder);
293: } else {
294: $folderObj = new Type\FolderIdType($folder);
295: }
296: $msg = new Type\MessageType();
297: $mimeContent = Type\MimeContentType::buildFromArray([
298: 'CharacterSet' => 'UTF-8',
299: '_' => base64_encode($message)
300: ]);
301: $msg->setMimeContent($mimeContent);
302:
303: $flags = 0;
304: if ($seen) {
305: $flags |= self::PID_TAG_MESSAGE_READ;
306: }
307: if ($draft) {
308: $flags |= self::PID_TAG_MESSAGE_DRAFT;
309: }
310: $extendedFieldURI = Type\PathToExtendedFieldType::buildFromArray([
311: 'PropertyTag' => self::PID_TAG_MESSAGE_FLAGS,
312: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
313: ]);
314:
315: $extendedProperty = Type\ExtendedPropertyType::buildFromArray([
316: 'ExtendedFieldURI' => $extendedFieldURI,
317: 'Value' => $flags,
318: ]);
319: $msg->addExtendedProperty($extendedProperty);
320:
321: $result = $this->api->sendMail($msg, [
322: 'MessageDisposition' => 'SaveOnly',
323: 'SavedItemFolderId' => $folderObj->toArray(true),
324: ]);
325: return bin2hex($result->getId());
326: } catch (\Exception $e) {
327: Hm_Msgs::add($e->getMessage(), 'danger');
328: return false;
329: }
330: }
331:
332: /**
333: * Performs an EWS search using FindItem operation and supplies sorting + pagination arguments.
334: * Search can be perfomed using Advanced Query Syntax when keyword is an array containing terms
335: * searching in specific fields (e.g. advanced search) or Restrictions list when requesting
336: * filtering by extended properties as answered or unanswered emails.
337: */
338: public function search($folder, $sort, $reverse, $flag_filter, $offset, $limit, $keyword, $trusted_senders) {
339: $lower_folder = strtolower($folder);
340: if ($this->is_distinguished_folder($lower_folder)) {
341: $folderObj = new Type\DistinguishedFolderIdType($lower_folder);
342: } elseif (ctype_xdigit($folder)) {
343: $folderObj = new Type\FolderIdType(hex2bin($folder));
344: } else {
345: $folderObj = new Type\FolderIdType($folder);
346: }
347: $request = array(
348: 'Traversal' => 'Shallow',
349: 'ItemShape' => array(
350: 'BaseShape' => 'IdOnly'
351: ),
352: 'IndexedPageItemView' => [
353: 'MaxEntriesReturned' => $limit,
354: 'Offset' => $offset,
355: 'BasePoint' => 'Beginning',
356: ],
357: 'ParentFolderIds' => $folderObj->toArray(true)
358: );
359: if (! empty($sort)) {
360: switch ($sort) {
361: case 'ARRIVAL':
362: $fieldURI = 'item:DateTimeCreated';
363: break;
364: case 'DATE':
365: $fieldURI = 'item:DateTimeReceived';
366: break;
367: case 'CC':
368: // TODO: figure out a way to sort by something not availalbe in FindItem operation
369: $fieldURI = null;
370: break;
371: case 'TO':
372: // TODO: figure out a way to sort by something not availalbe in FindItem operation
373: $fieldURI = null;
374: break;
375: case 'SUBJECT':
376: $fieldURI = 'item:Subject';
377: break;
378: case 'FROM':
379: $fieldURI = 'message:From';
380: break;
381: case 'SIZE':
382: $fieldURI = 'item:Size';
383: break;
384: default:
385: $fieldURI = null;
386: }
387: if ($fieldURI) {
388: $request['SortOrder'] = [
389: 'FieldOrder' => [
390: 'Order' => $reverse ? 'Descending' : 'Ascending',
391: 'FieldURI' => [
392: 'FieldURI' => $fieldURI,
393: ],
394: ]
395: ];
396: }
397: }
398: $qs = [];
399: if (is_array($keyword)) {
400: foreach ($keyword as $term) {
401: switch ($term[0]) {
402: case 'SINCE':
403: $qs[] = "Received:>$term[1]";
404: break;
405: case 'SENTSINCE':
406: $qs[] = "Sent:>$term[1]";
407: break;
408: case 'FROM':
409: $qs[] = "From:($term[1])";
410: break;
411: case 'TO':
412: $qs[] = "To:($term[1])";
413: break;
414: case 'CC':
415: $qs[] = "Cc:($term[1])";
416: break;
417: case 'TEXT':
418: $qs[] = "(Subject:($term[1]) OR Body:($term[1]))";
419: break;
420: case 'BODY':
421: $qs[] = "Body:($term[1])";
422: break;
423: case 'SUBJECT':
424: $qs[] = "Subject:($term[1])";
425: break;
426: default:
427: // noop
428: }
429: }
430: } elseif (! empty($keyword)) {
431: $qs[] = $keyword;
432: }
433: switch ($flag_filter) {
434: case 'UNSEEN':
435: $qs[] = 'isRead:false';
436: break;
437: case 'SEEN':
438: $qs[] = 'isRead:true';
439: break;
440: case 'FLAGGED':
441: $qs[] = 'isFlagged:true';
442: break;
443: case 'UNFLAGGED':
444: $qs[] = 'isFlagged:false';
445: break;
446: case 'ANSWERED':
447: $request['Restriction'] = [
448: 'IsEqualTo' => [
449: 'ExtendedFieldURI' => [
450: 'PropertyTag' => self::PID_TAG_ICON_INDEX,
451: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
452: ],
453: 'FieldURIOrConstant' => [
454: 'Constant' => ['Value' => self::PID_TAG_ICON_REPLIED],
455: ],
456: ],
457: ];
458: break;
459: case 'UNANSWERED':
460: $request['Restriction'] = [
461: 'IsNotEqualTo' => [
462: 'ExtendedFieldURI' => [
463: 'PropertyTag' => self::PID_TAG_ICON_INDEX,
464: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
465: ],
466: 'FieldURIOrConstant' => [
467: 'Constant' => ['Value' => self::PID_TAG_ICON_REPLIED],
468: ],
469: ],
470: ];
471: break;
472: break;
473: case 'ALL':
474: default:
475: // noop
476: }
477: if ($qs && empty($request['Restriction'])) {
478: $request['QueryString'] = implode(' AND ', $qs);
479: } elseif ($keyword && ! empty($request['Restriction'])) {
480: $restriction = ['And' => $request['Restriction']];
481: $restriction['And']['Or'] = [
482: [
483: 'Contains' => [
484: 'FieldURI' => ['FieldURI' => 'item:Subject'],
485: 'Constant' => ['Value' => $keyword],
486: ],
487: ],
488: [
489: 'Contains' => [
490: 'FieldURI' => ['FieldURI' => 'item:Body'],
491: 'Constant' => ['Value' => $keyword],
492: ],
493: ],
494: ];
495: $request['Restriction'] = $restriction;
496: }
497: $request = Type::buildFromArray($request);
498: $result = $this->ews->FindItem($request);
499: $messages = $result->getItems()->getMessage() ?? [];
500: if ($messages instanceof Type\MessageType) {
501: $messages = [$messages];
502: }
503: $itemIds = array_map(function($msg) {
504: return bin2hex($msg->getItemId()->getId());
505: }, $messages);
506: return [$result->getTotalItemsInView(), $itemIds];
507: }
508:
509: public function get_messages($folder, $sort, $reverse, $flag_filter, $offset, $limit, $keyword, $trusted_senders, $include_preview = false) {
510: list ($total, $itemIds) = $this->search($folder, $sort, $reverse, $flag_filter, $offset, $limit, $keyword, $trusted_senders);
511: return [$total, $this->get_message_list($itemIds, $include_preview)];
512: }
513:
514: public function get_message_list($itemIds, $include_preview = false) {
515: if (empty($itemIds)) {
516: return [];
517: }
518: $request = array(
519: 'ItemShape' => array(
520: 'BaseShape' => 'AllProperties',
521: 'AdditionalProperties' => [
522: 'ExtendedFieldURI' => [
523: [
524: 'PropertyTag' => self::PID_TAG_FLAG_STATUS, //check flagged msg
525: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
526: ],
527: [
528: 'PropertyTag' => self::PID_TAG_ICON_INDEX, // check if replied/answered
529: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
530: ],
531: ],
532: ],
533: ),
534: 'ItemIds' => [
535: 'ItemId' => array_map(function($id) {
536: return ['Id' => hex2bin($id)];
537: }, $itemIds),
538: ],
539: );
540: $request = Type::buildFromArray($request);
541: $result = $this->ews->GetItem($request);
542: if ($result instanceof Type\MessageType) {
543: $result = [$result];
544: }
545: $messages = [];
546: foreach ($result as $message) {
547: $flags = $this->extract_flags($message);
548: $uid = bin2hex($message->getItemId()->getId());
549: $msg = [
550: 'uid' => $uid,
551: 'flags' => implode(' ', $flags),
552: 'internal_date' => $message->getDateTimeCreated()->format('d-M-Y H:i:s O'),
553: 'size' => $message->getSize(),
554: 'date' => $message->getDateTimeReceived()->format('d-M-Y H:i:s O'),
555: 'from' => $this->extract_mailbox($message->getFrom()),
556: 'to' => $this->extract_mailbox($message->getToRecipients()),
557: 'subject' => $message->getSubject(),
558: 'content-type' => null,
559: 'timestamp' => time(),
560: 'charset' => null,
561: 'x-priority' => null,
562: 'google_msg_id' => null,
563: 'google_thread_id' => null,
564: 'google_labels' => null,
565: 'list_archive' => null,
566: 'references' => $message->getReferences(),
567: 'message_id' => $message->getInternetMessageId(),
568: 'x_auto_bcc' => null,
569: 'x_snoozed' => null,
570: 'x_schedule' => null,
571: 'x_profile_id' => null,
572: 'x_delivery' => null,
573: ];
574: foreach ($message->getInternetMessageHeaders() as $header) {
575: foreach (['x-gm-msgid' => 'google_msg_id', 'x-gm-thrid' => 'google_thread_id', 'x-gm-labels' => 'google_labels', 'x-auto-bcc' => 'x_auto_bcc', 'message-id' => 'message_id', 'references' => 'references', 'x-snoozed' => 'x_snoozed', 'x-schedule' => 'x_schedule', 'x-profile-id' => 'x_profile_id', 'x-delivery' => 'x_delivery', 'list-archive' => 'list_archive', 'content-type' => 'content-type', 'x-priority' => 'x-priority'] as $hname => $key) {
576: if (strtolower($header->getHeaderName()) == $hname) {
577: $msg[$key] = (string) $header;
578: }
579: }
580: }
581: $cset = '';
582: if (mb_stristr($msg['content-type'], 'charset=')) {
583: if (preg_match("/charset\=([^\s;]+)/", $msg['content-type'], $matches)) {
584: $cset = trim(mb_strtolower(str_replace(array('"', "'"), '', $matches[1])));
585: }
586: }
587: $msg['charset'] = $cset;
588: $msg['preview_msg'] = $include_preview ? strip_tags($message->getBody()) : "";
589: $messages[$uid] = $msg;
590: }
591: return $messages;
592: }
593:
594: public function message_action($action, $itemIds, $folder=false, $keyword=false) {
595: if (empty($itemIds)) {
596: return ['status' => true, 'responses' => []];
597: }
598: if (! is_array($itemIds)) {
599: $itemIds = [$itemIds];
600: }
601: $change = null;
602: $status = false;
603: $responses = [];
604: switch ($action) {
605: case 'ARCHIVE':
606: $status = $this->archive_items($itemIds);
607: break;
608: case 'JUNK' :
609: $status = $this->move_items_to_junk($itemIds);
610: break;
611: case 'DELETE':
612: $status = $this->delete_items($itemIds);
613: break;
614: case 'HARDDELETE':
615: $status = $this->delete_items($itemIds, true);
616: break;
617: case 'COPY':
618: $newIds = $this->copy_items($itemIds, $folder);
619: if ($newIds) {
620: foreach ($newIds as $key => $newId) {
621: $responses[] = ['oldUid' => $itemIds[$key], 'newUid' => $newId];
622: }
623: $status = true;
624: }
625: break;
626: case 'MOVE':
627: $newIds = $this->move_items($itemIds, $folder);
628: if ($newIds) {
629: foreach ($newIds as $key => $newId) {
630: $responses[] = ['oldUid' => $itemIds[$key], 'newUid' => $newId];
631: }
632: $status = true;
633: }
634: break;
635: case 'READ':
636: $change = ItemUpdateBuilder::buildUpdateItemChanges('Message', 'message', ['IsRead' => true]);
637: break;
638: case 'UNREAD':
639: $change = ItemUpdateBuilder::buildUpdateItemChanges('Message', 'message', ['IsRead' => false]);
640: break;
641: case 'FLAG':
642: $change = [
643: 'SetItemField' => [
644: 'ExtendedFieldURI' => [
645: 'PropertyTag' => self::PID_TAG_FLAG_STATUS,
646: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
647: ],
648: 'Message' => [
649: 'ExtendedProperty' => [
650: 'ExtendedFieldURI' => [
651: 'PropertyTag' => self::PID_TAG_FLAG_STATUS,
652: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
653: ],
654: 'Value' => self::PID_TAG_FLAG_FLAGGED,
655: ],
656: ],
657: ],
658: ];
659: break;
660: case 'UNFLAG':
661: $change = [
662: 'DeleteItemField' => [
663: 'ExtendedFieldURI' => [
664: 'PropertyTag' => self::PID_TAG_FLAG_STATUS,
665: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
666: ],
667: ],
668: ];
669: break;
670: case 'ANSWERED':
671: case 'UNDELETE':
672: case 'CUSTOM':
673: // TODO: unsupported out of the box, we can emulate via custom extended properties
674: $change = null;
675: $status = true;
676: break;
677: case 'EXPUNGE':
678: // not needed for EWS
679: return ['status' => true, 'responses' => $responses];
680: default:
681: $change = null;
682: }
683:
684: if ($change) {
685: $changes = ['ItemChange' => []];
686: foreach ($itemIds as $itemId) {
687: $changes['ItemChange'][] = [
688: 'ItemId' => (new Type\ItemIdType(hex2bin($itemId)))->toArray(),
689: 'Updates' => $change,
690: ];
691: }
692: $status = $this->api->updateItems($changes);
693: }
694:
695: return ['status' => $status, 'responses' => $responses];
696: }
697:
698: public function get_message_headers($itemId) {
699: $binaryId = hex2bin($itemId);
700: $request = array(
701: 'ItemShape' => array(
702: 'BaseShape' => 'AllProperties',
703: 'AdditionalProperties' => [
704: 'ExtendedFieldURI' => [
705: [
706: 'PropertyTag' => self::PID_TAG_FLAG_STATUS, //check flagged msg
707: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
708: ],
709: [
710: 'PropertyTag' => self::PID_TAG_ICON_INDEX, // check if replied/answered
711: 'PropertyType' => Enumeration\MapiPropertyTypeType::INTEGER,
712: ],
713: ],
714: ],
715: ),
716: 'ItemIds' => [
717: 'ItemId' => ['Id' => $binaryId],
718: ],
719: );
720: $request = Type::buildFromArray($request);
721: $message = $this->ews->GetItem($request);
722: $sender = $message->getSender();
723: $from = $message->getFrom();
724: $headers = [];
725: $headers['Arrival Date'] = $message->getDateTimeCreated()->format('Y-m-d H:i:s.u');
726: if ($sender && $from) {
727: $headers['From'] = $message->getSender()->getMailbox()->getName() . ' <' . $message->getSender()->getMailbox()->getEmailAddress() . '>';
728: } elseif ($sender) {
729: $headers['From'] = $this->extract_mailbox($sender);
730: } elseif ($from) {
731: $headers['From'] = $this->extract_mailbox($from);
732: } else {
733: $headers['From'] = null;
734: }
735: $headers['To'] = $this->extract_mailbox($message->getToRecipients());
736: $headers['To'] = flatten_headers_to_string($headers, 'To');
737: if($message->getCcRecipients()) {
738: $headers['Cc'] = $this->extract_mailbox($message->getCcRecipients());
739: $headers['Cc'] = flatten_headers_to_string($headers, 'Cc');
740: }
741: if ($message->getBccRecipients()) {
742: $headers['Bcc'] = $this->extract_mailbox($message->getBccRecipients());
743: $headers['Bcc'] = flatten_headers_to_string($headers, 'Bcc');
744: }
745: $headers['Flags'] = implode(' ', $this->extract_flags($message));
746: foreach ($message->getInternetMessageHeaders() as $header) {
747: $name = $header->getHeaderName();
748: if (isset($headers[$name])) {
749: if (! is_array($headers[$name])) {
750: $headers[$name] = [$headers[$name]];
751: }
752: $headers[$name][] = (string) $header;
753: } else {
754: $headers[$name] = (string) $header;
755: }
756: }
757: if (! $message->isRead()) {
758: $this->api->updateMailItem($message->getItemId(), [
759: 'IsRead' => true,
760: ]);
761: }
762: return $headers;
763: }
764:
765: public function get_message_content($itemId, $part) {
766: if ($part) {
767: list($msg_struct, $msg_struct_current, $msg_text, $part) = $this->get_structured_message($itemId, $part, false);
768: return $msg_text;
769: } else {
770: $message = $this->get_mime_message_by_id($itemId);
771: $content = (string) $message;
772: return $content;
773: }
774: }
775:
776: public function stream_message_part($itemId, $part, $start_cb) {
777: if ($part !== '0' && $part) {
778: // imap handler modules strip this prefix
779: $part = '0.' . $part;
780: }
781: list($msg_struct, $part_struct, $msg_text, $part) = $this->get_structured_message($itemId, $part, false);
782: $charset = '';
783: if (! empty($part_struct['attributes']['charset'])) {
784: $charset = '; charset=' . $part_struct['attributes']['charset'];
785: }
786: $part_name = get_imap_part_name($part_struct, $itemId, $part);
787: $start_cb($part_struct['type'] . '/' . $part_struct['subtype'] . $charset, $part_name);
788: if (! $charset) {
789: $charset = 'UTF-8';
790: } else {
791: $charset = $part_struct['attributes']['charset'];
792: }
793: $stream = $part_struct['mime_object']->getContentStream($charset);
794: if ($stream) {
795: while (! $stream->eof()) {
796: echo $stream->read(1024);
797: }
798: }
799: }
800:
801: public function get_structured_message($itemId, $part, $text_only) {
802: $message = $this->get_mime_message_by_id($itemId);
803: $msg_struct = [];
804: $this->parse_mime_part($message, $msg_struct, 0);
805: if ($part !== false) {
806: $struct = $this->search_mime_part_in_struct($msg_struct, ['part_id' => $part], true);
807: } else {
808: $struct = null;
809: if (! $text_only) {
810: $struct = $this->search_mime_part_in_struct($msg_struct, ['type' => 'text', 'subtype' => 'html']);
811: }
812: if (! $struct) {
813: $struct = $this->search_mime_part_in_struct($msg_struct, ['type' => 'text']);
814: }
815: }
816: if ($struct) {
817: $part = array_key_first($struct);
818: $msg_struct_current = $struct[$part];
819: $msg_text = $msg_struct_current['mime_object']->getContent();
820: } else {
821: $part = false;
822: $msg_struct_current = null;
823: $msg_text = '';
824: }
825: if (isset($msg_struct_current['subtype']) && mb_strtolower($msg_struct_current['subtype'] == 'html')) {
826: // add inline images
827: if (preg_match_all("/src=('|\"|)cid:([^\s'\"]+)/", $msg_text, $matches)) {
828: $cids = array_pop($matches);
829: foreach ($cids as $id) {
830: $struct = $this->search_mime_part_in_struct($msg_struct, ['id' => $id, 'type' => 'image']);
831: if ($struct) {
832: $struct = array_shift($struct);
833: $msg_text = str_replace('cid:'.$id, 'data:image/'.$struct['subtype'].';base64,'.base64_encode($struct['mime_object']->getContent()), $msg_text);
834: }
835: }
836: }
837: }
838: return [$msg_struct, $msg_struct_current, $msg_text, $part];
839: }
840:
841: public function get_mime_message_by_id($itemId) {
842: $binaryId = hex2bin($itemId);
843: $request = array(
844: 'ItemShape' => array(
845: 'BaseShape' => 'IdOnly',
846: 'IncludeMimeContent' => true,
847: ),
848: 'ItemIds' => [
849: 'ItemId' => ['Id' => $binaryId],
850: ],
851: );
852: $request = Type::buildFromArray($request);
853: $message = $this->ews->GetItem($request);
854: $mime = $message->getMimeContent();
855: $content = base64_decode($mime);
856: if (strtoupper($mime->getCharacterSet()) != 'UTF-8') {
857: $content = mb_convert_encoding($content, 'UTF-8', $mime->getCharacterSet());
858: }
859: $parser = new MailMimeParser();
860: return $parser->parse($content, false);
861: }
862:
863: protected function parse_mime_part($part, &$struct, $part_num) {
864: $struct[$part_num] = [];
865: list($struct[$part_num]['type'], $struct[$part_num]['subtype']) = explode('/', $part->getContentType());
866: if ($part->isMultiPart()) {
867: $boundary = $part->getHeaderParameter('Content-Type', 'boundary');
868: if ($boundary) {
869: $struct[$part_num]['attributes'] = ['boundary' => $boundary];
870: }
871: $struct[$part_num]['disposition'] = $part->getContentDisposition();
872: $struct[$part_num]['language'] = '';
873: $struct[$part_num]['location'] = '';
874: } else {
875: $content = $part->getContent();
876: $charset = $part->getCharset();
877: if ($charset) {
878: $struct[$part_num]['attributes'] = ['charset' => $charset];
879: } else {
880: $struct[$part_num]['attributes'] = [];
881: }
882: $struct[$part_num]['id'] = $part->getContentId();
883: $struct[$part_num]['description'] = $part->getHeaderValue('Content-Description');
884: $struct[$part_num]['encoding'] = $part->getContentTransferEncoding();
885: $struct[$part_num]['size'] = strlen($content);
886: $struct[$part_num]['lines'] = substr_count($content, "\n");
887: $struct[$part_num]['md5'] = '';
888: $struct[$part_num]['disposition'] = $part->getContentDisposition();
889:
890: if ($filename = $part->getFilename()) {
891: $struct[$part_num]['file_attributes'] = ['filename' => $filename];
892:
893: if ($part->getContentDisposition() == 'attachment') {
894: $struct[$part_num]['file_attributes']['attachment'] = true;
895: }
896: } else {
897: $struct[$part_num]['file_attributes'] = '';
898: }
899: $struct[$part_num]['language'] = '';
900: $struct[$part_num]['location'] = '';
901: }
902: $struct[$part_num]['mime_object'] = $part;
903: if ($part->getChildCount() > 0) {
904: $struct[$part_num]['subs'] = [];
905: foreach ($part->getChildParts() as $i => $child) {
906: $this->parse_mime_part($child, $struct[$part_num]['subs'], $part_num . '.' . ($i+1));
907: }
908: }
909: }
910:
911: protected function search_mime_part_in_struct($struct, $conditions, $all = false) {
912: $found = [];
913: foreach ($struct as $part_id => $sub) {
914: $matches = 0;
915: if (isset($conditions['part_id']) && $part_id == $conditions['part_id']) {
916: $matches++;
917: }
918: foreach ($conditions as $name => $value) {
919: if (isset($sub[$name]) && mb_stristr($sub[$name], $value)) {
920: $matches++;
921: }
922: }
923: if ($matches === count($conditions)) {
924: $part = $sub;
925: if (isset($part['subs'])) {
926: $part['subs'] = count($part['subs']);
927: }
928: $found[$part_id] = $part;
929: if (! $all) {
930: break;
931: }
932: }
933: if (isset($sub['subs'])) {
934: $found = array_merge($found, $this->search_mime_part_in_struct($sub['subs'], $conditions, $all));
935: }
936: if (! $all && $found) {
937: break;
938: }
939: }
940: return $found;
941: }
942:
943: protected function extract_mailbox($data) {
944: if (is_array($data)) {
945: $result = [];
946: foreach ($data as $mailbox) {
947: $result[] = $this->extract_mailbox($mailbox);
948: }
949: return $result;
950: } elseif (is_object($data) && $data->Mailbox) {
951: if(is_array($data->Mailbox)) {
952: $result = [];
953: foreach ($data->Mailbox as $mailbox) {
954: $result[] = $this->extract_mailbox($mailbox);
955: }
956: return $result;
957: }else {
958: return $data->Mailbox->getName() . ' <' . $data->Mailbox->getEmailAddress() . '>';
959: }
960: } elseif (is_object($data) && method_exists($data, 'getMailbox')) {
961: $mailbox = $data->getMailbox()->getMailbox();
962:
963: $name = $mailbox->getName();
964: $email = $mailbox->getEmailAddress();
965: return $name ? $name . ' <' . $email . '>' : $email;
966: } elseif (is_object($data) && method_exists($data, 'getName') && method_exists($data, 'getEmailAddress')) {
967: $name = $data->getName();
968: $email = $data->getEmailAddress();
969: return $name ? $name . ' <' . $email . '>' : $email;
970: }else {
971: return (string) $data;
972: }
973: }
974:
975: protected function extract_flags($message) {
976: // note about flags: EWS - doesn't support the \Deleted flag
977: $flags = [];
978: if ($message->getIsRead()) {
979: $flags[] = '\\Seen';
980: }
981: if ($message->getIsDraft()) {
982: $flags[] = '\\Draft';
983: }
984: if ($extended_properties = $message->getExtendedProperty()) {
985: if ($extended_properties instanceof Type\ExtendedPropertyType) {
986: $extended_properties = [$extended_properties];
987: }
988: foreach ($extended_properties as $prop) {
989: if (hexdec($prop->getExtendedFieldURI()->getPropertyTag()) == self::PID_TAG_FLAG_STATUS && $prop->getValue() > 0) {
990: $flags[] = '\\Flagged';
991: }
992: if (hexdec($prop->getExtendedFieldURI()->getPropertyTag()) == self::PID_TAG_ICON_INDEX && $prop->getValue() == self::PID_TAG_ICON_REPLIED) {
993: $flags[] = '\\Answered';
994: }
995: }
996: }
997: return $flags;
998: }
999:
1000: protected function is_distinguished_folder(&$folder) {
1001: $oClass = new ReflectionClass(new Enumeration\DistinguishedFolderIdNameType());
1002: $constants = $oClass->getConstants();
1003: if (in_array($folder, $constants)) {
1004: return true;
1005: }
1006: if (isset($constants[$folder])) {
1007: $folder = $constants[$folder];
1008: return true;
1009: }
1010: return false;
1011: }
1012:
1013: protected function archive_items($itemIds) {
1014: $result = true;
1015: $folders = $this->get_parent_folders_of_items($itemIds);
1016: if (!$folders) {
1017: return false;
1018: }
1019: foreach ($folders as $folder => $folderItemIds) {
1020: if ($this->is_distinguished_folder($folder)) {
1021: $folderObj = new Type\DistinguishedFolderIdType($folder);
1022: } else {
1023: $folderObj = new Type\FolderIdType(hex2bin($folder));
1024: }
1025:
1026: $payload = [
1027: 'ArchiveSourceFolderId' => $folderObj->toArray(true),
1028: 'ItemIds' => [
1029: 'ItemId' => array_map(function($itemId) {
1030: return (new Type\ItemIdType(hex2bin($itemId)))->toArray();
1031: }, $folderItemIds),
1032: ]
1033: ];
1034:
1035: $request = Type::buildFromArray($payload);
1036:
1037: try {
1038: $result = $result && $this->ews->ArchiveItem($request);
1039: } catch (\Exception $e) {
1040: Hm_Msgs::add($e->getMessage(), 'danger');
1041: $result = false;
1042: }
1043: }
1044: return $result;
1045: }
1046:
1047: protected function move_items_to_junk($itemIds) {
1048: $result = true;
1049: $folders = $this->get_parent_folders_of_items($itemIds);
1050: if (!$folders) {
1051: return false;
1052: }
1053: foreach ($folders as $folder => $itemIds) {
1054: if ($this->is_distinguished_folder($folder)) {
1055: $folder = new Type\DistinguishedFolderIdType($folder);
1056: } else {
1057: // Convert hex folder ID to binary for EWS API
1058: $folder = new Type\FolderIdType(hex2bin($folder));
1059: }
1060:
1061: $junkFolder = new Type\DistinguishedFolderIdType(Enumeration\DistinguishedFolderIdNameType::JUNK);
1062: $request = [
1063: 'SourceFolderId' => $folder->toArray(true),
1064: 'DestinationFolderId' => $junkFolder->toArray(true),
1065: 'ItemIds' => [
1066: 'ItemId' => $itemIds = array_map(function($itemId) {
1067: return (new Type\ItemIdType(hex2bin($itemId)))->toArray();
1068: }, $itemIds),
1069: ]
1070: ];
1071:
1072: $request = Type::buildFromArray($request);
1073:
1074: try {
1075: $result = $result && $this->ews->MoveItem($request);
1076: } catch (\Exception $e) {
1077: Hm_Msgs::add('ERR' . $e->getMessage());
1078: $result = false;
1079: }
1080: }
1081: return $result;
1082: }
1083:
1084:
1085: protected function delete_items($itemIds, $hard = false) {
1086: $result = true;
1087: try {
1088: if ($hard) {
1089: $result = $this->api->deleteItems(array_map(function($itemId) {
1090: return (new Type\ItemIdType(hex2bin($itemId)))->toArray(true);
1091: }, $itemIds), [
1092: 'DeleteType' => 'HardDelete',
1093: ]);
1094: } else {
1095: $trash = $this->api->getFolderByDistinguishedId(Type\DistinguishedFolderIdNameType::DELETED);
1096: $folders = $this->get_parent_folders_of_items($itemIds);
1097: if (!$folders) {
1098: return false;
1099: }
1100: foreach ($folders as $folder => $itemIds) {
1101: if ($trash && $trash->getFolderId()->getId() == $folder) {
1102: $options = ['DeleteType' => 'HardDelete'];
1103: } else {
1104: $options = [];
1105: }
1106: $binaryItemIds = array_map(function($itemId) {
1107: return (new Type\ItemIdType($itemId))->toArray();
1108: }, $itemIds);
1109: $result = $result && $this->api->deleteItems($binaryItemIds, $options);
1110: }
1111: }
1112: } catch (\Exception $e) {
1113: Hm_Msgs::add($e->getMessage(), 'danger');
1114: $result = false;
1115: }
1116: return $result;
1117: }
1118:
1119: protected function copy_items($itemIds, $folder) {
1120: if ($this->is_distinguished_folder($folder)) {
1121: $folder = new Type\DistinguishedFolderIdType($folder);
1122: } else {
1123: $folder = new Type\FolderIdType($folder);
1124: }
1125: $request = [
1126: 'ToFolderId' => $folder->toArray(true),
1127: 'ItemIds' => [
1128: 'ItemId' => array_map(function($itemId) {
1129: return (new Type\ItemIdType(hex2bin($itemId)))->toArray();
1130: }, $itemIds),
1131: ]
1132: ];
1133: $request = Type::buildFromArray($request);
1134: try {
1135: $result = $this->ews->CopyItem($request);
1136: if (! is_array($result)) {
1137: $result = [$result];
1138: }
1139: $result = array_map(function($itemId) {
1140: return $itemId->getId();
1141: }, $result);
1142: } catch (\Exception $e) {
1143: Hm_Msgs::add($e->getMessage(), 'danger');
1144: $result = [];
1145: }
1146: return $result;
1147: }
1148:
1149: protected function move_items($itemIds, $folder) {
1150: if ($this->is_distinguished_folder($folder)) {
1151: $folder = new Type\DistinguishedFolderIdType($folder);
1152: } else {
1153: $folder = new Type\FolderIdType($folder);
1154: }
1155: $request = [
1156: 'ToFolderId' => $folder->toArray(true),
1157: 'ItemIds' => [
1158: 'ItemId' => array_map(function($itemId) {
1159: return (new Type\ItemIdType(hex2bin($itemId)))->toArray();
1160: }, $itemIds),
1161: ],
1162: 'ReturnNewItemIds' => false,
1163: ];
1164: $request = Type::buildFromArray($request);
1165: try {
1166: $result = $this->ews->MoveItem($request);
1167: if (! is_array($result)) {
1168: $result = [$result];
1169: }
1170: $result = array_map(function($itemId) {
1171: return $itemId->getId();
1172: }, $result);
1173: } catch (\Exception $e) {
1174: Hm_Msgs::add($e->getMessage(), 'danger');
1175: $result = [];
1176: }
1177: return $result;
1178: }
1179:
1180: protected function get_parent_folders_of_items($itemIds) {
1181: $itemIds = array_map(function($itemId) {
1182: return (new Type\ItemIdType(hex2bin($itemId)))->toArray();
1183: }, $itemIds);
1184: $folders = null;
1185: $request = [
1186: 'ItemShape' => [
1187: 'BaseShape' => 'IdOnly',
1188: 'AdditionalProperties' => [
1189: 'FieldURI' => ['FieldURI' => 'item:ParentFolderId'],
1190: ],
1191: ],
1192: 'ItemIds' => [
1193: 'ItemId' => $itemIds,
1194: ],
1195: ];
1196: $request = Type::buildFromArray($request);
1197: $result = $this->ews->GetItem($request);
1198: if ($result instanceof Type\MessageType) {
1199: $result = [$result];
1200: }
1201: foreach ($result as $message) {
1202: $folder = $message->getParentFolderId()->getId();
1203: if (! isset($folders[$folder])) {
1204: $folders[$folder] = [];
1205: }
1206: $folders[$folder][] = $message->getItemId()->getId();
1207: }
1208: return $folders;
1209: }
1210: }
1211:
1212: if(!hm_exists('flatten_headers_to_string')) {
1213: function flatten_headers_to_string($headers, $key) {
1214: if (!isset($headers[$key]) || !is_array($headers[$key])) {
1215: return isset($headers[$key]) ? $headers[$key] : '';
1216: }
1217:
1218: $flattened_header = [];
1219: foreach ($headers[$key] as $to_item) {
1220: if (is_array($to_item)) {
1221: $flattened_header = array_merge($flattened_header, $to_item);
1222: } else {
1223: $flattened_header[] = $to_item;
1224: }
1225: }
1226: return implode(', ', $flattened_header);
1227: }
1228: }
1229: