<?php

/**
 * ItemModule
 * Module which opens, creates, saves and deletes an item. It
 * extends the Module class.
 */
class ItemModule extends Module {
	/**
	 * The setting whether Meeting Requests should be booked directly or not.
	 */
	public $directBookingMeetingRequest;

	/**
	 * The array of properties which should not be copied during the copy() action.
	 */
	public $skipCopyProperties;

	/**
	 * Indicates that we are supporting only plain text body in the message props.
	 */
	public $plaintext;

	/**
	 * Constructor.
	 *
	 * @param int   $id   unique id
	 * @param array $data list of all actions
	 */
	public function __construct($id, $data) {
		$this->directBookingMeetingRequest = ENABLE_DIRECT_BOOKING;
		$this->skipCopyProperties = [];
		$this->plaintext = false;

		parent::__construct($id, $data);
	}

	/**
	 * Executes all the actions in the $data variable.
	 */
	#[Override]
	public function execute() {
		foreach ($this->data as $actionType => $action) {
			if (!isset($actionType)) {
				continue;
			}

			try {
				$store = $this->getActionStore($action);
				$parententryid = $this->getActionParentEntryID($action);
				$entryid = $this->getActionEntryID($action);

				switch ($actionType) {
					case "open":
						$this->open($store, $entryid, $action);
						break;

					case "save":
						if (!$store || !$parententryid) {
							/*
							 * if parententryid or storeentryid is not passed then we can take a guess that
							 * it would be  a save operation but instead of depending on server to get default
							 * parent and store client should always send parententryid and storeentryid
							 *
							 * we can also assume that user has permission to right in his own store
							 */
							$this->save($store, $parententryid, $entryid, $action);
							break;
						}
						/*
						 * The "message_action" object has been set, check the action_type field for
						 * the exact action which must be taken.
						 * Supported actions:
						 *   - acceptmeetingrequest: attendee has accepted mr
						 *   - declineMeetingRequest: attendee has declined mr
						 */
						if (!isset($action["message_action"], $action["message_action"]["action_type"])) {
							$this->save($store, $parententryid, $entryid, $action);
							break;
						}

						switch ($action["message_action"]["action_type"]) {
							case "declineMeetingRequest":
							case "acceptMeetingRequest":
								$message = $GLOBALS["operations"]->openMessage($store, $entryid);
								$basedate = ($action['basedate'] ?? false);
								$delete = false;

								if ($basedate) {
									$recurrence = new Recurrence($store, $message);
									$exceptionatt = $recurrence->getExceptionAttachment($basedate);
									if ($exceptionatt) {
										// get properties of existing exception.
										$exceptionattProps = mapi_getprops($exceptionatt, [PR_ATTACH_NUM]);
										$attach_num = $exceptionattProps[PR_ATTACH_NUM];
									}
								}

								/**
								 * Get message class from original message. This can be changed to
								 * IPM.Appointment if the item is a Meeting Request in the maillist.
								 * After Accepting/Declining the message is moved and changed.
								 */
								$originalMessageProps = mapi_getprops($message, [PR_MESSAGE_CLASS]);

								// Clear PR_PROCESSED so that doAccept fully copies
								// all properties from the meeting request to the
								// calendar item.  Without this, an update that changes
								// a single meeting into a recurring series is not
								// applied because the flag was already set during
								// auto-processing in open().
								mapi_deleteprops($message, [PR_PROCESSED]);

								$req = new Meetingrequest($store, $message, $GLOBALS["mapisession"]->getSession(), $this->directBookingMeetingRequest);

								// Update extra body information
								if (isset($action["message_action"]['meetingTimeInfo']) && !empty($action["message_action"]['meetingTimeInfo'])) {
									$req->setMeetingTimeInfo($action["message_action"]['meetingTimeInfo'],
										$action["message_action"]['mti_html'] ?? false);
									unset($action["message_action"]['meetingTimeInfo']);
								}

								// sendResponse flag if it is set then send the mail response to the organzer.
								$sendResponse = true;
								if (isset($action["message_action"]["sendResponse"]) && $action["message_action"]["sendResponse"] == false) {
									$sendResponse = false;
								}

								// @FIXME: fix body
								$body = false;
								if (isset($action["props"]["isHTML"]) && $action["props"]["isHTML"] === true) {
									$body = $action["props"]["html_body"] ?? false;
								}
								else {
									$body = $action["props"]["body"] ?? false;
								}

								if ($action["message_action"]["action_type"] == "acceptMeetingRequest") {
									$tentative = $action["message_action"]["responseType"] === olResponseTentative;
									$newProposedStartTime = $action["message_action"]["proposed_starttime"] ?? false;
									$newProposedEndTime = $action["message_action"]["proposed_endtime"] ?? false;

									// We are accepting MR from preview-read-mail so set delete the actual mail flag.
									$delete = $req->isMeetingRequest($originalMessageProps[PR_MESSAGE_CLASS]);

									$req->doAccept($tentative, $sendResponse, $delete, $newProposedStartTime, $newProposedEndTime, $body, true, $store, $basedate);
								}
								else {
									$delete = $req->doDecline($sendResponse, $basedate, $body);
								}

								/**
								 * Now if the item is the Meeting Request that was sent to the attendee
								 * it is removed when the user has clicked on Accept/Decline. If the
								 * item is the appointment in the calendar it will not be moved. To only
								 * notify the bus when the item is a Meeting Request we are going to
								 * check the PR_MESSAGE_CLASS and see if it is "IPM.Meeting*".
								 */
								$messageProps = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);

								// if opened appointment is exception then it will add
								// the attach_num and basedate in messageProps.
								if (isset($attach_num)) {
									$messageProps[PR_ATTACH_NUM] = [$attach_num];
									$messageProps[$this->properties["basedate"]] = $basedate;
								}

								if ($delete) {
									// send TABLE_DELETE event because the message has moved
									$this->sendFeedback(true);
									$GLOBALS["bus"]->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), TABLE_DELETE, $messageProps);
								}
								else {
									$this->addActionData("update", ["item" => Conversion::mapMAPI2XML($this->properties, $messageProps)]);
									$GLOBALS["bus"]->addData($this->getResponseData());

									// send TABLE_SAVE event because an occurrence is deleted
									$GLOBALS["bus"]->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), TABLE_SAVE, $messageProps);
								}

								break;

							case "acceptTaskRequest":
							case "declineTaskRequest":
								$message = $GLOBALS["operations"]->openMessage($store, $entryid);

								if (isset($action["props"]) && !empty($action["props"])) {
									$properties = $GLOBALS["properties"]->getTaskProperties();
									mapi_setprops($message, Conversion::mapXML2MAPI($properties, $action["props"]));
									mapi_savechanges($message);
								}
								// The task may be a delegated task, do an update if needed (will fail for non-delegated tasks)
								$tr = new TaskRequest($store, $message, $GLOBALS["mapisession"]->getSession());
								$isAccept = $action["message_action"]["action_type"] == "acceptTaskRequest";
								if (isset($action["message_action"]["task_comments_info"]) && !empty($action["message_action"]["task_comments_info"])) {
									$tr->setTaskCommentsInfo($action["message_action"]["task_comments_info"]);
								}
								if ($isAccept) {
									$result = $tr->doAccept();
								}
								else {
									$result = $tr->doDecline();
								}

								$this->sendFeedback(true);
								if ($result !== false) {
									$GLOBALS["bus"]->notify(bin2hex((string) $result[PR_PARENT_ENTRYID]), TABLE_DELETE, $result);
								}

								$props = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);
								if (!$tr->isTaskRequest()) {
									unset($props[PR_MESSAGE_CLASS]);
									$GLOBALS["bus"]->notify(bin2hex((string) $props[PR_PARENT_ENTRYID]), $isAccept ? TABLE_SAVE : TABLE_DELETE, $props);
								}
								break;

							case "copy":
							case "move":
								$this->copy($store, $parententryid, $entryid, $action);
								break;

							case "forwardMeetingRequest":
								$this->forwardMeetingRequest($store, $entryid, $action, $this->directBookingMeetingRequest);
								break;

							case "reply":
							case "replyall":
							case "forward":
							default:
								$this->save($store, $parententryid, $entryid, $action);
						}
						break;

					case "delete":
						$subActionType = false;
						if (isset($action["message_action"], $action["message_action"]["action_type"])) {
							$subActionType = $action["message_action"]["action_type"];
						}

						/*
						 * The "message_action" object has been set, check the action_type field for
						 * the exact action which must be taken.
						 * Supported actions:
						 *   - cancelInvitation: organizer cancels already scheduled meeting
						 *   - removeFromCalendar: attendee receives meeting cancellation and wants to remove item from calendar
						 */
						switch ($subActionType) {
							case "removeFromCalendar":
								$basedate = (isset($action['basedate']) && !empty($action['basedate'])) ? $action['basedate'] : false;

								$this->removeFromCalendar($store, $entryid, $basedate, $this->directBookingMeetingRequest);
								$this->sendFeedback(true);
								break;

							case "cancelInvitation":
								$this->cancelInvitation($store, $entryid, $action, $this->directBookingMeetingRequest);
								$this->sendFeedback(true);
								break;

							case "declineMeeting":
								// @FIXME can we somehow merge declineMeeting and declineMeetingRequest sub actions?
								$message = $GLOBALS["operations"]->openMessage($store, $entryid);
								$basedate = (isset($action['basedate']) && !empty($action['basedate'])) ? $action['basedate'] : false;

								$req = new Meetingrequest($store, $message, $GLOBALS["mapisession"]->getSession(), $this->directBookingMeetingRequest);

								// @FIXME: may be we can remove this body check any get it while declining meeting 'body'
								$body = false;
								if (isset($action["props"]["isHTML"]) && $action["props"]["isHTML"] === true) {
									$body = $action["props"]["html_body"] ?? false;
								}
								else {
									$body = $action["props"]["body"] ?? false;
								}
								$req->doDecline(true, $basedate, $body);

								$messageProps = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);
								$GLOBALS["bus"]->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), $basedate ? TABLE_SAVE : TABLE_DELETE, $messageProps);

								break;

							case "snooze":
							case "dismiss":
								$this->delete($store, $parententryid, $entryid, $action);
								break;

							default:
								// Deleting an occurrence means that we have to save the message to
								// generate an exception. So when the basedate is provided, we actually
								// perform a save rather then delete.
								if (isset($action['basedate']) && !empty($action['basedate'])) {
									$this->save($store, $parententryid, $entryid, $action, "delete");
								}
								else {
									$this->delete($store, $parententryid, $entryid, $action);
								}
								break;
						}
						break;

					default:
						$this->handleUnknownActionType($actionType);
				}
			}
			catch (MAPIException $e) {
				$this->processException($e, $actionType, $store, $parententryid, $entryid, $action);
			}
		}
	}

	/**
	 * Function does customization of exception based on module data.
	 * like, here it will generate display message based on actionType
	 * for particular exception.
	 *
	 * @param object     $e             Exception object
	 * @param string     $actionType    the action type, sent by the client
	 * @param MAPIobject $store         store object of message
	 * @param string     $parententryid parent entryid of the message
	 * @param string     $entryid       entryid of the message
	 * @param array      $action        the action data, sent by the client
	 */
	#[Override]
	public function handleException(&$e, $actionType = null, $store = null, $parententryid = null, $entryid = null, $action = null) {
		if (is_null($e->displayMessage)) {
			switch ($actionType) {
				case "open":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						$e->setDisplayMessage(_("You have insufficient privileges to open this message."));
					}
					elseif ($e->getCode() == MAPI_E_NOT_FOUND) {
						$e->setDisplayMessage(_("Could not find message, either it has been moved or deleted or you don't have access to open this message."));
						// Show error in console instead of a pop-up
						if (isset($action["message_action"]['suppress_exception']) && $action["message_action"]['suppress_exception'] === true) {
							$e->setNotificationType('console');
						}
					}
					else {
						$e->setDisplayMessage(_("Could not open message."));
						$e->allowToShowDetailsMessage = true;
					}
					break;

				case "save":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						if (!empty($action["message_action"]["action_type"])) {
							switch ($action["message_action"]["action_type"]) {
								case "declineMeetingRequest":
									$e->setDisplayMessage(_("You have insufficient privileges to decline this Meeting Request") . ".");
									break;

								case "acceptMeetingRequest":
									$e->setDisplayMessage(_("You have insufficient privileges to accept this Meeting Request") . ".");
									break;

								case "copy":
									$e->setDisplayMessage(_("Could not copy message") . ".");
									break;

								case "move":
									$e->setDisplayMessage(_("Could not move message") . ".");
									break;

								case "forwardMeetingRequest":
									$e->setDisplayMessage(_("Could not forward meeting request") . ".");
									break;
							}
						}

						if (empty($e->displayMessage)) {
							$e->setDisplayMessage(_("You have insufficient privileges to save items in this folder") . ".");
						}
					}
					elseif ($e->getCode() == MAPI_E_STORE_FULL) {
						$e->setDisplayMessage($this->getOverQuotaMessage($store));
					}
					else {
						$e->setDisplayMessage(_("Could not save message") . ".");
						$e->allowToShowDetailsMessage = true;
					}
					break;

				case 'delete':
					switch ($e->getCode()) {
						case MAPI_E_NO_ACCESS:
							if (!empty($action['message_action']['action_type'])) {
								switch ($action['message_action']['action_type']) {
									case 'removeFromCalendar':
										$e->setDisplayMessage(_('You have insufficient privileges to remove item from the calendar.'));
										break;
								}
							}
							break;

						case MAPI_E_NOT_IN_QUEUE:
							$e->setDisplayMessage(_('Message is no longer in the outgoing queue, typically because it has already been sent.'));
							break;

						case MAPI_E_UNABLE_TO_ABORT:
							$e->setDisplayMessage(_('Message cannot be aborted'));
							break;
					}
					if (empty($e->displayMessage)) {
						$e->setDisplayMessage(_("You have insufficient privileges to delete items in this folder") . ".");
					}
					break;

				case "attach_items":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						$e->setDisplayMessage(_("You have insufficient privileges to attach item as an attachment."));
					}
					else {
						$e->setDisplayMessage(_("Could not attach item as an attachment."));
					}
					break;

				case "reclaimownership":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						$e->setDisplayMessage(_("You have insufficient privileges to reclaim the ownership for the Task Request."));
					}
					else {
						$e->setDisplayMessage(_("Could not reclaim the ownership for the Task Request."));
					}
					break;

				case "acceptTaskRequest":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						$e->setDisplayMessage(_("You have insufficient privileges to accept this Task Request."));
					}
					else {
						$e->setDisplayMessage(_("Could not accept Task Request."));
					}
					break;

				case "declineTaskRequest":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						$e->setDisplayMessage(_("You have insufficient privileges to decline this Task Request."));
					}
					else {
						$e->setDisplayMessage(_("Could not decline Task Request."));
					}
					break;
			}
			Log::Write(
				LOGLEVEL_ERROR,
				"itemmodule::handleException():" . $actionType . ": " . $e->displayMessage
			);
		}

		parent::handleException($e, $actionType, $store, $parententryid, $entryid, $action);
	}

	/**
	 * Function which opens an item.
	 *
	 * @param object $store   MAPI Message Store Object
	 * @param string $entryid entryid of the message
	 * @param array  $action  the action data, sent by the client
	 */
	public function open($store, $entryid, $action) {
		$data = [];

		if ($entryid) {
			if ($store) {
				$message = $GLOBALS['operations']->openMessage($store, $entryid);
			}
			else {
				// store is not passed so we need to open the message first to get the store resource
				$message = $GLOBALS['mapisession']->openMessage($entryid);

				$messageStoreInfo = mapi_getprops($message, [PR_STORE_ENTRYID]);
				$store = $GLOBALS['mapisession']->openMessageStore($messageStoreInfo[PR_STORE_ENTRYID]);
			}
		}

		if (empty($message)) {
			return;
		}

		// Detect whether S/MIME decoding or meeting request processing is required
		$props = mapi_getprops($message, [PR_MESSAGE_CLASS]);
		$messageClass = $props[PR_MESSAGE_CLASS] ?? '';
		$requiresSmime = stripos((string) $messageClass, 'SMIME') !== false; /* infix match(!), cannot use class_match_prefix */
		$requiresMeeting = class_match_prefix($messageClass, "IPM.Schedule.Meeting");

		// Decode S/MIME signed messages only when needed
		if ($requiresSmime) {
			parse_smime($store, $message);
		}

		// Open embedded message if requested
		$attachNum = !empty($action['attach_num']) ? $action['attach_num'] : false;

		if ($attachNum) {
			// get message props of sub message
			$parentMessage = $message;
			$message = $GLOBALS['operations']->openMessage($store, $entryid, $attachNum, true);

			if (empty($message)) {
				return;
			}

			$data['item'] = $GLOBALS['operations']->getEmbeddedMessageProps($store, $message, $this->properties, $parentMessage, $attachNum);
		}
		else {
			// Mark as read if requested by the client.  This combines the
			// open + mark-as-read into a single HTTP round-trip by setting
			// the read flag BEFORE getMessageProps(), so the returned
			// message_flags already includes MSGFLAG_READ.
			if (isset($action['message_action']['mark_read'])) {
				$flag = MAPI_DEFERRED_ERRORS;
				if (isset($action['message_action']['send_read_receipt']) &&
					$action['message_action']['send_read_receipt'] == false) {
					$flag |= SUPPRESS_RECEIPT;
				}
				mapi_message_setreadflag($message, $flag);

				// Notify the mail list so it updates the unread state / counter.
				$notifyProps = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);
				$GLOBALS['bus']->notify(bin2hex((string) $notifyProps[PR_PARENT_ENTRYID]), TABLE_SAVE, $notifyProps);
			}

			// get message props of the message
			$data['item'] = $GLOBALS['operations']->getMessageProps($store, $message, $this->properties, $this->plaintext, true);
			$messageClass = $data['item']['props']['message_class'] ?? $messageClass;

			// Determine again if meeting processing is required after parsing
			if (!$requiresMeeting) {
				$requiresMeeting = class_match_prefix($messageClass, "IPM.Schedule.Meeting");
			}

			// Skip meeting request processing for items in the Sent
			// Items folder — those are outgoing copies that should not
			// be processed as incoming requests.
			$isSentItem = false;
			if ($requiresMeeting) {
				$sentStoreProps = mapi_getprops($store, [PR_IPM_SENTMAIL_ENTRYID]);
				$msgFolderProps = mapi_getprops($message, [PR_PARENT_ENTRYID]);
				if (isset($sentStoreProps[PR_IPM_SENTMAIL_ENTRYID], $msgFolderProps[PR_PARENT_ENTRYID]) &&
					$GLOBALS['entryid']->compareEntryIds(
						bin2hex((string) $sentStoreProps[PR_IPM_SENTMAIL_ENTRYID]),
						bin2hex((string) $msgFolderProps[PR_PARENT_ENTRYID]))) {
					$isSentItem = true;
				}
			}

			// Forward notifications are informational only (no calendar
			// lookup / accept / decline processing needed).
			$isMeetingNotification = class_match_prefix($messageClass, "IPM.Schedule.Meeting.Notification");

			// Check for meeting request, do processing if necessary
			if ($requiresMeeting && !$isSentItem && !$isMeetingNotification) {
				$req = new Meetingrequest($store, $message, $GLOBALS['mapisession']->getSession(), $this->directBookingMeetingRequest);

				try {
					if ($req->isMeetingRequestResponse($messageClass)) {
						if ($req->isLocalOrganiser()) {
							// We received a meeting request response, and we're the delegate/organiser
							$req->processMeetingRequestResponse();
						}
					}
					elseif ($req->isMeetingRequest($messageClass)) {
						if (!$req->isLocalOrganiser()) {
							if ($req->isMeetingOutOfDate()) {
								// we know that meeting is out of date so directly set this properties
								$data['item']['props']['meetingtype'] = mtgOutOfDate;
								$data['item']['props']['icon_index'] = 1033;

								// send update to maillistmodule that meeting request is updated with out of date flag
								$messageProps = mapi_getprops($message, [PR_ENTRYID, PR_PARENT_ENTRYID, PR_STORE_ENTRYID]);
								$GLOBALS['bus']->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), TABLE_SAVE, $messageProps);
							}
							else {
								/*
								 * if meeting request is not out of date then process it for the first time
								 * which will create corresponding appointment in the user's calendar
								 */
								$calItemEntryid = $req->doAccept(true, false, false);
								// we need to set timezone information for all-day events
								if (isset($data['item']['props']['alldayevent']) &&
									$data['item']['props']['alldayevent'] == true &&
									!empty($action["timezone_iana"]) &&
									is_string($calItemEntryid)) {
									try {
										$tzdef = mapi_ianatz_to_tzdef($action['timezone_iana']);
										$localStart = getLocalStart($data['item']['props']['appointment_startdate'], $action['timezone_iana']);
										$duration = $data['item']['props']['appointment_duedate'] - $data['item']['props']['appointment_startdate'];

										$calItem = $GLOBALS['mapisession']->openMessage($calItemEntryid);
										mapi_setprops($calItem, [
											$this->properties['appointment_startdate'] => $localStart,
											$this->properties['appointment_duedate'] => $localStart + $duration,
											$this->properties['tzdefstart'] => $tzdef,
											$this->properties['tzdefend'] => $tzdef,
										]);
										mapi_savechanges($calItem);
									}
									catch (Exception $e) {
										error_log(sprintf("Error setting timezone to an all-day event: %s", $e));
									}
								}
							}

							// Show user whether meeting request conflict with other appointment or not.
							$meetingConflicts = $req->isMeetingConflicting();

							/**
							 * if $meetingConflicts is boolean and true then its a normal meeting.
							 * if $meetingConflicts is integer then it indicates no of instances of a recurring meeting which conflicts with Calendar.
							 */
							if ($meetingConflicts !== false) {
								if ($meetingConflicts === true) {
									$data['item']['props']['conflictinfo'] = _('Conflicts with another appointment.');
								}
								else {
									$data['item']['props']['conflictinfo'] = sprintf(ngettext('%s occurrence of this recurring appointment conflicts with other appointment.', '%s occurrences of this recurring appointment conflicts with other appointments.', $meetingConflicts), $meetingConflicts);
								}
							}
						}
					}
					elseif ($req->isMeetingCancellation()) {
						$req->processMeetingCancellation();
					}

					if ($req->isInCalendar()) {
						$calendarItemProps = $this->getCalendarItemProps($req);
						if (!empty($calendarItemProps)) {
							$data['item']['props'] = array_merge($data['item']['props'], $calendarItemProps);
						}
					}
					else {
						$data['item']['props']['appointment_not_found'] = true;
					}
				}
				catch (MAPIException $e) {
					// if quota is exceeded or or we don't have permission to write in calendar folder than ignore the exception.
					if ($e->getCode() !== MAPI_E_STORE_FULL && $e->getCode() !== MAPI_E_NO_ACCESS) {
						// re-throw the exception if it is not one of quota/calendar permission.
						throw $e;
					}
				}
				if (!empty($data['item']['props']['appointment_recurring']) &&
					empty($data['item']['props']['appointment_recurring_pattern'])) {
					$recurr = new Recurrence($store, $message);
					$data['item']['props']['appointment_recurring_pattern'] = $recurr->saveRecurrencePattern();
				}
			}
			elseif (class_match_prefix($messageClass, "REPORT.IPM.NOTE.NDR")) {
				// check if this message is a NDR (mail)message, if so, generate a new body message
				$data['item']['props']['isHTML'] = false;
				$data['item']['props']['body'] = $this->getNDRbody($message);
			}
		}

		$userEntryId = '';
		if (isset($data['item']['props']['sent_representing_entryid'])) {
			$userEntryId = hex2bin($data['item']['props']['sent_representing_entryid']);
		}
		elseif (isset($data['item']['props']['sender_entryid'])) {
			$userEntryId = hex2bin($data['item']['props']['sender_entryid']);
		}

		// get user image saved in LDAP.
		if (!empty($userEntryId)) {
			$data['item']['props']['user_image'] = $GLOBALS['operations']->getCompressedUserImage($userEntryId);
		}

		// Allowing to hook in just before the data sent away to be sent to the client
		$GLOBALS['PluginManager']->triggerHook('server.module.itemmodule.open.after', [
			'moduleObject' => &$this,
			'store' => $store,
			'entryid' => $entryid,
			'action' => $action,
			'message' => &$message,
			'data' => &$data,
		]);

		// ugly workaround to show clip icon for the signed/encrypted emails
		if (isset($data['item']['props']['smime'])) {
			$data['item']['props']['hasattach'] = true;
			unset($data['item']['props']['hide_attachments']);
		}

		$this->addActionData('item', $data);
		$GLOBALS['bus']->addData($this->getResponseData());
	}

	/**
	 * Function which saves an item.
	 *
	 * @param object $store         MAPI Message Store Object
	 * @param string $parententryid parent entryid of the message
	 * @param mixed  $entryid       entryid of the message
	 * @param array  $action        the action data, sent by the client
	 * @param string $actionType    The action type which triggered this action
	 */
	public function save($store, $parententryid, $entryid, $action, $actionType = 'save') {
		$result = false;

		if (isset($action["props"])) {
			if (!$store) {
				$store = $GLOBALS['mapisession']->getDefaultMessageStore();
			}
			if (!$parententryid) {
				if (isset($action['props']['message_class'])) {
					$parententryid = $this->getDefaultFolderEntryID($store, $action['props']['message_class']);
				}
				else {
					$parententryid = $this->getDefaultFolderEntryID($store, '');
				}
			}

			if ($store && $parententryid) {
				$props = Conversion::mapXML2MAPI($this->properties, $action["props"]);

				$messageProps = []; // props returned from saveMessage

				// Save message
				if (!empty($props)) {
					$result = $GLOBALS["operations"]->saveMessage($store, $entryid, $parententryid, $props, $messageProps, [], !empty($action['attachments']) ? $action['attachments'] : []);
				}

				if ($result) {
					$GLOBALS["bus"]->notify(bin2hex($parententryid), TABLE_SAVE, $messageProps);

					$this->addActionData("update", ["item" => Conversion::mapMAPI2XML($this->properties, $messageProps)]);
					$GLOBALS["bus"]->addData($this->getResponseData());
				}
				else {
					$this->sendFeedback(false);
				}
			}
		}
	}

	/**
	 * Function which deletes an item.
	 *
	 * @param object $store         MAPI Message Store Object
	 * @param string $parententryid parent entryid of the message
	 * @param string $entryid       entryid of the message
	 * @param array  $action        the action data, sent by the client
	 */
	public function delete($store, $parententryid, $entryid, $action) {
		if (!$store || !$parententryid || !$entryid) {
			return;
		}
		$props = [];
		$props[PR_PARENT_ENTRYID] = $parententryid;
		$props[PR_ENTRYID] = $entryid;

		$storeprops = mapi_getprops($store, [PR_ENTRYID]);
		$props[PR_STORE_ENTRYID] = $storeprops[PR_ENTRYID];

		$soft = $action['message_action']['soft_delete'] ?? false;
		$unread = $action['message_action']['non_read_notify'] ?? false;
		$result = $GLOBALS["operations"]->deleteMessages($store, $parententryid, $entryid, $soft, $unread);
		if ($result) {
			$GLOBALS["bus"]->notify(bin2hex($parententryid), TABLE_DELETE, $props);
			$this->sendFeedback(true);
		}
		else {
			$this->sendFeedback(false);
		}
	}

	/**
	 * Function which returns the entryid of a default folder.
	 *
	 * @param object $store        MAPI Message Store Object
	 * @param string $messageClass the class of the folder
	 *
	 * @return string entryid of a default folder, false if not found
	 */
	public function getDefaultFolderEntryID($store, $messageClass) {
		$entryid = false;

		if ($store) {
			$rootcontainer = mapi_msgstore_openentry($store);
			$rootcontainerprops = mapi_getprops($rootcontainer, [PR_IPM_DRAFTS_ENTRYID, PR_IPM_APPOINTMENT_ENTRYID, PR_IPM_CONTACT_ENTRYID, PR_IPM_JOURNAL_ENTRYID, PR_IPM_NOTE_ENTRYID, PR_IPM_TASK_ENTRYID]);

			switch ($messageClass) {
				case "IPM.Appointment":
					if (isset($rootcontainerprops[PR_IPM_APPOINTMENT_ENTRYID])) {
						$entryid = $rootcontainerprops[PR_IPM_APPOINTMENT_ENTRYID];
					}
					break;

				case "IPM.Contact":
				case "IPM.DistList":
					if (isset($rootcontainerprops[PR_IPM_CONTACT_ENTRYID])) {
						$entryid = $rootcontainerprops[PR_IPM_CONTACT_ENTRYID];
					}
					break;

				case "IPM.StickyNote":
					if (isset($rootcontainerprops[PR_IPM_NOTE_ENTRYID])) {
						$entryid = $rootcontainerprops[PR_IPM_NOTE_ENTRYID];
					}
					break;

				case "IPM.Task":
					if (isset($rootcontainerprops[PR_IPM_TASK_ENTRYID])) {
						$entryid = $rootcontainerprops[PR_IPM_TASK_ENTRYID];
					}
					break;

				default:
					if (isset($rootcontainerprops[PR_IPM_DRAFTS_ENTRYID])) {
						$entryid = $rootcontainerprops[PR_IPM_DRAFTS_ENTRYID];
					}
					break;
			}
		}

		return $entryid;
	}

	/**
	 * Function which copies or moves one or more items.
	 *
	 * @param resource $store         MAPI Message Store Object
	 * @param string   $parententryid entryid of the folder
	 * @param mixed    $entryids      list of entryids which will be copied or moved (in binary format)
	 * @param array    $action        the action data, sent by the client
	 */
	public function copy($store, $parententryid, $entryids, $action) {
		$result = false;

		if ($store && $parententryid && $entryids) {
			$dest_store = $store;
			if (isset($action["message_action"]["destination_store_entryid"])) {
				$dest_storeentryid = hex2bin($action["message_action"]["destination_store_entryid"]);
				$dest_store = $GLOBALS["mapisession"]->openMessageStore($dest_storeentryid);
			}

			$dest_folderentryid = false;
			if (isset($action["message_action"]["destination_parent_entryid"])) {
				$dest_folderentryid = hex2bin($action["message_action"]["destination_parent_entryid"]);
			}

			$moveMessages = false;
			if (isset($action["message_action"]["action_type"]) && $action["message_action"]["action_type"] == "move") {
				$moveMessages = true;
			}

			// if item has some set of props that need to be saved into the newly copied/moved item
			$copyProps = [];
			if (isset($action["message_action"]["dropmodifications"])) {
				$copyProps = Conversion::mapXML2MAPI($this->properties, $action["message_action"]["dropmodifications"]);
			}

			// if item has some changes made before choosing different calendar from create-in dropdown
			if (isset($action["props"]) && !empty($action["props"])) {
				$copyProps = Conversion::mapXML2MAPI($this->properties, $action["props"]);
			}

			$props = [];
			$props[PR_PARENT_ENTRYID] = $parententryid;
			$props[PR_ENTRYID] = $entryids;

			$storeprops = mapi_getprops($store, [PR_ENTRYID]);
			$props[PR_STORE_ENTRYID] = $storeprops[PR_ENTRYID];

			$skipCopyProperties = [];
			if (isset($action["message_action"]["unset_Private"])) {
				if ($moveMessages) {
					array_push($skipCopyProperties, $this->properties["private"], $this->properties["sensitivity"]);
				}
				else {
					array_push($this->skipCopyProperties, $this->properties["private"], $this->properties["sensitivity"]);
				}
			}

			$result = $GLOBALS["operations"]->copyMessages($store, $parententryid, $dest_store, $dest_folderentryid, $entryids, $moveMessages ? $skipCopyProperties : $this->skipCopyProperties, $moveMessages, $copyProps);

			if ($result) {
				if ($moveMessages) {
					$GLOBALS["bus"]->notify(bin2hex($parententryid), TABLE_DELETE, $props);
				}

				// Delete the PR_ENTRYID, the copied or moved message has a new entryid,
				// and at this time we have no idea what that might be. So make sure
				// we unset it, otherwise the notification handlers get weird ideas
				// and could reset the PR_PARENT_ENTRYID to the old folder again.
				unset($props[PR_ENTRYID]);
				$props[PR_PARENT_ENTRYID] = $dest_folderentryid;
				$props[PR_STORE_ENTRYID] = $dest_storeentryid;
				$GLOBALS["bus"]->notify(bin2hex($dest_folderentryid), TABLE_SAVE, $props);
			}

			$this->sendFeedback($result, []);
		}
	}

	/**
	 * Function returns correspondent calendar item's properties attached
	 * with the meeting request/response/cancellation.
	 *
	 * @param Meetingrequest $meetingRequestObject the meeting request object
	 *                                             using which function will fetch meeting request properties and return them
	 */
	public function getCalendarItemProps($meetingRequestObject) {
		$calendarItem = $meetingRequestObject->getCorrespondentCalendarItem();
		$props = [];
		if ($calendarItem !== false) {
			$calendarItemProps = mapi_getprops($calendarItem, [PR_STORE_ENTRYID, PR_PARENT_ENTRYID, PR_ENTRYID, $meetingRequestObject->proptags['updatecounter'], $meetingRequestObject->proptags['goid']]);

			// Store calendar item's necessary properties in props array.
			$props['appointment_store_entryid'] = bin2hex((string) $calendarItemProps[PR_STORE_ENTRYID]);
			$props['appointment_parent_entryid'] = bin2hex((string) $calendarItemProps[PR_PARENT_ENTRYID]);
			$props['appointment_entryid'] = bin2hex((string) $calendarItemProps[PR_ENTRYID]);

			$props['appointment_updatecounter'] = $calendarItemProps[$meetingRequestObject->proptags['updatecounter']] ?? 0;

			$messageProps = mapi_getprops($meetingRequestObject->message, [$meetingRequestObject->proptags['goid']]);

			$basedate = $meetingRequestObject->getBasedateFromGlobalID($messageProps[$meetingRequestObject->proptags['goid']]);

			if ($basedate !== false) {
				$props['appointment_basedate'] = $basedate;

				// if basedate is provided then it is exception, so get update counter of the exception
				$exception = $meetingRequestObject->getExceptionItem($calendarItem, $basedate);

				if ($exception !== false) {
					// we are able to find the exception then get updatecounter
					$exceptionProps = mapi_getprops($exception, [$meetingRequestObject->proptags['updatecounter']]);
					$props['appointment_updatecounter'] = $exceptionProps[$meetingRequestObject->proptags['updatecounter']] ?? 0;
				}
			}

			if ($meetingRequestObject->isMeetingRequestResponse()) {
				$props['meeting_updated'] = $meetingRequestObject->isMeetingUpdated($basedate);
			}

			return $props;
		}

		return false;
	}

	/**
	 * Get a text body for a Non-Delivery report.
	 *
	 * This function reads the necessary properties from the passed message and constructs
	 * a user-readable NDR message from those properties
	 *
	 * @param mapimessage $message The NDR message to read the information from
	 *
	 * @return string NDR body message as plaintext message
	 */
	public function getNDRbody($message) {
		$message_props = mapi_getprops($message, [PR_ORIGINAL_SUBJECT, PR_ORIGINAL_SUBMIT_TIME, PR_BODY]);
		$body = '';

		// use PR_BODY if it's there, otherwise create a recipient failed message
		if (isset($message_props[PR_BODY]) || propIsError(PR_BODY, $message_props) == MAPI_E_NOT_ENOUGH_MEMORY) {
			$body = mapi_openproperty($message, PR_BODY);
		}

		if (empty($body)) {
			$body = _("Your message did not reach some or all of the intended recipients") . "\n\n";
			$body .= "\t" . _("Subject") . ": " . $message_props[PR_ORIGINAL_SUBJECT] . "\n";
			$body .= "\t" . _("Sent") . ":    " . date(DATE_RFC2822, $message_props[PR_ORIGINAL_SUBMIT_TIME]) . "\n\n";
			$body .= _("The following recipient(s) could not be reached") . ":\n";

			$recipienttable = mapi_message_getrecipienttable($message);
			$recipientrows = mapi_table_queryallrows($recipienttable, [PR_DISPLAY_NAME, PR_REPORT_TIME, PR_REPORT_TEXT]);
			foreach ($recipientrows as $recipient) {
				$body .= "\n\t" . $recipient[PR_DISPLAY_NAME] . " on " . date(DATE_RFC2822, $recipient[PR_REPORT_TIME]) . "\n";
				$body .= "\t\t" . $recipient[PR_REPORT_TEXT] . "\n";
			}
		}

		return $body;
	}

	/**
	 * Send a meeting cancellation.
	 *
	 * This function sends a meeting cancellation for the meeting references by the passed entryid. It
	 * will send the meeting cancellation and move the item itself to the waste basket.
	 *
	 * @param mapistore $store                       The store in which the meeting request resides
	 * @param string    $entryid                     entryid of the appointment for which the cancellation should be sent
	 * @param object    $action                      data sent by client
	 * @param bool      $directBookingMeetingRequest Indicates if a Meeting Request should use direct booking or not
	 */
	public function cancelInvitation($store, $entryid, $action, $directBookingMeetingRequest) {
		$message = $GLOBALS['operations']->openMessage($store, $entryid);

		// @TODO move this to meeting request class ?
		$req = new Meetingrequest($store, $message, $GLOBALS['mapisession']->getSession(), $directBookingMeetingRequest);

		// Update extra body information
		if (isset($action['message_action']['meetingTimeInfo']) && !empty($action['message_action']['meetingTimeInfo'])) {
			$req->setMeetingTimeInfo($action["message_action"]['meetingTimeInfo'],
				$action["message_action"]['mti_html'] ?? false);
			unset($action["message_action"]['meetingTimeInfo']);
		}

		// get basedate from action data and pass to meeting request class
		$basedate = !empty($action['basedate']) ? $action['basedate'] : false;

		$req->doCancelInvitation($basedate);

		if ($basedate !== false) {
			// if basedate is specified then we have created exception in recurring meeting request
			// so send notification of creation of exception
			$messageProps = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);
			$GLOBALS["bus"]->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), TABLE_SAVE, $messageProps);
		}
		else {
			// for normal/recurring meetings send delete notification
			$messageProps = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);
			$GLOBALS["bus"]->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), TABLE_DELETE, $messageProps);
		}
	}

	/**
	 * Remove all appointments for a certain meeting request.
	 *
	 * This function searches the default calendar for all meeting requests for the specified
	 * meeting. All those appointments are then removed.
	 *
	 * @param mapistore $store                       Mapi store in which the meeting request and the calendar reside
	 * @param string    $entryid                     Entryid of the meeting request or appointment for which all items should be deleted
	 * @param string    $basedate                    if specified contains starttime of day of an occurrence
	 * @param bool      $directBookingMeetingRequest Indicates if a Meeting Request should use direct booking or not
	 */
	public function removeFromCalendar($store, $entryid, $basedate, $directBookingMeetingRequest) {
		$message = $GLOBALS["operations"]->openMessage($store, $entryid);

		$req = new Meetingrequest($store, $message, $GLOBALS["mapisession"]->getSession(), $directBookingMeetingRequest);

		$req->doRemoveFromCalendar($basedate);

		// Notify the bus that the message has been deleted
		$messageProps = mapi_getprops($message, [PR_ENTRYID, PR_STORE_ENTRYID, PR_PARENT_ENTRYID]);
		$GLOBALS["bus"]->notify(bin2hex((string) $messageProps[PR_PARENT_ENTRYID]), $basedate ? TABLE_SAVE : TABLE_DELETE, $messageProps);
	}

	/**
	 * Forward a meeting request to additional recipients.
	 *
	 * Creates a new IPM.Schedule.Meeting.Request message addressed to the
	 * specified recipients and sends a forward notification to the organizer.
	 *
	 * @param mapistore $store                       MAPI store of the appointment
	 * @param string    $entryid                     entryid of the appointment to forward
	 * @param array     $action                      action data from the client
	 * @param bool      $directBookingMeetingRequest direct-booking flag
	 */
	public function forwardMeetingRequest($store, $entryid, $action, $directBookingMeetingRequest) {
		$message = $GLOBALS['operations']->openMessage($store, $entryid);

		if (empty($message)) {
			return;
		}

		// For recurring occurrences, open the exception attachment
		$basedate = !empty($action['basedate']) ? $action['basedate'] : false;
		$exceptionMessage = false;
		if ($basedate) {
			$recur = new Recurrence($store, $message);
			$exceptionAtt = $recur->getExceptionAttachment($basedate);
			if ($exceptionAtt) {
				$exceptionMessage = mapi_attach_openobj($exceptionAtt, 0);
			}
		}

		$sourceMessage = $exceptionMessage ?: $message;
		// Get all properties plus explicitly request PR_SUBJECT which
		// is a computed property that may be omitted without a filter.
		$sourceProps = mapi_getprops($sourceMessage);
		$subjectProps = mapi_getprops($sourceMessage, [PR_SUBJECT]);
		if (isset($subjectProps[PR_SUBJECT])) {
			$sourceProps[PR_SUBJECT] = $subjectProps[PR_SUBJECT];
		}

		// Ensure we have appointment properties even when called from a
		// non-appointment module (e.g. forwarding from the mail list).
		$props = $this->properties;
		if (empty($props['goid'])) {
			$props = $GLOBALS['properties']->getAppointmentProperties();
		}

		// Always use the current user's own store for outbox operations
		// so that forwarding works without Send As permission on shared
		// calendars.
		$userStore = $GLOBALS['mapisession']->getDefaultMessageStore();
		$storeProps = mapi_getprops($userStore, [PR_IPM_OUTBOX_ENTRYID, PR_IPM_SENTMAIL_ENTRYID]);
		$outbox = mapi_msgstore_openentry($userStore, $storeProps[PR_IPM_OUTBOX_ENTRYID]);
		$fwdMsg = mapi_folder_createmessage($outbox);

		// Copy properties and attachments from the source, excluding
		// envelope/identity properties. PR_SENDER_* will be set to
		// the current user. All PR_SENT_REPRESENTING_* variants
		// (including SMTP_ADDRESS) must be absent so gromox treats
		// this as the user's own message and skips delegation checks.
		// The organizer is still informed via the MFN.
		mapi_copyto($sourceMessage, [], [
			PR_ENTRYID,
			PR_PARENT_ENTRYID,
			PR_STORE_ENTRYID,
			PR_MESSAGE_FLAGS,
			PR_MESSAGE_RECIPIENTS,
			PR_SENTMAIL_ENTRYID,
			PR_MESSAGE_DELIVERY_TIME,
			PR_SENDER_ENTRYID,
			PR_SENDER_NAME,
			PR_SENDER_EMAIL_ADDRESS,
			PR_SENDER_ADDRTYPE,
			PR_SENDER_SEARCH_KEY,
			PR_SENT_REPRESENTING_ENTRYID,
			PR_SENT_REPRESENTING_NAME,
			PR_SENT_REPRESENTING_EMAIL_ADDRESS,
			PR_SENT_REPRESENTING_ADDRTYPE,
			PR_SENT_REPRESENTING_SEARCH_KEY,
			PR_SENT_REPRESENTING_SMTP_ADDRESS,
		], $fwdMsg, 0);

		$session = $GLOBALS['mapisession'];
		$senderName = $session->getFullName() ?: $session->getUserName();
		$senderEmail = $session->getSMTPAddress() ?: $session->getEmailAddress();

		$subject = $sourceProps[PR_SUBJECT] ?? '';
		$subjectPrefix = $action['message_action']['forwardSubjectPrefix'] ?? 'FW: ';
		$fwdProps = [
			PR_MESSAGE_CLASS => 'IPM.Schedule.Meeting.Request',
			PR_SUBJECT => $subjectPrefix . $subject,
			PR_SENTMAIL_ENTRYID => $storeProps[PR_IPM_SENTMAIL_ENTRYID],
			PR_RESPONSE_REQUESTED => true,
			PR_SENDER_NAME => $senderName,
			PR_SENDER_EMAIL_ADDRESS => $senderEmail,
			PR_SENDER_ADDRTYPE => 'SMTP',
			PR_SENDER_ENTRYID => $session->getUserEntryID(),
			PR_SENDER_SEARCH_KEY => $session->getSearchKey(),
		];

		if (isset($props['meeting'])) {
			$fwdProps[$props['meeting']] = olMeetingReceived;
		}
		if (isset($props['responsestatus'])) {
			$fwdProps[$props['responsestatus']] = olResponseNotResponded;
		}
		if (isset($props['busystatus'])) {
			$fwdProps[$props['busystatus']] = fbTentative;
		}
		if (isset($props['intendedbusystatus'], $sourceProps[$props['busystatus']])) {
			$fwdProps[$props['intendedbusystatus']] = $sourceProps[$props['busystatus']];
		}

		// Set PR_START_DATE / PR_END_DATE from the appointment named
		// properties. Mail list views and previews rely on these.
		if (isset($props['startdate'], $sourceProps[$props['startdate']])) {
			$fwdProps[PR_START_DATE] = $sourceProps[$props['startdate']];
		}
		if (isset($props['duedate'], $sourceProps[$props['duedate']])) {
			$fwdProps[PR_END_DATE] = $sourceProps[$props['duedate']];
		}

		mapi_setprops($fwdMsg, $fwdProps);

		// Remove properties that should not be on the forwarded copy.
		// Clear icon index so the mail list derives it from the message class.
		$deleteProps = [PR_ICON_INDEX, PR_MESSAGE_DELIVERY_TIME];
		if (isset($props['counter_proposal'], $sourceProps[$props['counter_proposal']])) {
			$deleteProps[] = $props['counter_proposal'];
		}
		if (isset($props['request_sent'])) {
			$deleteProps[] = $props['request_sent'];
		}
		mapi_deleteprops($fwdMsg, $deleteProps);

		// Build recipient list using the standard Operations helper which
		// handles address resolution and one-off entryid creation.
		$forwardRecipients = $action['message_action']['forwardRecipients'] ?? [];
		foreach ($forwardRecipients as &$recip) {
			if (!isset($recip['recipient_type'])) {
				$recip['recipient_type'] = MAPI_TO;
			}
			if (!isset($recip['address_type'])) {
				$recip['address_type'] = 'SMTP';
			}
			if (!isset($recip['display_type'])) {
				$recip['display_type'] = DT_MAILUSER;
			}
			if (!isset($recip['display_type_ex'])) {
				$recip['display_type_ex'] = DT_MAILUSER;
			}
			if (!isset($recip['object_type'])) {
				$recip['object_type'] = MAPI_MAILUSER;
			}
		}
		unset($recip);

		$recipientRows = $GLOBALS['operations']->createRecipientList($forwardRecipients, 'add', false, true);

		if (empty($recipientRows)) {
			return;
		}

		mapi_message_modifyrecipients($fwdMsg, MODRECIP_ADD, $recipientRows);

		// Submit the forwarded meeting request
		mapi_savechanges($fwdMsg);
		mapi_message_submitmessage($fwdMsg);

		// Send forward notification to the organizer
		$this->sendForwardNotification($store, $message, $sourceProps, $recipientRows, $props, $userStore);

		$this->sendFeedback(true);
	}

	/**
	 * Send a forward notification to the meeting organizer informing them
	 * that the meeting was forwarded to additional recipients.
	 *
	 * @param mapistore $store          MAPI store containing the appointment
	 * @param resource  $message        original appointment MAPI message
	 * @param array     $messageProps   properties of the source message
	 * @param array     $recipientRows  MAPI recipient rows of forward targets
	 * @param array     $props          property tag mapping
	 * @param mapistore $userStore      current user's default store
	 */
	private function sendForwardNotification($store, $message, $messageProps, $recipientRows, $props, $userStore) {
		// Do not notify if the current user is the organizer
		$req = new Meetingrequest($store, $message, $GLOBALS['mapisession']->getSession());
		if ($req->isLocalOrganiser()) {
			return;
		}

		// Determine organizer address
		$organizerEmail = $messageProps[PR_SENT_REPRESENTING_EMAIL_ADDRESS] ?? '';
		$organizerName = $messageProps[PR_SENT_REPRESENTING_NAME] ?? '';
		if (empty($organizerEmail)) {
			return;
		}

		$userStoreProps = mapi_getprops($userStore, [PR_IPM_OUTBOX_ENTRYID, PR_IPM_SENTMAIL_ENTRYID]);
		$outbox = mapi_msgstore_openentry($userStore, $userStoreProps[PR_IPM_OUTBOX_ENTRYID]);
		$notifMsg = mapi_folder_createmessage($outbox);

		// Collect forward recipient names and addresses
		$forwardedTo = [];
		foreach ($recipientRows as $recip) {
			$name = $recip[PR_DISPLAY_NAME] ?? '';
			$email = $recip[PR_SMTP_ADDRESS] ?? $recip[PR_EMAIL_ADDRESS] ?? '';
			if (!empty($name) && !empty($email) && $name !== $email) {
				$forwardedTo[] = $name . ' (' . $email . ')';
			} else {
				$forwardedTo[] = !empty($name) ? $name : $email;
			}
		}
		$currentUser = $GLOBALS['mapisession']->getFullName() ?: $GLOBALS['mapisession']->getUserName();
		$subject = $messageProps[PR_SUBJECT] ?? '';
		$recipients = implode(', ', $forwardedTo);

		// Format meeting time. Property keys differ between the
		// appointment set (startdate) and mail set (appointment_startdate).
		// Prefer the appointment_* keys as the mail set also has
		// startdate/duedate mapped to Task properties.
		$startKey = isset($props['appointment_startdate']) ? 'appointment_startdate' : 'startdate';
		$endKey = isset($props['appointment_duedate']) ? 'appointment_duedate' : 'duedate';
		$locKey = isset($props['appointment_location']) ? 'appointment_location' : 'location';
		$startDate = isset($props[$startKey], $messageProps[$props[$startKey]])
			? $messageProps[$props[$startKey]] : null;
		$endDate = isset($props[$endKey], $messageProps[$props[$endKey]])
			? $messageProps[$props[$endKey]] : null;
		$location = isset($props[$locKey], $messageProps[$props[$locKey]])
			? $messageProps[$props[$locKey]] : '';
		$meetingTime = '';
		if ($startDate) {
			$meetingTime = date(_('l, F j, Y g:i A'), $startDate);
			if ($endDate) {
				$meetingTime .= ' - ' . date(_('l, F j, Y g:i A'), $endDate);
			}
		}

		// Build notification body
		$body = _('Your meeting has been forwarded') . "\n\n";
		$body .= $currentUser . ' ' . _('has forwarded your meeting request to others.') . "\n\n";
		$body .= '     ' . _('Meeting') . ': ' . $subject . "\n";
		if (!empty($meetingTime)) {
			$body .= '     ' . _('Meeting Time') . ': ' . $meetingTime . "\n";
		}
		if (!empty($location)) {
			$body .= '     ' . _('Location') . ': ' . $location . "\n";
		}
		$body .= '     ' . _('Recipients') . ': ' . $recipients . "\n";

		$notifProps = [
			PR_MESSAGE_CLASS => 'IPM.Schedule.Meeting.Notification.Forward',
			PR_SUBJECT => _('Your meeting has been forwarded') . ': ' . $subject,
			PR_BODY => $body,
			PR_SENTMAIL_ENTRYID => $userStoreProps[PR_IPM_SENTMAIL_ENTRYID],
		];

		// Resolve named property tags against the user's store so
		// the tags match when the MFN is later read back.
		$namedProps = getPropIdsFromStrings($userStore, [
			'startdate' => "PT_SYSTIME:PSETID_Appointment:" . PidLidAppointmentStartWhole,
			'duedate' => "PT_SYSTIME:PSETID_Appointment:" . PidLidAppointmentEndWhole,
			'location' => "PT_STRING8:PSETID_Appointment:" . PidLidLocation,
			'goid' => "PT_BINARY:PSETID_Meeting:" . PidLidGlobalObjectId,
			'goid2' => "PT_BINARY:PSETID_Meeting:" . PidLidCleanGlobalObjectId,
		]);

		// Copy appointment properties so the preview can show
		// When/Location and link the notification to the calendar item.
		if (isset($props['goid'], $messageProps[$props['goid']])) {
			$notifProps[$namedProps['goid']] = $messageProps[$props['goid']];
		}
		if (isset($props['goid2'], $messageProps[$props['goid2']])) {
			$notifProps[$namedProps['goid2']] = $messageProps[$props['goid2']];
		}
		if ($startDate) {
			$notifProps[PR_START_DATE] = $startDate;
			$notifProps[$namedProps['startdate']] = $startDate;
		}
		if ($endDate) {
			$notifProps[PR_END_DATE] = $endDate;
			$notifProps[$namedProps['duedate']] = $endDate;
		}
		if (!empty($location)) {
			$notifProps[$namedProps['location']] = $location;
		}

		mapi_setprops($notifMsg, $notifProps);

		// Address the notification to the organizer
		$addrType = $messageProps[PR_SENT_REPRESENTING_ADDRTYPE] ?? 'SMTP';
		$organizerRecip = [
			PR_DISPLAY_NAME => $organizerName,
			PR_EMAIL_ADDRESS => $organizerEmail,
			PR_ADDRTYPE => $addrType,
			PR_RECIPIENT_TYPE => MAPI_TO,
		];
		if (!empty($messageProps[PR_SENT_REPRESENTING_ENTRYID])) {
			$organizerRecip[PR_ENTRYID] = $messageProps[PR_SENT_REPRESENTING_ENTRYID];
		} else {
			$organizerRecip[PR_ENTRYID] = mapi_createoneoff($organizerName, $addrType, $organizerEmail);
		}
		if (!empty($messageProps[PR_SENT_REPRESENTING_SEARCH_KEY])) {
			$organizerRecip[PR_SEARCH_KEY] = $messageProps[PR_SENT_REPRESENTING_SEARCH_KEY];
		}
		mapi_message_modifyrecipients($notifMsg, MODRECIP_ADD, [$organizerRecip]);

		mapi_savechanges($notifMsg);
		mapi_message_submitmessage($notifMsg);
	}
}
