Issues (203)

class.meetingrequest.php (9 issues)

1
<?php
2
3
/*
4
 * SPDX-License-Identifier: AGPL-3.0-only
5
 * SPDX-FileCopyrightText: Copyright 2005-2016 Zarafa Deutschland GmbH
6
 * SPDX-FileCopyrightText: Copyright 2020-2024 grommunio GmbH
7
 */
8
9
class Meetingrequest {
10
	/*
11
	 * NOTE
12
	 *
13
	 * This class is designed to modify and update meeting request properties
14
	 * and to search for linked appointments in the calendar. It does not
15
	 * - set standard properties like subject or location
16
	 * - commit property changes through savechanges() (except in accept() and decline())
17
	 *
18
	 * To set all the other properties, just handle the item as any other appointment
19
	 * item. You aren't even required to set those properties before or after using
20
	 * this class. If you update properties before REsending a meeting request (ie with
21
	 * a time change) you MUST first call updateMeetingRequest() so the internal counters
22
	 * can be updated. You can then submit the message any way you like.
23
	 *
24
	 */
25
26
	/*
27
	 * How to use
28
	 * ----------
29
	 *
30
	 * Sending a meeting request:
31
	 * - Create appointment item as normal, but as 'tentative'
32
	 *   (this is the state of the item when the receiving user has received but
33
	 *    not accepted the item)
34
	 * - Set recipients as normally in e-mails
35
	 * - Create Meetingrequest class instance
36
	 * - Call checkCalendarWriteAccess(), to check for write permissions on calendar folder
37
	 * - Call setMeetingRequest(), this turns on all the meeting request properties in the
38
	 *   calendar item
39
	 * - Call sendMeetingRequest(), this sends a copy of the item with some extra properties
40
	 *
41
	 * Updating a meeting request:
42
	 * - Create Meetingrequest class instance
43
	 * - Call checkCalendarWriteAccess(), to check for write permissions on calendar folder
44
	 * - Call updateMeetingRequest(), this updates the counters
45
	 * - Call checkSignificantChanges(), this will check for significant changes and if needed will clear the
46
	 *   existing recipient responses
47
	 * - Call sendMeetingRequest()
48
	 *
49
	 * Clicking on a an e-mail:
50
	 * - Create Meetingrequest class instance
51
	 * - Check isMeetingRequest(), if true:
52
	 *   - Check isLocalOrganiser(), if true then ignore the message
53
	 *   - Check isInCalendar(), if not call doAccept(true, false, false). This adds the item in your
54
	 *     calendar as tentative without sending a response
55
	 *   - Show Accept, Tentative, Decline buttons
56
	 *   - When the user presses Accept, Tentative or Decline, call doAccept(false, true, true),
57
	 *     doAccept(true, true, true) or doDecline(true) respectively to really accept or decline and
58
	 *     send the response. This will remove the request from your inbox.
59
	 * - Check isMeetingRequestResponse, if true:
60
	 *   - Check isLocalOrganiser(), if not true then ignore the message
61
	 *   - Call processMeetingRequestResponse()
62
	 *     This will update the trackstatus of all recipients, and set the item to 'busy'
63
	 *     when all the recipients have accepted.
64
	 * - Check isMeetingCancellation(), if true:
65
	 *   - Check isLocalOrganiser(), if true then ignore the message
66
	 *   - Check isInCalendar(), if not, then ignore
67
	 *     Call processMeetingCancellation()
68
	 *   - Show 'Remove From Calendar' button to user
69
	 *   - When userpresses button, call doRemoveFromCalendar(), which removes the item from your
70
	 *     calendar and deletes the message
71
	 *
72
	 * Cancelling a meeting request:
73
	 *   - Call doCancelInvitation, which will send cancellation mails to attendees and will remove
74
	 *     meeting object from calendar
75
	 */
76
77
	// All properties for a recipient that are interesting
78
	public $recipprops = [
79
		PR_ENTRYID,
80
		PR_DISPLAY_NAME,
81
		PR_EMAIL_ADDRESS,
82
		PR_RECIPIENT_ENTRYID,
83
		PR_RECIPIENT_TYPE,
84
		PR_SEND_INTERNET_ENCODING,
85
		PR_SEND_RICH_INFO,
86
		PR_RECIPIENT_DISPLAY_NAME,
87
		PR_ADDRTYPE,
88
		PR_DISPLAY_TYPE,
89
		PR_DISPLAY_TYPE_EX,
90
		PR_RECIPIENT_TRACKSTATUS,
91
		PR_RECIPIENT_TRACKSTATUS_TIME,
92
		PR_RECIPIENT_FLAGS,
93
		PR_ROWID,
94
		PR_OBJECT_TYPE,
95
		PR_SEARCH_KEY,
96
		PR_SMTP_ADDRESS,
97
	];
98
99
	/**
100
	 * Indication whether the setting of resources in a Meeting Request is success (false) or if it
101
	 * has failed (integer).
102
	 *
103
	 * @var null|false|int
104
	 *
105
	 * @psalm-var 1|3|4|false|null
106
	 */
107
	public $errorSetResource;
108
109
	public $proptags;
110
111
	/**
112
	 * @var false|string
113
	 */
114
	private $meetingTimeInfo;
115
116
	/**
117
	 * @var null|bool
118
	 */
119
	private $includesResources;
120
	private $nonAcceptingResources;
121
	private $recipientDisplayname;
122
123
	/**
124
	 * Constructor.
125
	 *
126
	 * Takes a store and a message. The message is an appointment item
127
	 * that should be converted into a meeting request or an incoming
128
	 * e-mail message that is a meeting request.
129
	 *
130
	 * The $session variable is optional, but required if the following features
131
	 * are to be used:
132
	 *
133
	 * - Sending meeting requests for meetings that are not in your own store
134
	 * - Sending meeting requests to resources, resource availability checking and resource freebusy updates
135
	 *
136
	 * @param mixed $store
137
	 * @param mixed $message
138
	 * @param mixed $session
139
	 * @param mixed $enableDirectBooking
140
	 */
141
	public function __construct(private $store, public $message, private $session = false, private $enableDirectBooking = true) {
142
		// This variable string saves time information for the MR.
143
		$this->meetingTimeInfo = false;
144
145
		$properties = [];
146
		$properties['goid'] = 'PT_BINARY:PSETID_Meeting:0x3';
147
		$properties['goid2'] = 'PT_BINARY:PSETID_Meeting:0x23';
148
		$properties['type'] = 'PT_STRING8:PSETID_Meeting:0x24';
149
		$properties['meetingrecurring'] = 'PT_BOOLEAN:PSETID_Meeting:0x5';
150
		$properties['unknown2'] = 'PT_BOOLEAN:PSETID_Meeting:0xa';
151
		$properties['attendee_critical_change'] = 'PT_SYSTIME:PSETID_Meeting:0x1';
152
		$properties['owner_critical_change'] = 'PT_SYSTIME:PSETID_Meeting:0x1a';
153
		$properties['meetingstatus'] = 'PT_LONG:PSETID_Appointment:' . PidLidAppointmentStateFlags;
154
		$properties['responsestatus'] = 'PT_LONG:PSETID_Appointment:0x8218';
155
		$properties['unknown6'] = 'PT_LONG:PSETID_Meeting:0x4';
156
		$properties['replytime'] = 'PT_SYSTIME:PSETID_Appointment:0x8220';
157
		$properties['usetnef'] = 'PT_BOOLEAN:PSETID_Common:0x8582';
158
		$properties['recurrence_data'] = 'PT_BINARY:PSETID_Appointment:' . PidLidAppointmentRecur;
159
		$properties['reminderminutes'] = 'PT_LONG:PSETID_Common:' . PidLidReminderDelta;
160
		$properties['reminderset'] = 'PT_BOOLEAN:PSETID_Common:' . PidLidReminderSet;
161
		$properties['sendasical'] = 'PT_BOOLEAN:PSETID_Appointment:0x8200';
162
		$properties['updatecounter'] = 'PT_LONG:PSETID_Appointment:' . PidLidAppointmentSequence;					// AppointmentSequenceNumber
163
		$properties['unknown7'] = 'PT_LONG:PSETID_Appointment:0x8202';
164
		$properties['last_updatecounter'] = 'PT_LONG:PSETID_Appointment:0x8203';			// AppointmentLastSequence
165
		$properties['busystatus'] = 'PT_LONG:PSETID_Appointment:' . PidLidBusyStatus;
166
		$properties['intendedbusystatus'] = 'PT_LONG:PSETID_Appointment:' . PidLidIntendedBusyStatus;
167
		$properties['start'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidAppointmentStartWhole;
168
		$properties['responselocation'] = 'PT_STRING8:PSETID_Meeting:0x2';
169
		$properties['location'] = 'PT_STRING8:PSETID_Appointment:' . PidLidLocation;
170
		$properties['requestsent'] = 'PT_BOOLEAN:PSETID_Appointment:0x8229';		// PidLidFInvited, MeetingRequestWasSent
171
		$properties['startdate'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidAppointmentStartWhole;
172
		$properties['duedate'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidAppointmentEndWhole;
173
		$properties['flagdueby'] = 'PT_SYSTIME:PSETID_Common:' . PidLidReminderSignalTime;
174
		$properties['commonstart'] = 'PT_SYSTIME:PSETID_Common:0x8516';
175
		$properties['commonend'] = 'PT_SYSTIME:PSETID_Common:0x8517';
176
		$properties['recurring'] = 'PT_BOOLEAN:PSETID_Appointment:' . PidLidRecurring;
177
		$properties['clipstart'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidClipStart;
178
		$properties['clipend'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidClipEnd;
179
		$properties['start_recur_date'] = 'PT_LONG:PSETID_Meeting:0xD';				// StartRecurTime
180
		$properties['start_recur_time'] = 'PT_LONG:PSETID_Meeting:0xE';				// StartRecurTime
181
		$properties['end_recur_date'] = 'PT_LONG:PSETID_Meeting:0xF';				// EndRecurDate
182
		$properties['end_recur_time'] = 'PT_LONG:PSETID_Meeting:0x10';				// EndRecurTime
183
		$properties['is_exception'] = 'PT_BOOLEAN:PSETID_Meeting:0xA';				// LID_IS_EXCEPTION
184
		$properties['apptreplyname'] = 'PT_STRING8:PSETID_Appointment:0x8230';
185
		// Propose new time properties
186
		$properties['proposed_start_whole'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidAppointmentProposedStartWhole;
187
		$properties['proposed_end_whole'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidAppointmentProposedEndWhole;
188
		$properties['proposed_duration'] = 'PT_LONG:PSETID_Appointment:0x8256';
189
		$properties['counter_proposal'] = 'PT_BOOLEAN:PSETID_Appointment:' . PidLidAppointmentCounterProposal;
190
		$properties['recurring_pattern'] = 'PT_STRING8:PSETID_Appointment:' . PidLidRecurrencePattern;
191
		$properties['basedate'] = 'PT_SYSTIME:PSETID_Appointment:' . PidLidExceptionReplaceTime;
192
		$properties['meetingtype'] = 'PT_LONG:PSETID_Meeting:0x26';
193
		$properties['timezone_data'] = 'PT_BINARY:PSETID_Appointment:' . PidLidTimeZoneStruct;
194
		$properties['timezone'] = 'PT_STRING8:PSETID_Appointment:' . PidLidTimeZoneDescription;
195
		$properties['categories'] = 'PT_MV_STRING8:PS_PUBLIC_STRINGS:Keywords';
196
		$properties['private'] = 'PT_BOOLEAN:PSETID_Common:' . PidLidPrivate;
197
		$properties['alldayevent'] = 'PT_BOOLEAN:PSETID_Appointment:' . PidLidAppointmentSubType;
198
		$properties['toattendeesstring'] = 'PT_STRING8:PSETID_Appointment:0x823B';
199
		$properties['ccattendeesstring'] = 'PT_STRING8:PSETID_Appointment:0x823C';
200
201
		$this->proptags = getPropIdsFromStrings($this->store, $properties);
202
	}
203
204
	/**
205
	 * Sets the direct booking property. This is an alternative to the setting of the direct booking
206
	 * property through the constructor. However, setting it in the constructor is preferred.
207
	 *
208
	 * @param bool $directBookingSetting
209
	 */
210
	public function setDirectBooking($directBookingSetting): void {
211
		$this->enableDirectBooking = $directBookingSetting;
212
	}
213
214
	/**
215
	 * Returns TRUE if the message pointed to is an incoming meeting request and should
216
	 * therefore be replied to with doAccept or doDecline().
217
	 *
218
	 * @param string $messageClass message class to use for checking
219
	 *
220
	 * @return bool returns true if this is a meeting request else false
221
	 */
222
	public function isMeetingRequest($messageClass = false) {
223
		if ($messageClass === false) {
224
			$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS]);
225
			$messageClass = $props[PR_MESSAGE_CLASS] ?? false;
226
		}
227
228
		if ($messageClass !== false && stripos($messageClass, 'ipm.schedule.meeting.request') === 0) {
229
			return true;
230
		}
231
232
		return false;
233
	}
234
235
	/**
236
	 * Returns TRUE if the message pointed to is a returning meeting request response.
237
	 *
238
	 * @param string $messageClass message class to use for checking
239
	 *
240
	 * @return bool returns true if this is a meeting request else false
241
	 */
242
	public function isMeetingRequestResponse($messageClass = false) {
243
		if ($messageClass === false) {
244
			$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS]);
245
			$messageClass = $props[PR_MESSAGE_CLASS] ?? false;
246
		}
247
248
		if ($messageClass !== false && stripos($messageClass, 'ipm.schedule.meeting.resp') === 0) {
249
			return true;
250
		}
251
252
		return false;
253
	}
254
255
	/**
256
	 * Returns TRUE if the message pointed to is a cancellation request.
257
	 *
258
	 * @param string $messageClass message class to use for checking
259
	 *
260
	 * @return bool returns true if this is a meeting request else false
261
	 */
262
	public function isMeetingCancellation($messageClass = false) {
263
		if ($messageClass === false) {
264
			$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS]);
265
			$messageClass = $props[PR_MESSAGE_CLASS] ?? false;
266
		}
267
268
		if ($messageClass !== false && stripos($messageClass, 'ipm.schedule.meeting.canceled') === 0) {
269
			return true;
270
		}
271
272
		return false;
273
	}
274
275
	/**
276
	 * Function is used to get the last update counter of meeting request.
277
	 *
278
	 * @return bool|int false when last_updatecounter not found else return last_updatecounter
279
	 */
280
	public function getLastUpdateCounter() {
281
		$calendarItemProps = mapi_getprops($this->message, [$this->proptags['last_updatecounter']]);
282
		if (isset($calendarItemProps) && !empty($calendarItemProps)) {
283
			return $calendarItemProps[$this->proptags['last_updatecounter']];
284
		}
285
286
		return false;
287
	}
288
289
	/**
290
	 * Process an incoming meeting request response. This updates the appointment
291
	 * in your calendar to show whether the user has accepted or declined.
292
	 */
293
	public function processMeetingRequestResponse() {
294
		if (!$this->isMeetingRequestResponse()) {
295
			return;
296
		}
297
298
		if (!$this->isLocalOrganiser()) {
299
			return;
300
		}
301
302
		// Get information we need from the response message
303
		$messageprops = mapi_getprops($this->message, [
304
			$this->proptags['goid'],
305
			$this->proptags['goid2'],
306
			PR_OWNER_APPT_ID,
307
			PR_SENT_REPRESENTING_EMAIL_ADDRESS,
308
			PR_SENT_REPRESENTING_NAME,
309
			PR_SENT_REPRESENTING_ADDRTYPE,
310
			PR_SENT_REPRESENTING_ENTRYID,
311
			PR_SENT_REPRESENTING_SEARCH_KEY,
312
			PR_MESSAGE_DELIVERY_TIME,
313
			PR_MESSAGE_CLASS,
314
			PR_PROCESSED,
315
			PR_RCVD_REPRESENTING_ENTRYID,
316
			$this->proptags['proposed_start_whole'],
317
			$this->proptags['proposed_end_whole'],
318
			$this->proptags['proposed_duration'],
319
			$this->proptags['counter_proposal'],
320
			$this->proptags['attendee_critical_change'],
321
		]);
322
323
		$goid2 = $messageprops[$this->proptags['goid2']];
324
325
		if (!isset($goid2) || !isset($messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS])) {
326
			return;
327
		}
328
329
		// Find basedate in GlobalID(0x3), this can be a response for an occurrence
330
		$basedate = $this->getBasedateFromGlobalID($messageprops[$this->proptags['goid']]);
331
332
		$userStore = $this->store;
333
		// check if delegate is processing the response
334
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID])) {
335
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
336
			if (!empty($delegatorStore['store'])) {
337
				$userStore = $delegatorStore['store'];
338
			}
339
		}
340
341
		// check for calendar access
342
		if ($this->checkCalendarWriteAccess($userStore) !== true) {
343
			// Throw an exception that we don't have write permissions on calendar folder,
344
			// allow caller to fill the error message
345
			throw new MAPIException(null, MAPI_E_NO_ACCESS);
346
		}
347
348
		$calendarItem = $this->getCorrespondentCalendarItem(true);
349
350
		// Open the calendar items, and update all the recipients of the calendar item that match
351
		// the email address of the response.
352
		if ($calendarItem !== false) {
353
			$this->processResponse($userStore, $calendarItem, $basedate, $messageprops);
354
		}
355
	}
356
357
	/**
358
	 * Process every incoming MeetingRequest response.This updates the appointment
359
	 * in your calendar to show whether the user has accepted or declined.
360
	 *
361
	 * @param resource $store        contains the userStore in which the meeting is created
362
	 * @param mixed    $calendarItem resource of the calendar item for which this response has arrived
363
	 * @param mixed    $basedate     if present the create an exception
364
	 * @param array    $messageprops contains message properties
365
	 *
366
	 * @return null|false
367
	 */
368
	public function processResponse($store, $calendarItem, $basedate, $messageprops) {
369
		$senderentryid = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
370
		$messageclass = $messageprops[PR_MESSAGE_CLASS];
371
		$deliverytime = $messageprops[PR_MESSAGE_DELIVERY_TIME];
372
		$recurringItem = 0;
373
374
		// Open the calendar item, find the sender in the recipient table and update all the recipients of the calendar item that match
375
		// the email address of the response.
376
		$calendarItemProps = mapi_getprops($calendarItem, [$this->proptags['recurring'], PR_STORE_ENTRYID, PR_PARENT_ENTRYID, PR_ENTRYID, $this->proptags['updatecounter']]);
377
378
		// check if meeting response is already processed
379
		if (isset($messageprops[PR_PROCESSED]) && $messageprops[PR_PROCESSED] == true) {
380
			// meeting is already processed
381
			return;
382
		}
383
		mapi_setprops($this->message, [PR_PROCESSED => true]);
384
		mapi_savechanges($this->message);
385
386
		// if meeting is updated in organizer's calendar then we don't need to process
387
		// old response
388
		if ($this->isMeetingUpdated($basedate)) {
389
			return;
390
		}
391
392
		// If basedate is found, then create/modify exception msg and do processing
393
		if ($basedate && isset($calendarItemProps[$this->proptags['recurring']]) && $calendarItemProps[$this->proptags['recurring']] === true) {
394
			$recurr = new Recurrence($store, $calendarItem);
395
396
			// Copy properties from meeting request
397
			$exception_props = mapi_getprops($this->message, [
398
				PR_OWNER_APPT_ID,
399
				$this->proptags['proposed_start_whole'],
400
				$this->proptags['proposed_end_whole'],
401
				$this->proptags['proposed_duration'],
402
				$this->proptags['counter_proposal'],
403
			]);
404
405
			// Create/modify exception
406
			if ($recurr->isException($basedate)) {
407
				$recurr->modifyException($exception_props, $basedate);
408
			}
409
			else {
410
				// When we are creating an exception we need copy recipients from main recurring item
411
				$recipTable = mapi_message_getrecipienttable($calendarItem);
412
				$recips = mapi_table_queryallrows($recipTable, $this->recipprops);
413
414
				// Retrieve actual start/due dates from calendar item.
415
				$exception_props[$this->proptags['startdate']] = $recurr->getOccurrenceStart($basedate);
416
				$exception_props[$this->proptags['duedate']] = $recurr->getOccurrenceEnd($basedate);
417
418
				$recurr->createException($exception_props, $basedate, false, $recips);
419
			}
420
421
			mapi_savechanges($calendarItem);
422
423
			$attach = $recurr->getExceptionAttachment($basedate);
424
			if ($attach) {
425
				$recurringItem = $calendarItem;
426
				$calendarItem = mapi_attach_openobj($attach, MAPI_MODIFY);
427
			}
428
			else {
429
				return false;
430
			}
431
		}
432
433
		// Get the recipients of the calendar item
434
		$reciptable = mapi_message_getrecipienttable($calendarItem);
435
		$recipients = mapi_table_queryallrows($reciptable, $this->recipprops);
436
437
		// FIXME we should look at the updatecounter property and compare it
438
		// to the counter in the recipient to see if this update is actually
439
		// newer than the status in the calendar item
440
		$found = false;
441
442
		$totalrecips = 0;
443
		$acceptedrecips = 0;
444
		foreach ($recipients as $recipient) {
445
			++$totalrecips;
446
			// external recipients might not have entryid
447
			if (!isset($recipient[PR_ENTRYID]) &&
448
				$recipient[PR_EMAIL_ADDRESS] == $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS]) {
449
				$recipient[PR_ENTRYID] = $senderentryid;
450
			}
451
			if (isset($recipient[PR_ENTRYID]) && $this->compareABEntryIDs($recipient[PR_ENTRYID], $senderentryid)) {
452
				$found = true;
453
454
				/*
455
				 * If value of attendee_critical_change on meeting response mail is less than PR_RECIPIENT_TRACKSTATUS_TIME
456
				 * on the corresponding recipientRow of meeting then we ignore this response mail.
457
				 */
458
				if (isset($recipient[PR_RECIPIENT_TRACKSTATUS_TIME]) && ($messageprops[$this->proptags['attendee_critical_change']] < $recipient[PR_RECIPIENT_TRACKSTATUS_TIME])) {
459
					continue;
460
				}
461
462
				// The email address matches, update the row
463
				$recipient[PR_RECIPIENT_TRACKSTATUS] = $this->getTrackStatus($messageclass);
464
				if (isset($messageprops[$this->proptags['attendee_critical_change']])) {
465
					$recipient[PR_RECIPIENT_TRACKSTATUS_TIME] = $messageprops[$this->proptags['attendee_critical_change']];
466
				}
467
468
				// If this is a counter proposal, set the proposal properties in the recipient row
469
				if (isset($messageprops[$this->proptags['counter_proposal']]) && $messageprops[$this->proptags['counter_proposal']]) {
470
					$recipient[PR_RECIPIENT_PROPOSEDSTARTTIME] = $messageprops[$this->proptags['proposed_start_whole']];
471
					$recipient[PR_RECIPIENT_PROPOSEDENDTIME] = $messageprops[$this->proptags['proposed_end_whole']];
472
					$recipient[PR_RECIPIENT_PROPOSED] = $messageprops[$this->proptags['counter_proposal']];
473
				}
474
475
				// Update the recipient information
476
				mapi_message_modifyrecipients($calendarItem, MODRECIP_REMOVE, [$recipient]);
477
				mapi_message_modifyrecipients($calendarItem, MODRECIP_ADD, [$recipient]);
478
			}
479
			if (isset($recipient[PR_RECIPIENT_TRACKSTATUS]) && $recipient[PR_RECIPIENT_TRACKSTATUS] == olRecipientTrackStatusAccepted) {
480
				++$acceptedrecips;
481
			}
482
		}
483
484
		// If the recipient was not found in the original calendar item,
485
		// then add the recpient as a new optional recipient
486
		if (!$found) {
487
			$recipient = [];
488
			$recipient[PR_ENTRYID] = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
489
			$recipient[PR_EMAIL_ADDRESS] = $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
490
			$recipient[PR_DISPLAY_NAME] = $messageprops[PR_SENT_REPRESENTING_NAME];
491
			$recipient[PR_ADDRTYPE] = $messageprops[PR_SENT_REPRESENTING_ADDRTYPE];
492
			$recipient[PR_RECIPIENT_TYPE] = MAPI_CC;
493
			$recipient[PR_SEARCH_KEY] = $messageprops[PR_SENT_REPRESENTING_SEARCH_KEY];
494
			$recipient[PR_RECIPIENT_TRACKSTATUS] = $this->getTrackStatus($messageclass);
495
			$recipient[PR_RECIPIENT_TRACKSTATUS_TIME] = $deliverytime;
496
497
			// If this is a counter proposal, set the proposal properties in the recipient row
498
			if (isset($messageprops[$this->proptags['counter_proposal']])) {
499
				$recipient[PR_RECIPIENT_PROPOSEDSTARTTIME] = $messageprops[$this->proptags['proposed_start_whole']];
500
				$recipient[PR_RECIPIENT_PROPOSEDENDTIME] = $messageprops[$this->proptags['proposed_end_whole']];
501
				$recipient[PR_RECIPIENT_PROPOSED] = $messageprops[$this->proptags['counter_proposal']];
502
			}
503
504
			mapi_message_modifyrecipients($calendarItem, MODRECIP_ADD, [$recipient]);
505
			++$totalrecips;
506
			if ($recipient[PR_RECIPIENT_TRACKSTATUS] == olRecipientTrackStatusAccepted) {
507
				++$acceptedrecips;
508
			}
509
		}
510
511
		// TODO: Update counter proposal number property on message
512
		/*
513
		If it is the first time this attendee has proposed a new date/time, increment the value of the PidLidAppointmentProposalNumber property on the organizer's meeting object, by 0x00000001. If this property did not previously exist on the organizer's meeting object, it MUST be set with a value of 0x00000001.
514
		*/
515
		// If this is a counter proposal, set the counter proposal indicator boolean
516
		if (isset($messageprops[$this->proptags['counter_proposal']])) {
517
			$props = [];
518
			if ($messageprops[$this->proptags['counter_proposal']]) {
519
				$props[$this->proptags['counter_proposal']] = true;
520
			}
521
			else {
522
				$props[$this->proptags['counter_proposal']] = false;
523
			}
524
525
			mapi_setprops($calendarItem, $props);
526
		}
527
528
		mapi_savechanges($calendarItem);
529
		if (isset($attach)) {
530
			mapi_savechanges($attach);
531
			mapi_savechanges($recurringItem);
532
		}
533
	}
534
535
	/**
536
	 * Process an incoming meeting request cancellation. This updates the
537
	 * appointment in your calendar to show that the meeting has been cancelled.
538
	 */
539
	public function processMeetingCancellation() {
540
		if (!$this->isMeetingCancellation()) {
541
			return;
542
		}
543
544
		if ($this->isLocalOrganiser()) {
545
			return;
546
		}
547
548
		if (!$this->isInCalendar()) {
549
			return;
550
		}
551
552
		$listProperties = $this->proptags;
553
		$listProperties['subject'] = PR_SUBJECT;
554
		$listProperties['sent_representing_name'] = PR_SENT_REPRESENTING_NAME;
555
		$listProperties['sent_representing_address_type'] = PR_SENT_REPRESENTING_ADDRTYPE;
556
		$listProperties['sent_representing_email_address'] = PR_SENT_REPRESENTING_EMAIL_ADDRESS;
557
		$listProperties['sent_representing_entryid'] = PR_SENT_REPRESENTING_ENTRYID;
558
		$listProperties['sent_representing_search_key'] = PR_SENT_REPRESENTING_SEARCH_KEY;
559
		$listProperties['rcvd_representing_name'] = PR_RCVD_REPRESENTING_NAME;
560
		$listProperties['rcvd_representing_address_type'] = PR_RCVD_REPRESENTING_ADDRTYPE;
561
		$listProperties['rcvd_representing_email_address'] = PR_RCVD_REPRESENTING_EMAIL_ADDRESS;
562
		$listProperties['rcvd_representing_entryid'] = PR_RCVD_REPRESENTING_ENTRYID;
563
		$listProperties['rcvd_representing_search_key'] = PR_RCVD_REPRESENTING_SEARCH_KEY;
564
		$messageProps = mapi_getprops($this->message, $listProperties);
565
566
		$goid = $messageProps[$this->proptags['goid']];	// GlobalID (0x3)
567
		if (!isset($goid)) {
568
			return;
569
		}
570
571
		$store = $this->store;
572
		// get delegator store, if delegate is processing this cancellation
573
		if (isset($messageProps[PR_RCVD_REPRESENTING_ENTRYID])) {
574
			$delegatorStore = $this->getDelegatorStore($messageProps[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
575
			if (!empty($delegatorStore['store'])) {
576
				$store = $delegatorStore['store'];
577
			}
578
		}
579
580
		// check for calendar access
581
		if ($this->checkCalendarWriteAccess($store) !== true) {
582
			// Throw an exception that we don't have write permissions on calendar folder,
583
			// allow caller to fill the error message
584
			throw new MAPIException(null, MAPI_E_NO_ACCESS);
585
		}
586
587
		$calendarItem = $this->getCorrespondentCalendarItem(true);
588
		$basedate = $this->getBasedateFromGlobalID($goid);
589
590
		if ($calendarItem !== false) {
591
			// if basedate is provided and we could not find the item then it could be that we are processing
592
			// an exception so get the exception and process it
593
			if ($basedate) {
594
				$calendarItemProps = mapi_getprops($calendarItem, [$this->proptags['recurring']]);
595
				if ($calendarItemProps[$this->proptags['recurring']] === true) {
596
					$recurr = new Recurrence($store, $calendarItem);
597
598
					// Set message class
599
					$messageProps[PR_MESSAGE_CLASS] = 'IPM.Appointment';
600
601
					if ($recurr->isException($basedate)) {
602
						$recurr->modifyException($messageProps, $basedate);
603
					}
604
					else {
605
						$recurr->createException($messageProps, $basedate);
606
					}
607
				}
608
			}
609
			else {
610
				// set the properties of the cancellation object
611
				mapi_setprops($calendarItem, $messageProps);
612
			}
613
614
			mapi_savechanges($calendarItem);
615
		}
616
	}
617
618
	/**
619
	 * Returns true if the corresponding calendar items exists in the celendar folder for this
620
	 * meeting request/response/cancellation.
621
	 */
622
	public function isInCalendar(): bool {
623
		// @TODO check for deleted exceptions
624
		return $this->getCorrespondentCalendarItem(false) !== false;
625
	}
626
627
	/**
628
	 * Accepts the meeting request by moving the item to the calendar
629
	 * and sending a confirmation message back to the sender. If $tentative
630
	 * is TRUE, then the item is accepted tentatively. After accepting, you
631
	 * can't use this class instance any more. The message is closed. If you
632
	 * specify TRUE for 'move', then the item is actually moved (from your
633
	 * inbox probably) to the calendar. If you don't, it is copied into
634
	 * your calendar.
635
	 *
636
	 * @param bool  $tentative            true if user as tentative accepted the meeting
637
	 * @param bool  $sendresponse         true if a response has to be sent to organizer
638
	 * @param bool  $move                 true if the meeting request should be moved to the deleted items after processing
639
	 * @param mixed $newProposedStartTime contains starttime if user has proposed other time
640
	 * @param mixed $newProposedEndTime   contains endtime if user has proposed other time
641
	 * @param mixed $body
642
	 * @param mixed $userAction
643
	 * @param mixed $store
644
	 * @param mixed $basedate             start of day of occurrence for which user has accepted the recurrent meeting
645
	 * @param bool  $isImported           true to indicate that MR is imported from .ics or .vcs file else it false.
646
	 *
647
	 * @return bool|string $entryid entryid of item which created/updated in calendar
648
	 */
649
	public function doAccept($tentative, $sendresponse, $move, $newProposedStartTime = false, $newProposedEndTime = false, $body = false, $userAction = false, $store = false, $basedate = false, $isImported = false) {
650
		if ($this->isLocalOrganiser()) {
651
			return false;
652
		}
653
654
		// Remove any previous calendar items with this goid and appt id
655
		$messageprops = mapi_getprops($this->message, [PR_ENTRYID, PR_PARENT_ENTRYID,
656
			PR_MESSAGE_CLASS, $this->proptags['goid'], $this->proptags['updatecounter'],
657
			PR_PROCESSED, PR_RCVD_REPRESENTING_ENTRYID, PR_SENDER_ENTRYID,
658
			PR_SENT_REPRESENTING_ENTRYID, PR_RECEIVED_BY_ENTRYID]);
659
660
		// do not process meeting requests in sent items folder
661
		$sentItemsEntryid = $this->getDefaultSentmailEntryID();
662
		if (isset($messageprops[PR_PARENT_ENTRYID]) &&
663
			$sentItemsEntryid !== false &&
664
			$sentItemsEntryid == $messageprops[PR_PARENT_ENTRYID]) {
665
			return false;
666
		}
667
668
		$calFolder = $this->openDefaultCalendar();
669
		$store = $this->store;
670
		// If this meeting request is received by a delegate then open delegator's store.
671
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID], $messageprops[PR_RECEIVED_BY_ENTRYID]) &&
672
			!compareEntryIds($messageprops[PR_RCVD_REPRESENTING_ENTRYID], $messageprops[PR_RECEIVED_BY_ENTRYID])) {
673
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
674
			if (!empty($delegatorStore['store'])) {
675
				$store = $delegatorStore['store'];
676
			}
677
			if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
678
				$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
679
			}
680
		}
681
682
		// check for calendar access
683
		if ($this->checkCalendarWriteAccess($store) !== true) {
684
			// Throw an exception that we don't have write permissions on calendar folder,
685
			// allow caller to fill the error message
686
			throw new MAPIException(null, MAPI_E_NO_ACCESS);
687
		}
688
689
		// if meeting is out dated then don't process it
690
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS]) && $this->isMeetingOutOfDate()) {
691
			return false;
692
		}
693
694
		/*
695
		 *	if this function is called automatically with meeting request object then there will be
696
		 *	two possibilitites
697
		 *	1) meeting request is opened first time, in this case make a tentative appointment in
698
		 *		recipient's calendar
699
		 *	2) after this every subsequent request to open meeting request will not do any processing
700
		 */
701
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS]) && $userAction == false) {
702
			if (isset($messageprops[PR_PROCESSED]) && $messageprops[PR_PROCESSED] == true) {
703
				// if meeting request is already processed then don't do anything
704
				return false;
705
			}
706
707
			// if correspondent calendar item is already processed then don't do anything
708
			$calendarItem = $this->getCorrespondentCalendarItem();
709
			if ($calendarItem) {
710
				$calendarItemProps = mapi_getprops($calendarItem, [PR_PROCESSED]);
711
				if (isset($calendarItemProps[PR_PROCESSED]) && $calendarItemProps[PR_PROCESSED] == true) {
712
					// mark meeting-request mail as processed as well
713
					mapi_setprops($this->message, [PR_PROCESSED => true]);
714
					mapi_savechanges($this->message);
715
716
					return false;
717
				}
718
			}
719
		}
720
721
		// Retrieve basedate from globalID, if it is not received as argument
722
		if (!$basedate) {
723
			$basedate = $this->getBasedateFromGlobalID($messageprops[$this->proptags['goid']]);
724
		}
725
726
		// set counter proposal properties in calendar item when proposing new time
727
		$proposeNewTimeProps = [];
728
		if ($newProposedStartTime && $newProposedEndTime) {
729
			$proposeNewTimeProps[$this->proptags['proposed_start_whole']] = $newProposedStartTime;
730
			$proposeNewTimeProps[$this->proptags['proposed_end_whole']] = $newProposedEndTime;
731
			$proposeNewTimeProps[$this->proptags['proposed_duration']] = round($newProposedEndTime - $newProposedStartTime) / 60;
732
			$proposeNewTimeProps[$this->proptags['counter_proposal']] = true;
733
		}
734
735
		// While sender is receiver then we have to process the meeting request as per the intended busy status
736
		// instead of tentative, and accept the same as per the intended busystatus.
737
		$senderEntryId = $messageprops[PR_SENT_REPRESENTING_ENTRYID] ?? $messageprops[PR_SENDER_ENTRYID];
738
		if (isset($messageprops[PR_RECEIVED_BY_ENTRYID]) && compareEntryIds($senderEntryId, $messageprops[PR_RECEIVED_BY_ENTRYID])) {
739
			$entryid = $this->accept(false, $sendresponse, $move, $proposeNewTimeProps, $body, true, $store, $calFolder, $basedate);
740
		}
741
		else {
742
			$entryid = $this->accept($tentative, $sendresponse, $move, $proposeNewTimeProps, $body, $userAction, $store, $calFolder, $basedate);
743
		}
744
745
		// if we have first time processed this meeting then set PR_PROCESSED property
746
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS]) && $userAction === false && $isImported === false) {
747
			if (!isset($messageprops[PR_PROCESSED]) || $messageprops[PR_PROCESSED] != true) {
748
				// set processed flag
749
				mapi_setprops($this->message, [PR_PROCESSED => true]);
750
				mapi_savechanges($this->message);
751
			}
752
		}
753
754
		return $entryid;
755
	}
756
757
	/**
758
	 * @param (float|mixed|true)[] $proposeNewTimeProps
759
	 * @param resource             $calFolder
760
	 * @param mixed                $body
761
	 * @param mixed                $store
762
	 * @param mixed                $basedate
763
	 *
764
	 * @psalm-param array<float|mixed|true> $proposeNewTimeProps
765
	 */
766
	public function accept(bool $tentative, bool $sendresponse, bool $move, array $proposeNewTimeProps, $body, bool $userAction, $store, $calFolder, $basedate = false) {
767
		$messageprops = mapi_getprops($this->message);
768
		$isDelegate = isset($messageprops[PR_RCVD_REPRESENTING_NAME]);
769
		$entryid = '';
770
771
		if ($sendresponse) {
772
			$this->createResponse($tentative ? olResponseTentative : olResponseAccepted, $proposeNewTimeProps, $body, $store, $basedate, $calFolder);
773
		}
774
775
		/*
776
		 * Further processing depends on what user is receiving. User can receive recurring item, a single occurrence or a normal meeting.
777
		 * 1) If meeting req is of recurrence then we find all the occurrence in calendar because in past user might have received one or few occurrences.
778
		 * 2) If single occurrence then find occurrence itself using globalID and if item is not found then use cleanGlobalID to find main recurring item
779
		 * 3) Normal meeting req are handled normally as they were handled previously.
780
		 *
781
		 * Also user can respond(accept/decline) to item either from previewpane or from calendar by opening the item. If user is responding the meeting from previewpane
782
		 * and that item is not found in calendar then item is move else item is opened and all properties, attachments and recipient are copied from meeting request.
783
		 * If user is responding from calendar then item is opened and properties are set such as meetingstatus, responsestatus, busystatus etc.
784
		 */
785
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS])) {
786
			// This meeting request item is recurring, so find all occurrences and saves them all as exceptions to this meeting request item.
787
			if (isset($messageprops[$this->proptags['recurring']]) && $messageprops[$this->proptags['recurring']] == true && $basedate == false) {
788
				$calendarItem = false;
789
790
				// Find main recurring item based on GlobalID (0x3)
791
				$items = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
792
				if (is_array($items)) {
793
					foreach ($items as $entryid) {
794
						$calendarItem = mapi_msgstore_openentry($store, $entryid);
795
					}
796
				}
797
798
				$processed = false;
799
				if (!$calendarItem) {
800
					// Recurring item not found, so create new meeting in Calendar
801
					$calendarItem = mapi_folder_createmessage($calFolder);
802
				}
803
				else {
804
					// we have found the main recurring item, check if this meeting request is already processed
805
					if (isset($messageprops[PR_PROCESSED]) && $messageprops[PR_PROCESSED] == true) {
806
						// only set required properties, other properties are already copied when processing this meeting request
807
						// for the first time
808
						$processed = true;
809
					}
810
					// While we applying updates of MR then all local categories will be removed,
811
					// So get the local categories of all occurrence before applying update from organiser.
812
					$localCategories = $this->getLocalCategories($calendarItem, $store, $calFolder);
813
				}
814
815
				if (!$processed) {
816
					// get all the properties and copy that to calendar item
817
					$props = mapi_getprops($this->message);
818
					// reset the PidLidMeetingType to Unspecified for outlook display the item
819
					$props[$this->proptags['meetingtype']] = mtgEmpty;
820
					/*
821
					 * the client which has sent this meeting request can generate wrong flagdueby
822
					 * time (mainly OL), so regenerate that property so we will always show reminder
823
					 * on right time
824
					 */
825
					if (isset($props[$this->proptags['reminderminutes']])) {
826
						$props[$this->proptags['flagdueby']] = $props[$this->proptags['startdate']] - ($props[$this->proptags['reminderminutes']] * 60);
827
					}
828
				}
829
				else {
830
					// only get required properties so we will not overwrite existing updated properties from calendar
831
					$props = mapi_getprops($this->message, [PR_ENTRYID]);
832
				}
833
834
				$props[PR_MESSAGE_CLASS] = 'IPM.Appointment';
835
				// When meeting requests are generated by third-party solutions, we might be missing the updatecounter property.
836
				if (!isset($props[$this->proptags['updatecounter']])) {
837
					$props[$this->proptags['updatecounter']] = 0;
838
				}
839
				$props[$this->proptags['meetingstatus']] = olMeetingReceived;
840
				// when we are automatically processing the meeting request set responsestatus to olResponseNotResponded
841
				$props[$this->proptags['responsestatus']] = $userAction ? ($tentative ? olResponseTentative : olResponseAccepted) : olResponseNotResponded;
842
843
				if (isset($props[$this->proptags['intendedbusystatus']])) {
844
					if ($tentative && $props[$this->proptags['intendedbusystatus']] !== fbFree) {
845
						$props[$this->proptags['busystatus']] = fbTentative;
846
					}
847
					else {
848
						$props[$this->proptags['busystatus']] = $props[$this->proptags['intendedbusystatus']];
849
					}
850
					// we already have intendedbusystatus value in $props so no need to copy it
851
				}
852
				else {
853
					$props[$this->proptags['busystatus']] = $tentative ? fbTentative : fbBusy;
854
				}
855
856
				if ($userAction) {
857
					$addrInfo = $this->getOwnerAddress($this->store);
858
859
					// if user has responded then set replytime and name
860
					$props[$this->proptags['replytime']] = time();
861
					if (!empty($addrInfo)) {
862
						// @FIXME conditionally set this property only for delegation case
863
						$props[$this->proptags['apptreplyname']] = $addrInfo[0];
864
					}
865
				}
866
867
				mapi_setprops($calendarItem, $props);
868
869
				// we have already processed attachments and recipients, so no need to do it again
870
				if (!$processed) {
871
					// Copy attachments too
872
					$this->replaceAttachments($this->message, $calendarItem);
873
					// Copy recipients too
874
					$this->replaceRecipients($this->message, $calendarItem, $isDelegate);
875
				}
876
877
				// Find all occurrences based on CleanGlobalID (0x23)
878
				// there will be no exceptions left if $processed is true, but even if it doesn't hurt to recheck
879
				$items = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder, true);
880
				if (is_array($items)) {
881
					// Save all existing occurrence as exceptions
882
					foreach ($items as $entryid) {
883
						// Open occurrence
884
						$occurrenceItem = mapi_msgstore_openentry($store, $entryid);
885
886
						// Save occurrence into main recurring item as exception
887
						if ($occurrenceItem) {
888
							$occurrenceItemProps = mapi_getprops($occurrenceItem, [$this->proptags['goid'], $this->proptags['recurring']]);
889
890
							// Find basedate of occurrence item
891
							$basedate = $this->getBasedateFromGlobalID($occurrenceItemProps[$this->proptags['goid']]);
892
							if ($basedate && $occurrenceItemProps[$this->proptags['recurring']] != true) {
893
								$this->mergeException($calendarItem, $occurrenceItem, $basedate, $store);
894
							}
895
						}
896
					}
897
				}
898
899
				if (!isset($props[$this->proptags["recurring_pattern"]])) {
900
					$recurr = new Recurrence($store, $calendarItem);
901
					$recurr->saveRecurrencePattern();
902
				}
903
904
				mapi_savechanges($calendarItem);
905
906
				// After applying update of organiser all local categories of occurrence was removed,
907
				// So if local categories exist then apply it on respective occurrence.
908
				if (!empty($localCategories)) {
909
					$this->applyLocalCategories($calendarItem, $store, $localCategories);
910
				}
911
912
				if ($move) {
913
					// open wastebasket of currently logged in user and move the meeting request to it
914
					// for delegates this will be delegate's wastebasket folder
915
					$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
916
					$sourcefolder = $this->openParentFolder();
917
					mapi_folder_copymessages($sourcefolder, [$props[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
918
				}
919
920
				$entryid = $props[PR_ENTRYID];
921
			}
922
			else {
923
				/**
924
				 * This meeting request is not recurring, so can be an exception or normal meeting.
925
				 * If exception then find main recurring item and update exception
926
				 * If main recurring item is not found then put exception into Calendar as normal meeting.
927
				 */
928
				$calendarItem = false;
929
930
				// We found basedate in GlobalID of this meeting request, so this meeting request if for an occurrence.
931
				if ($basedate) {
932
					// Find main recurring item from CleanGlobalID of this meeting request
933
					$items = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
934
					if (is_array($items)) {
935
						foreach ($items as $entryid) {
936
							$calendarItem = mapi_msgstore_openentry($store, $entryid);
937
						}
938
					}
939
940
					// Main recurring item is found, so now update exception
941
					if ($calendarItem) {
942
						$this->acceptException($calendarItem, $this->message, $basedate, $move, $tentative, $userAction, $store, $isDelegate);
943
						$calendarItemProps = mapi_getprops($calendarItem, [PR_ENTRYID]);
944
						$entryid = $calendarItemProps[PR_ENTRYID];
945
					}
946
				}
947
948
				if (!$calendarItem) {
949
					$items = $this->findCalendarItems($messageprops[$this->proptags['goid']], $calFolder);
950
					if (is_array($items)) {
951
						// Get local categories before deleting MR.
952
						$message = mapi_msgstore_openentry($store, $items[0]);
953
						$localCategories = mapi_getprops($message, [$this->proptags['categories']]);
954
						mapi_folder_deletemessages($calFolder, $items);
955
					}
956
957
					if ($move) {
958
						// All we have to do is open the default calendar,
959
						// set the message class correctly to be an appointment item
960
						// and move it to the calendar folder
961
						$sourcefolder = $this->openParentFolder();
962
963
						// create a new calendar message, and copy the message to there,
964
						// since we want to delete (move to wastebasket) the original message
965
						$old_entryid = mapi_getprops($this->message, [PR_ENTRYID]);
966
						$calmsg = mapi_folder_createmessage($calFolder);
967
						mapi_copyto($this->message, [], [], $calmsg); /* includes attachments and recipients */
968
						// reset the PidLidMeetingType to Unspecified for outlook display the item
969
						$tmp_props = [];
970
						$tmp_props[$this->proptags['meetingtype']] = mtgEmpty;
971
						// OL needs this field always being set, or it will not display item
972
						$tmp_props[$this->proptags['recurring']] = false;
973
						mapi_setprops($calmsg, $tmp_props);
974
975
						// After creating new MR, If local categories exist then apply it on new MR.
976
						if (!empty($localCategories)) {
977
							mapi_setprops($calmsg, $localCategories);
978
						}
979
980
						$calItemProps = [];
981
						$calItemProps[PR_MESSAGE_CLASS] = 'IPM.Appointment';
982
983
						/*
984
						 * the client which has sent this meeting request can generate wrong flagdueby
985
						 * time (mainly OL), so regenerate that property so we will always show reminder
986
						 * on right time
987
						 */
988
						if (isset($messageprops[$this->proptags['reminderminutes']])) {
989
							$calItemProps[$this->proptags['flagdueby']] = $messageprops[$this->proptags['startdate']] - ($messageprops[$this->proptags['reminderminutes']] * 60);
990
						}
991
992
						if (isset($messageprops[$this->proptags['intendedbusystatus']])) {
993
							if ($tentative && $messageprops[$this->proptags['intendedbusystatus']] !== fbFree) {
994
								$calItemProps[$this->proptags['busystatus']] = fbTentative;
995
							}
996
							else {
997
								$calItemProps[$this->proptags['busystatus']] = $messageprops[$this->proptags['intendedbusystatus']];
998
							}
999
							$calItemProps[$this->proptags['intendedbusystatus']] = $messageprops[$this->proptags['intendedbusystatus']];
1000
						}
1001
						else {
1002
							$calItemProps[$this->proptags['busystatus']] = $tentative ? fbTentative : fbBusy;
1003
						}
1004
1005
						// when we are automatically processing the meeting request set responsestatus to olResponseNotResponded
1006
						$calItemProps[$this->proptags['responsestatus']] = $userAction ? ($tentative ? olResponseTentative : olResponseAccepted) : olResponseNotResponded;
1007
						if ($userAction) {
1008
							$addrInfo = $this->getOwnerAddress($this->store);
1009
1010
							// if user has responded then set replytime and name
1011
							$calItemProps[$this->proptags['replytime']] = time();
1012
							if (!empty($addrInfo)) {
1013
								$calItemProps[$this->proptags['apptreplyname']] = $addrInfo[0];
1014
							}
1015
						}
1016
1017
						$calItemProps[$this->proptags['recurring_pattern']] = '';
1018
						$calItemProps[$this->proptags['alldayevent']] = $messageprops[$this->proptags['alldayevent']] ?? false;
1019
						$calItemProps[$this->proptags['private']] = $messageprops[$this->proptags['private']] ?? false;
1020
						$calItemProps[$this->proptags['meetingstatus']] = $messageprops[$this->proptags['meetingstatus']] ?? olMeetingReceived;
1021
						if (isset($messageprops[$this->proptags['startdate']])) {
1022
							$calItemProps[$this->proptags['commonstart']] = $calItemProps[$this->proptags['startdate']] = $messageprops[$this->proptags['startdate']];
1023
						}
1024
						if (isset($messageprops[$this->proptags['duedate']])) {
1025
							$calItemProps[$this->proptags['commonend']] = $calItemProps[$this->proptags['duedate']] = $messageprops[$this->proptags['duedate']];
1026
						}
1027
1028
						mapi_setprops($calmsg, $proposeNewTimeProps + $calItemProps);
1029
1030
						// get properties which stores owner information in meeting request mails
1031
						$props = mapi_getprops($calmsg, [
1032
							PR_SENT_REPRESENTING_ENTRYID,
1033
							PR_SENT_REPRESENTING_NAME,
1034
							PR_SENT_REPRESENTING_EMAIL_ADDRESS,
1035
							PR_SENT_REPRESENTING_ADDRTYPE,
1036
							PR_SENT_REPRESENTING_SEARCH_KEY,
1037
							PR_SENT_REPRESENTING_SMTP_ADDRESS,
1038
						]);
1039
1040
						// add owner to recipient table
1041
						$recips = [];
1042
						$this->addOrganizer($props, $recips);
1043
						mapi_message_modifyrecipients($calmsg, MODRECIP_ADD, $recips);
1044
						mapi_savechanges($calmsg);
1045
1046
						// Move the message to the wastebasket
1047
						$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1048
						mapi_folder_copymessages($sourcefolder, [$old_entryid[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1049
1050
						$messageprops = mapi_getprops($calmsg, [PR_ENTRYID]);
1051
						$entryid = $messageprops[PR_ENTRYID];
1052
					}
1053
					else {
1054
						// Create a new appointment with duplicate properties and recipient, but as an IPM.Appointment
1055
						$new = mapi_folder_createmessage($calFolder);
1056
						$props = mapi_getprops($this->message);
1057
1058
						$props[$this->proptags['recurring_pattern']] = '';
1059
						$props[$this->proptags['alldayevent']] ??= false;
1060
						$props[$this->proptags['private']] ??= false;
1061
						$props[$this->proptags['meetingstatus']] ??= olMeetingReceived;
1062
						if (isset($props[$this->proptags['startdate']])) {
1063
							$props[$this->proptags['commonstart']] = $props[$this->proptags['startdate']];
1064
						}
1065
						if (isset($props[$this->proptags['duedate']])) {
1066
							$props[$this->proptags['commonend']] = $props[$this->proptags['duedate']];
1067
						}
1068
1069
						$props[PR_MESSAGE_CLASS] = 'IPM.Appointment';
1070
						// reset the PidLidMeetingType to Unspecified for outlook display the item
1071
						$props[$this->proptags['meetingtype']] = mtgEmpty;
1072
						// OL needs this field always being set, or it will not display item
1073
						$props[$this->proptags['recurring']] = false;
1074
1075
						// After creating new MR, If local categories exist then apply it on new MR.
1076
						if (!empty($localCategories)) {
1077
							mapi_setprops($new, $localCategories);
1078
						}
1079
1080
						/*
1081
						 * the client which has sent this meeting request can generate wrong flagdueby
1082
						 * time (mainly OL), so regenerate that property so we will always show reminder
1083
						 * on right time
1084
						 */
1085
						if (isset($props[$this->proptags['reminderminutes']])) {
1086
							$props[$this->proptags['flagdueby']] = $props[$this->proptags['startdate']] - ($props[$this->proptags['reminderminutes']] * 60);
1087
						}
1088
1089
						// When meeting requests are generated by third-party solutions, we might be missing the updatecounter property.
1090
						if (!isset($props[$this->proptags['updatecounter']])) {
1091
							$props[$this->proptags['updatecounter']] = 0;
1092
						}
1093
						// when we are automatically processing the meeting request set responsestatus to olResponseNotResponded
1094
						$props[$this->proptags['responsestatus']] = $userAction ? ($tentative ? olResponseTentative : olResponseAccepted) : olResponseNotResponded;
1095
1096
						if (isset($props[$this->proptags['intendedbusystatus']])) {
1097
							if ($tentative && $props[$this->proptags['intendedbusystatus']] !== fbFree) {
1098
								$props[$this->proptags['busystatus']] = fbTentative;
1099
							}
1100
							else {
1101
								$props[$this->proptags['busystatus']] = $props[$this->proptags['intendedbusystatus']];
1102
							}
1103
							// we already have intendedbusystatus value in $props so no need to copy it
1104
						}
1105
						else {
1106
							$props[$this->proptags['busystatus']] = $tentative ? fbTentative : fbBusy;
1107
						}
1108
1109
						if ($userAction) {
1110
							$addrInfo = $this->getOwnerAddress($this->store);
1111
1112
							// if user has responded then set replytime and name
1113
							$props[$this->proptags['replytime']] = time();
1114
							if (!empty($addrInfo)) {
1115
								$props[$this->proptags['apptreplyname']] = $addrInfo[0];
1116
							}
1117
						}
1118
1119
						mapi_setprops($new, $proposeNewTimeProps + $props);
1120
1121
						$reciptable = mapi_message_getrecipienttable($this->message);
1122
1123
						$recips = [];
1124
						// If delegate, then do not add the delegate in recipients
1125
						if ($isDelegate) {
1126
							$delegate = mapi_getprops($this->message, [PR_RECEIVED_BY_EMAIL_ADDRESS]);
1127
							$res = [
1128
								RES_PROPERTY,
1129
								[
1130
									RELOP => RELOP_NE,
1131
									ULPROPTAG => PR_EMAIL_ADDRESS,
1132
									VALUE => [PR_EMAIL_ADDRESS => $delegate[PR_RECEIVED_BY_EMAIL_ADDRESS]],
1133
								],
1134
							];
1135
							$recips = mapi_table_queryallrows($reciptable, $this->recipprops, $res);
1136
						}
1137
						else {
1138
							$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
1139
						}
1140
1141
						$this->addOrganizer($props, $recips);
1142
						mapi_message_modifyrecipients($new, MODRECIP_ADD, $recips);
1143
						mapi_savechanges($new);
1144
1145
						$props = mapi_getprops($new, [PR_ENTRYID]);
1146
						$entryid = $props[PR_ENTRYID];
1147
					}
1148
				}
1149
			}
1150
		}
1151
		else {
1152
			// Here only properties are set on calendaritem, because user is responding from calendar.
1153
			$props = [];
1154
			$props[$this->proptags['responsestatus']] = $tentative ? olResponseTentative : olResponseAccepted;
1155
1156
			if (isset($messageprops[$this->proptags['intendedbusystatus']])) {
1157
				if ($tentative && $messageprops[$this->proptags['intendedbusystatus']] !== fbFree) {
1158
					$props[$this->proptags['busystatus']] = fbTentative;
1159
				}
1160
				else {
1161
					$props[$this->proptags['busystatus']] = $messageprops[$this->proptags['intendedbusystatus']];
1162
				}
1163
				$props[$this->proptags['intendedbusystatus']] = $messageprops[$this->proptags['intendedbusystatus']];
1164
			}
1165
			else {
1166
				$props[$this->proptags['busystatus']] = $tentative ? fbTentative : fbBusy;
1167
			}
1168
1169
			$props[$this->proptags['meetingstatus']] = olMeetingReceived;
1170
1171
			$addrInfo = $this->getOwnerAddress($this->store);
1172
1173
			// if user has responded then set replytime and name
1174
			$props[$this->proptags['replytime']] = time();
1175
			if (!empty($addrInfo)) {
1176
				$props[$this->proptags['apptreplyname']] = $addrInfo[0];
1177
			}
1178
1179
			if ($basedate) {
1180
				$recurr = new Recurrence($store, $this->message);
1181
1182
				// Copy recipients list
1183
				$reciptable = mapi_message_getrecipienttable($this->message);
1184
				$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
1185
1186
				if ($recurr->isException($basedate)) {
1187
					$recurr->modifyException($proposeNewTimeProps + $props, $basedate, $recips);
1188
				}
1189
				else {
1190
					$props[$this->proptags['startdate']] = $recurr->getOccurrenceStart($basedate);
1191
					$props[$this->proptags['duedate']] = $recurr->getOccurrenceEnd($basedate);
1192
1193
					$props[PR_SENT_REPRESENTING_EMAIL_ADDRESS] = $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
1194
					$props[PR_SENT_REPRESENTING_NAME] = $messageprops[PR_SENT_REPRESENTING_NAME];
1195
					$props[PR_SENT_REPRESENTING_ADDRTYPE] = $messageprops[PR_SENT_REPRESENTING_ADDRTYPE];
1196
					$props[PR_SENT_REPRESENTING_ENTRYID] = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
1197
					$props[PR_SENT_REPRESENTING_SEARCH_KEY] = $messageprops[PR_SENT_REPRESENTING_SEARCH_KEY];
1198
1199
					$recurr->createException($proposeNewTimeProps + $props, $basedate, false, $recips);
1200
				}
1201
			}
1202
			else {
1203
				mapi_setprops($this->message, $proposeNewTimeProps + $props);
1204
			}
1205
			mapi_savechanges($this->message);
1206
1207
			$entryid = $messageprops[PR_ENTRYID];
1208
		}
1209
1210
		return $entryid;
1211
	}
1212
1213
	/**
1214
	 * Declines the meeting request by moving the item to the deleted
1215
	 * items folder and sending a decline message. After declining, you
1216
	 * can't use this class instance any more. The message is closed.
1217
	 * When an occurrence is decline then false is returned because that
1218
	 * occurrence is deleted not the recurring item.
1219
	 *
1220
	 * @param bool  $sendresponse true if a response has to be sent to organizer
1221
	 * @param mixed $basedate     if specified contains starttime of day of an occurrence
1222
	 * @param mixed $body
1223
	 *
1224
	 * @return bool true if item is deleted from Calendar else false
1225
	 */
1226
	public function doDecline($sendresponse, $basedate = false, $body = false) {
1227
		if ($this->isLocalOrganiser()) {
1228
			return false;
1229
		}
1230
1231
		$result = false;
1232
		$calendaritem = false;
1233
1234
		// Remove any previous calendar items with this goid and appt id
1235
		$messageprops = mapi_getprops($this->message, [$this->proptags['goid'], $this->proptags['goid2'], PR_RCVD_REPRESENTING_ENTRYID]);
1236
1237
		$store = $this->store;
1238
		$calFolder = $this->openDefaultCalendar();
1239
		// If this meeting request is received by a delegate then open delegator's store.
1240
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID])) {
1241
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
1242
			if (!empty($delegatorStore['store'])) {
1243
				$store = $delegatorStore['store'];
1244
			}
1245
			if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
1246
				$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
1247
			}
1248
		}
1249
1250
		// check for calendar access before deleting the calendar item
1251
		if ($this->checkCalendarWriteAccess($store) !== true) {
1252
			// Throw an exception that we don't have write permissions on calendar folder,
1253
			// allow caller to fill the error message
1254
			throw new MAPIException(null, MAPI_E_NO_ACCESS);
1255
		}
1256
1257
		$goid = $messageprops[$this->proptags['goid']];
1258
1259
		// First, find the items in the calendar by GlobalObjid (0x3)
1260
		$entryids = $this->findCalendarItems($goid, $calFolder);
1261
1262
		if (!$basedate) {
1263
			$basedate = $this->getBasedateFromGlobalID($goid);
1264
		}
1265
1266
		if ($sendresponse) {
1267
			$this->createResponse(olResponseDeclined, [], $body, $store, $basedate, $calFolder);
1268
		}
1269
1270
		if ($basedate) {
1271
			// use CleanGlobalObjid (0x23)
1272
			$calendaritems = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
1273
1274
			if (is_array($calendaritems)) {
1275
				foreach ($calendaritems as $entryid) {
1276
					// Open each calendar item and set the properties of the cancellation object
1277
					$calendaritem = mapi_msgstore_openentry($store, $entryid);
1278
1279
					// Recurring item is found, now delete exception
1280
					if ($calendaritem) {
1281
						$this->doRemoveExceptionFromCalendar($basedate, $calendaritem, $store);
1282
						$result = true;
1283
					}
1284
				}
1285
			}
1286
1287
			if ($this->isMeetingRequest()) {
1288
				$calendaritem = false;
1289
			}
1290
		}
1291
1292
		if (!$calendaritem) {
1293
			$calendar = $this->openDefaultCalendar($store);
1294
1295
			if (!empty($entryids)) {
1296
				mapi_folder_deletemessages($calendar, $entryids);
1297
			}
1298
1299
			// All we have to do to decline, is to move the item to the waste basket
1300
			$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1301
			$sourcefolder = $this->openParentFolder();
1302
1303
			$messageprops = mapi_getprops($this->message, [PR_ENTRYID]);
1304
1305
			// Release the message
1306
			$this->message = null;
1307
1308
			// Move the message to the waste basket
1309
			mapi_folder_copymessages($sourcefolder, [$messageprops[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1310
1311
			$result = true;
1312
		}
1313
1314
		return $result;
1315
	}
1316
1317
	/**
1318
	 * Removes a meeting request from the calendar when the user presses the
1319
	 * 'remove from calendar' button in response to a meeting cancellation.
1320
	 *
1321
	 * @param mixed $basedate if specified contains starttime of day of an occurrence
1322
	 *
1323
	 * @return null|false
1324
	 */
1325
	public function doRemoveFromCalendar($basedate) {
1326
		if ($this->isLocalOrganiser()) {
1327
			return false;
1328
		}
1329
1330
		$messageprops = mapi_getprops($this->message, [PR_ENTRYID, $this->proptags['goid'], PR_RCVD_REPRESENTING_ENTRYID, PR_MESSAGE_CLASS]);
1331
1332
		$goid = $messageprops[$this->proptags['goid']];
1333
1334
		$store = $this->store;
1335
		$calFolder = $this->openDefaultCalendar();
1336
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID])) {
1337
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
1338
			if (!empty($delegatorStore['store'])) {
1339
				$store = $delegatorStore['store'];
1340
			}
1341
			if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
1342
				$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
1343
			}
1344
		}
1345
1346
		// check for calendar access before deleting the calendar item
1347
		if ($this->checkCalendarWriteAccess($store) !== true) {
1348
			// Throw an exception that we don't have write permissions on calendar folder,
1349
			// allow caller to fill the error message
1350
			throw new MAPIException(null, MAPI_E_NO_ACCESS);
1351
		}
1352
1353
		$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1354
		// get the source folder of the meeting message
1355
		$sourcefolder = $this->openParentFolder();
1356
1357
		// Check if the message is a meeting request in the inbox or a calendaritem by checking the message class
1358
		if ($this->isMeetingCancellation($messageprops[PR_MESSAGE_CLASS])) {
1359
			// get the basedate to check for exception
1360
			$basedate = $this->getBasedateFromGlobalID($goid);
1361
1362
			$calendarItem = $this->getCorrespondentCalendarItem(true);
1363
1364
			if ($calendarItem !== false) {
1365
				// basedate is provided so open exception
1366
				if ($basedate) {
1367
					$exception = $this->getExceptionItem($calendarItem, $basedate);
1368
1369
					if ($exception !== false) {
0 ignored issues
show
The condition $exception !== false is always true.
Loading history...
1370
						// exception found, remove it from calendar
1371
						$this->doRemoveExceptionFromCalendar($basedate, $calendarItem, $store);
1372
					}
1373
				}
1374
				else {
1375
					// remove normal / recurring series from calendar
1376
					$entryids = mapi_getprops($calendarItem, [PR_ENTRYID]);
1377
1378
					$entryids = [$entryids[PR_ENTRYID]];
1379
1380
					mapi_folder_copymessages($calFolder, $entryids, $wastebasket, MESSAGE_MOVE);
1381
				}
1382
			}
1383
1384
			// Release the message, because we are going to move it to wastebasket
1385
			$this->message = null;
1386
1387
			// Move the cancellation mail to wastebasket
1388
			mapi_folder_copymessages($sourcefolder, [$messageprops[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1389
		}
1390
		else {
1391
			// Here only properties are set on calendaritem, because user is responding from calendar.
1392
			if ($basedate) {
1393
				// remove the occurrence
1394
				$this->doRemoveExceptionFromCalendar($basedate, $this->message, $store);
1395
			}
1396
			else {
1397
				// remove normal/recurring meeting item.
1398
				// Move the message to the waste basket
1399
				mapi_folder_copymessages($sourcefolder, [$messageprops[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1400
			}
1401
		}
1402
	}
1403
1404
	/**
1405
	 * Function can be used to cancel any existing meeting and send cancellation mails to attendees.
1406
	 * Should only be called from meeting object from calendar.
1407
	 *
1408
	 * @param mixed $basedate (optional) basedate of occurrence which should be cancelled
1409
	 *
1410
	 * @FIXME cancellation mail is also sent to attendee which has declined the meeting
1411
	 * @FIXME don't send canellation mail when cancelling meeting from past
1412
	 */
1413
	public function doCancelInvitation($basedate = false) {
1414
		if (!$this->isLocalOrganiser()) {
1415
			return;
1416
		}
1417
1418
		// check write access for delegate
1419
		if ($this->checkCalendarWriteAccess($this->store) !== true) {
1420
			// Throw an exception that we don't have write permissions on calendar folder,
1421
			// error message will be filled by module
1422
			throw new MAPIException(null, MAPI_E_NO_ACCESS);
1423
		}
1424
1425
		$messageProps = mapi_getprops($this->message, [PR_ENTRYID, $this->proptags['recurring']]);
1426
1427
		if (isset($messageProps[$this->proptags['recurring']]) && $messageProps[$this->proptags['recurring']] === true) {
1428
			// cancellation of recurring series or one occurrence
1429
			$recurrence = new Recurrence($this->store, $this->message);
1430
1431
			// if basedate is specified then we are cancelling only one occurrence, so create exception for that occurrence
1432
			if ($basedate) {
1433
				$recurrence->createException([], $basedate, true);
1434
			}
1435
1436
			// update the meeting request
1437
			$this->updateMeetingRequest();
1438
1439
			// send cancellation mails
1440
			$this->sendMeetingRequest(true, dgettext('zarafa', 'Canceled') . ': ', $basedate);
1441
1442
			// save changes in the message
1443
			mapi_savechanges($this->message);
1444
		}
1445
		else {
1446
			// cancellation of normal meeting request
1447
			// Send the cancellation
1448
			$this->updateMeetingRequest();
1449
			$this->sendMeetingRequest(true, dgettext('zarafa', 'Canceled') . ': ');
1450
1451
			// save changes in the message
1452
			mapi_savechanges($this->message);
1453
		}
1454
1455
		// if basedate is specified then we have already created exception of it so nothing should be done now
1456
		// but when cancelling normal / recurring meeting request we need to remove meeting from calendar
1457
		if ($basedate === false) {
1458
			// get the wastebasket folder, for delegate this will give wastebasket of delegate
1459
			$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1460
1461
			// get the source folder of the meeting message
1462
			$sourcefolder = $this->openParentFolder();
1463
1464
			// Move the message to the deleted items
1465
			mapi_folder_copymessages($sourcefolder, [$messageProps[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1466
		}
1467
	}
1468
1469
	/**
1470
	 * Convert epoch to MAPI FileTime, number of 100-nanosecond units since
1471
	 * the start of January 1, 1601.
1472
	 * https://msdn.microsoft.com/en-us/library/office/cc765906.aspx.
1473
	 *
1474
	 * @param int $epoch the current epoch
1475
	 *
1476
	 * @return int the MAPI FileTime equalevent to the given epoch time
1477
	 */
1478
	public function epochToMapiFileTime($epoch) {
1479
		$nanoseconds_between_epoch = 116444736000000000;
1480
1481
		return ($epoch * 10000000) + $nanoseconds_between_epoch;
1482
	}
1483
1484
	/**
1485
	 * Sets the properties in the message so that is can be sent
1486
	 * as a meeting request. The caller has to submit the message. This
1487
	 * is only used for new MeetingRequests. Pass the appointment item as $message
1488
	 * in the constructor to do this.
1489
	 *
1490
	 * @param mixed $basedate
1491
	 */
1492
	public function setMeetingRequest($basedate = false): void {
1493
		$props = mapi_getprops($this->message, [$this->proptags['updatecounter']]);
1494
1495
		// Create a new global id for this item
1496
		// https://msdn.microsoft.com/en-us/library/ee160198(v=exchg.80).aspx
1497
		$goid = pack('H*', '040000008200E00074C5B7101A82E00800000000');
1498
		/*
1499
		$year = gmdate('Y');
1500
		$month = gmdate('n');
1501
		$day = gmdate('j');
1502
		$goid .= pack('n', $year);
1503
		$goid .= pack('C', $month);
1504
		$goid .= pack('C', $day);
1505
		*/
1506
		// Creation Time
1507
		$time = $this->epochToMapiFileTime(time());
1508
		$goid .= pack('V', $time & 0xFFFFFFFF);
1509
		$goid .= pack('V', $time >> 32);
1510
		// 8 Zeros
1511
		$goid .= pack('H*', '0000000000000000');
1512
		// Length of the random data
1513
		$goid .= pack('V', 16);
1514
		// Random data.
1515
		for ($i = 0; $i < 16; ++$i) {
1516
			$goid .= chr(random_int(0, 255));
1517
		}
1518
1519
		// Create a new appointment id for this item
1520
		$apptid = random_int(0, mt_getrandmax());
1521
1522
		$props[PR_OWNER_APPT_ID] = $apptid;
1523
		$props[PR_ICON_INDEX] = 1026;
1524
		$props[$this->proptags['goid']] = $goid;
1525
		$props[$this->proptags['goid2']] = $goid;
1526
1527
		if (!isset($props[$this->proptags['updatecounter']])) {
1528
			$props[$this->proptags['updatecounter']] = 0;			// OL also starts sequence no with zero.
1529
			$props[$this->proptags['last_updatecounter']] = 0;
1530
		}
1531
1532
		mapi_setprops($this->message, $props);
1533
	}
1534
1535
	/**
1536
	 * Sends a meeting request by copying it to the outbox, converting
1537
	 * the message class, adding some properties that are required only
1538
	 * for sending the message and submitting the message. Set cancel to
1539
	 * true if you wish to completely cancel the meeting request. You can
1540
	 * specify an optional 'prefix' to prefix the sent message, which is normally
1541
	 * 'Canceled: '.
1542
	 *
1543
	 * @param mixed $cancel
1544
	 * @param mixed $prefix
1545
	 * @param mixed $basedate
1546
	 * @param mixed $modifiedRecips
1547
	 * @param mixed $deletedRecips
1548
	 *
1549
	 * @return (int|mixed)[]|true
1550
	 *
1551
	 * @psalm-return array{error: 1|3|4, displayname: mixed}|true
1552
	 */
1553
	public function sendMeetingRequest($cancel, $prefix = false, $basedate = false, $modifiedRecips = false, $deletedRecips = false) {
1554
		$this->includesResources = false;
1555
		$this->nonAcceptingResources = [];
1556
1557
		// Get the properties of the message
1558
		$messageprops = mapi_getprops($this->message, [$this->proptags['recurring']]);
1559
1560
		/*
1561
		 * Submit message to non-resource recipients
1562
		 */
1563
		// Set BusyStatus to olTentative (1)
1564
		// Set MeetingStatus to olMeetingReceived
1565
		// Set ResponseStatus to olResponseNotResponded
1566
1567
		/*
1568
		 * While sending recurrence meeting exceptions are not sent as attachments
1569
		 * because first all exceptions are sent and then recurrence meeting is sent.
1570
		 */
1571
		if (isset($messageprops[$this->proptags['recurring']]) && $messageprops[$this->proptags['recurring']] && !$basedate) {
1572
			// Book resource
1573
			$this->bookResources($this->message, $cancel, $prefix);
1574
1575
			if (!$this->errorSetResource) {
1576
				$recurr = new Recurrence($this->openDefaultStore(), $this->message);
1577
1578
				// First send meetingrequest for recurring item
1579
				$this->submitMeetingRequest($this->message, $cancel, $prefix, false, $recurr, false, $modifiedRecips, $deletedRecips);
1580
1581
				// Then send all meeting request for all exceptions
1582
				$exceptions = $recurr->getAllExceptions();
1583
				if ($exceptions) {
1584
					foreach ($exceptions as $exceptionBasedate) {
1585
						$attach = $recurr->getExceptionAttachment($exceptionBasedate);
1586
1587
						if ($attach) {
1588
							$occurrenceItem = mapi_attach_openobj($attach, MAPI_MODIFY);
1589
							$this->submitMeetingRequest($occurrenceItem, $cancel, false, $exceptionBasedate, $recurr, false, $modifiedRecips, $deletedRecips);
1590
							mapi_savechanges($attach);
1591
						}
1592
					}
1593
				}
1594
			}
1595
		}
1596
		else {
1597
			// Basedate found, an exception is to be sent
1598
			if ($basedate) {
1599
				$recurr = new Recurrence($this->openDefaultStore(), $this->message);
1600
1601
				if ($cancel) {
1602
					// @TODO: remove occurrence from Resource's Calendar if resource was booked for whole series
1603
					$this->submitMeetingRequest($this->message, $cancel, $prefix, $basedate, $recurr, false);
1604
				}
1605
				else {
1606
					$attach = $recurr->getExceptionAttachment($basedate);
1607
1608
					if ($attach) {
1609
						$occurrenceItem = mapi_attach_openobj($attach, MAPI_MODIFY);
1610
1611
						// Book resource for this occurrence
1612
						$resourceRecipData = $this->bookResources($occurrenceItem, $cancel, $prefix, $basedate);
1613
1614
						if (!$this->errorSetResource) {
1615
							// Save all previous changes
1616
							mapi_savechanges($this->message);
1617
1618
							$this->submitMeetingRequest($occurrenceItem, $cancel, $prefix, $basedate, $recurr, true, $modifiedRecips, $deletedRecips);
1619
							mapi_savechanges($occurrenceItem);
1620
							mapi_savechanges($attach);
1621
						}
1622
					}
1623
				}
1624
			}
1625
			else {
1626
				// This is normal meeting
1627
				$resourceRecipData = $this->bookResources($this->message, $cancel, $prefix);
1628
1629
				if (!$this->errorSetResource) {
1630
					$this->submitMeetingRequest($this->message, $cancel, $prefix, false, false, false, $modifiedRecips, $deletedRecips);
1631
				}
1632
			}
1633
		}
1634
1635
		if (isset($this->errorSetResource) && $this->errorSetResource) {
1636
			return [
1637
				'error' => $this->errorSetResource,
1638
				'displayname' => $this->recipientDisplayname,
1639
			];
1640
		}
1641
1642
		return true;
1643
	}
1644
1645
	/**
1646
	 * Updates the message after an update has been performed (for example,
1647
	 * changing the time of the meeting). This must be called before re-sending
1648
	 * the meeting request. You can also call this function instead of 'setMeetingRequest()'
1649
	 * as it will automatically call setMeetingRequest on this object if it is the first
1650
	 * call to this function.
1651
	 *
1652
	 * @param mixed $basedate
1653
	 */
1654
	public function updateMeetingRequest($basedate = false): void {
1655
		$messageprops = mapi_getprops($this->message, [$this->proptags['last_updatecounter'], $this->proptags['goid']]);
1656
1657
		if (!isset($messageprops[$this->proptags['goid']])) {
1658
			$this->setMeetingRequest($basedate);
1659
		}
1660
		else {
1661
			$counter = (isset($messageprops[$this->proptags['last_updatecounter']]) ?? 0) + 1;
1662
1663
			// increment value of last_updatecounter, last_updatecounter will be common for recurring series
1664
			// so even if you sending an exception only you need to update the last_updatecounter in the recurring series message
1665
			// this way we can make sure that every time we will be using a uniwue number for every operation
1666
			mapi_setprops($this->message, [$this->proptags['last_updatecounter'] => $counter]);
1667
		}
1668
	}
1669
1670
	/**
1671
	 * Returns TRUE if we are the organiser of the meeting. Can be used with any type of meeting object.
1672
	 */
1673
	public function isLocalOrganiser(): bool {
1674
		$props = mapi_getprops($this->message, [$this->proptags['goid'], PR_MESSAGE_CLASS]);
1675
1676
		if (!$this->isMeetingRequest($props[PR_MESSAGE_CLASS]) && !$this->isMeetingRequestResponse($props[PR_MESSAGE_CLASS]) && !$this->isMeetingCancellation($props[PR_MESSAGE_CLASS])) {
1677
			// we are checking with calendar item
1678
			$calendarItem = $this->message;
1679
		}
1680
		else {
1681
			// we are checking with meeting request / response / cancellation mail
1682
			// get calendar items
1683
			$calendarItem = $this->getCorrespondentCalendarItem(true);
1684
		}
1685
1686
		// even if we have received request/response for exception/occurrence then also
1687
		// we can check recurring series for organizer, no need to check with exception/occurrence
1688
1689
		if ($calendarItem !== false) {
1690
			$messageProps = mapi_getprops($calendarItem, [$this->proptags['responsestatus']]);
1691
1692
			if (isset($messageProps[$this->proptags['responsestatus']]) && $messageProps[$this->proptags['responsestatus']] === olResponseOrganized) {
1693
				return true;
1694
			}
1695
		}
1696
1697
		return false;
1698
	}
1699
1700
	/*
1701
	 * Support functions - INTERNAL ONLY
1702
	 ***************************************************************************************************
1703
	 */
1704
1705
	/**
1706
	 * Return the tracking status of a recipient based on the IPM class (passed).
1707
	 *
1708
	 * @param mixed $class
1709
	 */
1710
	public function getTrackStatus($class) {
1711
		$status = olRecipientTrackStatusNone;
1712
1713
		return match ($class) {
1714
			'IPM.Schedule.Meeting.Resp.Pos' => olRecipientTrackStatusAccepted,
1715
			'IPM.Schedule.Meeting.Resp.Tent' => olRecipientTrackStatusTentative,
1716
			'IPM.Schedule.Meeting.Resp.Neg' => olRecipientTrackStatusDeclined,
1717
			default => $status,
1718
		};
1719
	}
1720
1721
	/**
1722
	 * Function returns MAPIFolder resource of the folder that currently holds this meeting/meeting request
1723
	 * object.
1724
	 */
1725
	public function openParentFolder() {
1726
		$messageprops = mapi_getprops($this->message, [PR_PARENT_ENTRYID]);
1727
1728
		return mapi_msgstore_openentry($this->store, $messageprops[PR_PARENT_ENTRYID]);
1729
	}
1730
1731
	/**
1732
	 * Function will return resource of the default calendar folder of store.
1733
	 *
1734
	 * @param mixed $store {optional} user store whose default calendar should be opened
1735
	 *
1736
	 * @return resource default calendar folder of store
1737
	 */
1738
	public function openDefaultCalendar($store = false) {
1739
		return $this->openDefaultFolder(PR_IPM_APPOINTMENT_ENTRYID, $store);
1740
	}
1741
1742
	/**
1743
	 * Function will return resource of the default outbox folder of store.
1744
	 *
1745
	 * @param mixed $store {optional} user store whose default outbox should be opened
1746
	 *
1747
	 * @return resource default outbox folder of store
1748
	 */
1749
	public function openDefaultOutbox($store = false) {
1750
		return $this->openBaseFolder(PR_IPM_OUTBOX_ENTRYID, $store);
1751
	}
1752
1753
	/**
1754
	 * Function will return resource of the default wastebasket folder of store.
1755
	 *
1756
	 * @param mixed $store {optional} user store whose default wastebasket should be opened
1757
	 *
1758
	 * @return resource default wastebasket folder of store
1759
	 */
1760
	public function openDefaultWastebasket($store = false) {
1761
		return $this->openBaseFolder(PR_IPM_WASTEBASKET_ENTRYID, $store);
1762
	}
1763
1764
	/**
1765
	 * Function will return resource of the default calendar folder of store.
1766
	 *
1767
	 * @param mixed $store {optional} user store whose default calendar should be opened
1768
	 *
1769
	 * @return bool|string default calendar folder of store
1770
	 */
1771
	public function getDefaultWastebasketEntryID($store = false) {
1772
		return $this->getBaseEntryID(PR_IPM_WASTEBASKET_ENTRYID, $store);
1773
	}
1774
1775
	/**
1776
	 * Function will return resource of the default sent mail folder of store.
1777
	 *
1778
	 * @param mixed $store {optional} user store whose default sent mail should be opened
1779
	 *
1780
	 * @return bool|string default sent mail folder of store
1781
	 */
1782
	public function getDefaultSentmailEntryID($store = false) {
1783
		return $this->getBaseEntryID(PR_IPM_SENTMAIL_ENTRYID, $store);
1784
	}
1785
1786
	/**
1787
	 * Function will return entryid of any default folder of store. This method is useful when you want
1788
	 * to get entryid of folder which is stored as properties of inbox folder
1789
	 * (PR_IPM_APPOINTMENT_ENTRYID, PR_IPM_CONTACT_ENTRYID, PR_IPM_DRAFTS_ENTRYID, PR_IPM_JOURNAL_ENTRYID, PR_IPM_NOTE_ENTRYID, PR_IPM_TASK_ENTRYID).
1790
	 *
1791
	 * @param int   $prop  proptag of the folder for which we want to get entryid
1792
	 * @param mixed $store {optional} user store from which we need to get entryid of default folder
1793
	 *
1794
	 * @return bool|string entryid of folder pointed by $prop
1795
	 */
1796
	public function getDefaultFolderEntryID($prop, $store = false) {
1797
		try {
1798
			$inbox = mapi_msgstore_getreceivefolder($store ?: $this->store);
1799
			$inboxprops = mapi_getprops($inbox, [$prop]);
1800
			if (isset($inboxprops[$prop])) {
1801
				return $inboxprops[$prop];
1802
			}
1803
		}
1804
		catch (MAPIException $e) {
1805
			// public store doesn't support this method
1806
			if ($e->getCode() == MAPI_E_NO_SUPPORT) {
1807
				// don't propagate this error to parent handlers, if store doesn't support it
1808
				$e->setHandled();
1809
			}
1810
		}
1811
1812
		return false;
1813
	}
1814
1815
	/**
1816
	 * Function will return resource of any default folder of store.
1817
	 *
1818
	 * @param int   $prop  proptag of the folder that we want to open
1819
	 * @param mixed $store {optional} user store from which we need to open default folder
1820
	 *
1821
	 * @return resource default folder of store
1822
	 */
1823
	public function openDefaultFolder($prop, $store = false) {
1824
		$folder = false;
1825
		$entryid = $this->getDefaultFolderEntryID($prop, $store);
1826
1827
		if ($entryid !== false) {
1828
			$folder = mapi_msgstore_openentry($store ?: $this->store, $entryid);
1829
		}
1830
1831
		return $folder;
1832
	}
1833
1834
	/**
1835
	 * Function will return entryid of default folder from store. This method is useful when you want
1836
	 * to get entryid of folder which is stored as store properties
1837
	 * (PR_IPM_FAVORITES_ENTRYID, PR_IPM_OUTBOX_ENTRYID, PR_IPM_SENTMAIL_ENTRYID, PR_IPM_WASTEBASKET_ENTRYID).
1838
	 *
1839
	 * @param int   $prop  proptag of the folder whose entryid we want to get
1840
	 * @param mixed $store {optional} user store from which we need to get entryid of default folder
1841
	 *
1842
	 * @return bool|string entryid of default folder from store
1843
	 */
1844
	public function getBaseEntryID($prop, $store = false) {
1845
		$storeprops = mapi_getprops($store ?: $this->store, [$prop]);
1846
		if (!isset($storeprops[$prop])) {
1847
			return false;
1848
		}
1849
1850
		return $storeprops[$prop];
1851
	}
1852
1853
	/**
1854
	 * Function will return resource of any default folder of store.
1855
	 *
1856
	 * @param int   $prop  proptag of the folder that we want to open
1857
	 * @param mixed $store {optional} user store from which we need to open default folder
1858
	 *
1859
	 * @return resource default folder of store
1860
	 */
1861
	public function openBaseFolder($prop, $store = false) {
1862
		$folder = false;
1863
		$entryid = $this->getBaseEntryID($prop, $store);
1864
1865
		if ($entryid !== false) {
1866
			$folder = mapi_msgstore_openentry($store ?: $this->store, $entryid);
1867
		}
1868
1869
		return $folder;
1870
	}
1871
1872
	/**
1873
	 * Function checks whether user has access over the specified folder or not.
1874
	 *
1875
	 * @param string $entryid entryid The entryid of the folder to check
1876
	 * @param mixed  $store   (optional) store from which folder should be opened
1877
	 *
1878
	 * @return bool true if user has an access over the folder, false if not
1879
	 */
1880
	public function checkFolderWriteAccess($entryid, $store = false) {
1881
		$accessToFolder = false;
1882
1883
		if (!empty($entryid)) {
1884
			if ($store === false) {
1885
				$store = $this->store;
1886
			}
1887
1888
			try {
1889
				$folder = mapi_msgstore_openentry($store, $entryid);
1890
				$folderProps = mapi_getprops($folder, [PR_ACCESS]);
1891
				if (($folderProps[PR_ACCESS] & MAPI_ACCESS_CREATE_CONTENTS) === MAPI_ACCESS_CREATE_CONTENTS) {
1892
					$accessToFolder = true;
1893
				}
1894
			}
1895
			catch (MAPIException $e) {
1896
				// we don't have rights to open folder, so return false
1897
				if ($e->getCode() == MAPI_E_NO_ACCESS) {
1898
					return $accessToFolder;
1899
				}
1900
1901
				// rethrow other errors
1902
				throw $e;
1903
			}
1904
		}
1905
1906
		return $accessToFolder;
1907
	}
1908
1909
	/**
1910
	 * Function checks whether user has access over the specified folder or not.
1911
	 *
1912
	 * @param mixed $store
1913
	 *
1914
	 * @return bool true if user has an access over the folder, false if not
1915
	 */
1916
	public function checkCalendarWriteAccess($store = false) {
1917
		if ($store === false) {
1918
			$messageProps = mapi_getprops($this->message, [PR_RCVD_REPRESENTING_ENTRYID]);
1919
			$store = $this->store;
1920
			// If this meeting request is received by a delegate then open delegator's store.
1921
			if (isset($messageProps[PR_RCVD_REPRESENTING_ENTRYID])) {
1922
				$delegatorStore = $this->getDelegatorStore($messageProps[PR_RCVD_REPRESENTING_ENTRYID]);
1923
				if (!empty($delegatorStore['store'])) {
1924
					$store = $delegatorStore['store'];
1925
				}
1926
			}
1927
		}
1928
1929
		// If the store is a public folder, the calendar folder is the PARENT_ENTRYID of the calendar item
1930
		$provider = mapi_getprops($store, [PR_MDB_PROVIDER]);
1931
		if (isset($provider[PR_MDB_PROVIDER]) && $provider[PR_MDB_PROVIDER] === ZARAFA_STORE_PUBLIC_GUID) {
1932
			$entryid = mapi_getprops($this->message, [PR_PARENT_ENTRYID]);
1933
			$entryid = $entryid[PR_PARENT_ENTRYID];
1934
		}
1935
		else {
1936
			$entryid = $this->getDefaultFolderEntryID(PR_IPM_APPOINTMENT_ENTRYID, $store);
1937
			if ($entryid === false) {
1938
				$entryid = $this->getBaseEntryID(PR_IPM_APPOINTMENT_ENTRYID, $store);
1939
			}
1940
1941
			if ($entryid === false) {
1942
				return false;
1943
			}
1944
		}
1945
1946
		return $this->checkFolderWriteAccess($entryid, $store);
1947
	}
1948
1949
	/**
1950
	 * Function will resolve the user and open its store.
1951
	 *
1952
	 * @param string $ownerentryid the entryid of the user
1953
	 *
1954
	 * @return resource store of the user
1955
	 */
1956
	public function openCustomUserStore($ownerentryid) {
1957
		$ab = mapi_openaddressbook($this->session);
1958
1959
		try {
1960
			$mailuser = mapi_ab_openentry($ab, $ownerentryid);
1961
			if (!$mailuser) {
0 ignored issues
show
$mailuser is of type resource, thus it always evaluated to true.
Loading history...
1962
				error_log(sprintf("Unable to open ab entry: 0x%08X", mapi_last_hresult()));
1963
1964
				return;
1965
			}
1966
		}
1967
		catch (MAPIException) {
1968
			return;
1969
		}
1970
1971
		$mailuserprops = mapi_getprops($mailuser, [PR_EMAIL_ADDRESS]);
1972
		$storeid = mapi_msgstore_createentryid($this->store, $mailuserprops[PR_EMAIL_ADDRESS]);
1973
1974
		return mapi_openmsgstore($this->session, $storeid);
1975
	}
1976
1977
	/**
1978
	 * Function which sends response to organizer when attendee accepts, declines or proposes new time to a received meeting request.
1979
	 *
1980
	 * @param int   $status              response status of attendee
1981
	 * @param array $proposeNewTimeProps properties of attendee's proposal
1982
	 * @param mixed $body
1983
	 * @param mixed $store
1984
	 * @param mixed $basedate            date of occurrence which attendee has responded
1985
	 * @param mixed $calFolder
1986
	 */
1987
	public function createResponse($status, $proposeNewTimeProps, $body, $store, $basedate, $calFolder): void {
1988
		$messageprops = mapi_getprops($this->message, [
1989
			PR_SENT_REPRESENTING_ENTRYID,
1990
			PR_SENT_REPRESENTING_EMAIL_ADDRESS,
1991
			PR_SENT_REPRESENTING_ADDRTYPE,
1992
			PR_SENT_REPRESENTING_NAME,
1993
			PR_SENT_REPRESENTING_SEARCH_KEY,
1994
			$this->proptags['goid'],
1995
			$this->proptags['goid2'],
1996
			$this->proptags['location'],
1997
			$this->proptags['startdate'],
1998
			$this->proptags['duedate'],
1999
			$this->proptags['recurring'],
2000
			$this->proptags['recurring_pattern'],
2001
			$this->proptags['recurrence_data'],
2002
			$this->proptags['timezone_data'],
2003
			$this->proptags['timezone'],
2004
			$this->proptags['updatecounter'],
2005
			PR_SUBJECT,
2006
			PR_MESSAGE_CLASS,
2007
			PR_OWNER_APPT_ID,
2008
			$this->proptags['is_exception'],
2009
		]);
2010
2011
		$props = [];
2012
2013
		if ($basedate !== false && !$this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS])) {
2014
			// we are creating response from a recurring calendar item object
2015
			// We found basedate,so opened occurrence and get properties.
2016
			$recurr = new Recurrence($store, $this->message);
2017
			$exception = $recurr->getExceptionAttachment($basedate);
2018
2019
			if ($exception) {
2020
				// Exception found, Now retrieve properties
2021
				$imessage = mapi_attach_openobj($exception, 0);
2022
				$imsgprops = mapi_getprops($imessage);
2023
2024
				// If location is provided, copy it to the response
2025
				if (isset($imsgprops[$this->proptags['location']])) {
2026
					$messageprops[$this->proptags['location']] = $imsgprops[$this->proptags['location']];
2027
				}
2028
2029
				// Update $messageprops with timings of occurrence
2030
				$messageprops[$this->proptags['startdate']] = $imsgprops[$this->proptags['startdate']];
2031
				$messageprops[$this->proptags['duedate']] = $imsgprops[$this->proptags['duedate']];
2032
2033
				// Meeting related properties
2034
				$props[$this->proptags['meetingstatus']] = $imsgprops[$this->proptags['meetingstatus']];
2035
				$props[$this->proptags['responsestatus']] = $imsgprops[$this->proptags['responsestatus']];
2036
				$props[PR_SUBJECT] = $imsgprops[PR_SUBJECT];
2037
			}
2038
			else {
2039
				// Exceptions is deleted.
2040
				// Update $messageprops with timings of occurrence
2041
				$messageprops[$this->proptags['startdate']] = $recurr->getOccurrenceStart($basedate);
2042
				$messageprops[$this->proptags['duedate']] = $recurr->getOccurrenceEnd($basedate);
2043
2044
				$props[$this->proptags['meetingstatus']] = olNonMeeting;
2045
				$props[$this->proptags['responsestatus']] = olResponseNone;
2046
			}
2047
2048
			$props[$this->proptags['recurring']] = false;
2049
			$props[$this->proptags['is_exception']] = true;
2050
		}
2051
		else {
2052
			// we are creating a response from meeting request mail (it could be recurring or non-recurring)
2053
			// Send all recurrence info in response, if this is a recurrence meeting.
2054
			$isRecurring = isset($messageprops[$this->proptags['recurring']]) && $messageprops[$this->proptags['recurring']];
2055
			$isException = isset($messageprops[$this->proptags['is_exception']]) && $messageprops[$this->proptags['is_exception']];
2056
			if ($isRecurring || $isException) {
2057
				if ($isRecurring) {
2058
					$props[$this->proptags['recurring']] = $messageprops[$this->proptags['recurring']];
2059
				}
2060
				if ($isException) {
2061
					$props[$this->proptags['is_exception']] = $messageprops[$this->proptags['is_exception']];
2062
				}
2063
				$calendaritems = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
2064
2065
				$calendaritem = mapi_msgstore_openentry($store, $calendaritems[0]);
2066
				$recurr = new Recurrence($store, $calendaritem);
2067
			}
2068
		}
2069
2070
		// we are sending a response for recurring meeting request (or exception), so set some required properties
2071
		if (isset($recurr) && $recurr) {
2072
			if (!empty($messageprops[$this->proptags['recurring_pattern']])) {
2073
				$props[$this->proptags['recurring_pattern']] = $messageprops[$this->proptags['recurring_pattern']];
2074
			}
2075
2076
			if (!empty($messageprops[$this->proptags['recurrence_data']])) {
2077
				$props[$this->proptags['recurrence_data']] = $messageprops[$this->proptags['recurrence_data']];
2078
			}
2079
2080
			$props[$this->proptags['timezone_data']] = $messageprops[$this->proptags['timezone_data']];
2081
			$props[$this->proptags['timezone']] = $messageprops[$this->proptags['timezone']];
2082
2083
			$this->generateRecurDates($recurr, $messageprops, $props);
2084
		}
2085
2086
		// Create a response message
2087
		$recip = [];
2088
		$recip[PR_ENTRYID] = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
2089
		$recip[PR_EMAIL_ADDRESS] = $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
2090
		$recip[PR_ADDRTYPE] = $messageprops[PR_SENT_REPRESENTING_ADDRTYPE];
2091
		$recip[PR_DISPLAY_NAME] = $messageprops[PR_SENT_REPRESENTING_NAME];
2092
		$recip[PR_RECIPIENT_TYPE] = MAPI_TO;
2093
		$recip[PR_SEARCH_KEY] = $messageprops[PR_SENT_REPRESENTING_SEARCH_KEY];
2094
2095
		$subjectprefix = '';
2096
		$classpostfix = '';
2097
2098
		switch ($status) {
2099
			case olResponseAccepted:
2100
				$classpostfix = 'Pos';
2101
				$subjectprefix = dgettext('zarafa', 'Accepted');
2102
				break;
2103
2104
			case olResponseDeclined:
2105
				$classpostfix = 'Neg';
2106
				$subjectprefix = dgettext('zarafa', 'Declined');
2107
				break;
2108
2109
			case olResponseTentative:
2110
				$classpostfix = 'Tent';
2111
				$subjectprefix = dgettext('zarafa', 'Tentatively accepted');
2112
				break;
2113
		}
2114
2115
		if (!empty($proposeNewTimeProps)) {
2116
			// if attendee has proposed new time then change subject prefix
2117
			$subjectprefix = dgettext('zarafa', 'New Time Proposed');
2118
		}
2119
2120
		$props[PR_SUBJECT] = $subjectprefix . ': ' . $messageprops[PR_SUBJECT];
2121
2122
		$props[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Resp.' . $classpostfix;
2123
		if (isset($messageprops[PR_OWNER_APPT_ID])) {
2124
			$props[PR_OWNER_APPT_ID] = $messageprops[PR_OWNER_APPT_ID];
2125
		}
2126
2127
		// Set GlobalId AND CleanGlobalId, if exception then also set basedate into GlobalId(0x3).
2128
		$props[$this->proptags['goid']] = $this->setBasedateInGlobalID($messageprops[$this->proptags['goid2']], $basedate);
2129
		$props[$this->proptags['goid2']] = $messageprops[$this->proptags['goid2']];
2130
		$props[$this->proptags['updatecounter']] = $messageprops[$this->proptags['updatecounter']] ?? 0;
2131
2132
		if (!empty($proposeNewTimeProps)) {
2133
			// merge proposal properties to message properties which will be sent to organizer
2134
			$props = $proposeNewTimeProps + $props;
2135
		}
2136
2137
		// Set body message in Appointment
2138
		if (isset($body)) {
2139
			$props[PR_BODY] = $this->getMeetingTimeInfo() ?: $body;
2140
		}
2141
2142
		// PR_START_DATE/PR_END_DATE is used in the UI in Outlook on the response message
2143
		$props[PR_START_DATE] = $messageprops[$this->proptags['startdate']];
2144
		$props[PR_END_DATE] = $messageprops[$this->proptags['duedate']];
2145
2146
		// Set startdate and duedate in response mail.
2147
		$props[$this->proptags['startdate']] = $messageprops[$this->proptags['startdate']];
2148
		$props[$this->proptags['duedate']] = $messageprops[$this->proptags['duedate']];
2149
2150
		// responselocation is used in the UI in Outlook on the response message
2151
		if (isset($messageprops[$this->proptags['location']])) {
2152
			$props[$this->proptags['responselocation']] = $messageprops[$this->proptags['location']];
2153
			$props[$this->proptags['location']] = $messageprops[$this->proptags['location']];
2154
		}
2155
2156
		$message = $this->createOutgoingMessage($store);
2157
2158
		mapi_setprops($message, $props);
2159
		mapi_message_modifyrecipients($message, MODRECIP_ADD, [$recip]);
2160
		mapi_savechanges($message);
2161
		mapi_message_submitmessage($message);
2162
	}
2163
2164
	/**
2165
	 * Function which finds items in calendar based on globalId and cleanGlobalId.
2166
	 *
2167
	 * @param string $goid             GlobalID(0x3) of item
2168
	 * @param mixed  $calendar         MAPI_folder of user (optional)
2169
	 * @param bool   $useCleanGlobalId if true then search should be performed on cleanGlobalId(0x23) else globalId(0x3)
2170
	 *
2171
	 * @return mixed
2172
	 */
2173
	public function findCalendarItems($goid, $calendar = false, $useCleanGlobalId = false) {
2174
		if ($calendar === false) {
2175
			// Open the Calendar
2176
			$calendar = $this->openDefaultCalendar();
2177
		}
2178
2179
		// Find the item by restricting all items to the correct ID
2180
		$restrict = [
2181
			RES_PROPERTY,
2182
			[
2183
				RELOP => RELOP_EQ,
2184
				ULPROPTAG => ($useCleanGlobalId === true ? $this->proptags['goid2'] : $this->proptags['goid']),
2185
				VALUE => $goid,
2186
			],
2187
		];
2188
2189
		$calendarcontents = mapi_folder_getcontentstable($calendar);
2190
2191
		$rows = mapi_table_queryallrows($calendarcontents, [PR_ENTRYID], $restrict);
2192
2193
		if (empty($rows)) {
2194
			return;
2195
		}
2196
2197
		$calendaritems = [];
2198
2199
		// In principle, there should only be one row, but we'll handle them all just in case
2200
		foreach ($rows as $row) {
2201
			$calendaritems[] = $row[PR_ENTRYID];
2202
		}
2203
2204
		return $calendaritems;
2205
	}
2206
2207
	// Returns TRUE if both entryid's are equal. Equality is defined by both entryid's pointing at the
2208
	// same SMTP address when converted to SMTP
2209
	public function compareABEntryIDs($entryid1, $entryid2): bool {
2210
		// If the session was not passed, just do a 'normal' compare.
2211
		if (!$this->session) {
2212
			return $entryid1 == $entryid2;
2213
		}
2214
2215
		$smtp1 = $this->getSMTPAddress($entryid1);
2216
		$smtp2 = $this->getSMTPAddress($entryid2);
2217
2218
		if ($smtp1 == $smtp2) {
2219
			return true;
2220
		}
2221
2222
		return false;
2223
	}
2224
2225
	// Gets the SMTP address of the passed addressbook entryid
2226
	public function getSMTPAddress($entryid) {
2227
		if (!$this->session) {
2228
			return false;
2229
		}
2230
2231
		try {
2232
			$ab = mapi_openaddressbook($this->session);
2233
			$abitem = mapi_ab_openentry($ab, $entryid);
2234
2235
			if (!$abitem) {
0 ignored issues
show
$abitem is of type resource, thus it always evaluated to true.
Loading history...
2236
				return '';
2237
			}
2238
		}
2239
		catch (MAPIException) {
2240
			return '';
2241
		}
2242
2243
		$props = mapi_getprops($abitem, [PR_ADDRTYPE, PR_EMAIL_ADDRESS, PR_SMTP_ADDRESS]);
2244
2245
		if ($props[PR_ADDRTYPE] == 'SMTP') {
2246
			return $props[PR_EMAIL_ADDRESS];
2247
		}
2248
2249
		return $props[PR_SMTP_ADDRESS];
2250
	}
2251
2252
	/**
2253
	 * Gets the properties associated with the owner of the passed store:
2254
	 * PR_DISPLAY_NAME, PR_EMAIL_ADDRESS, PR_ADDRTYPE, PR_ENTRYID, PR_SEARCH_KEY.
2255
	 *
2256
	 * @param mixed $store                  message store
2257
	 * @param bool  $fallbackToLoggedInUser If true then return properties of logged in user instead of mailbox owner.
2258
	 *                                      Not used when passed store is public store.
2259
	 *                                      For public store we are always returning logged in user's info.
2260
	 *
2261
	 * @return array|false properties of logged in user in an array in sequence of display_name, email address, address type, entryid and search key
2262
	 *
2263
	 * @psalm-return false|list{mixed, mixed, mixed, mixed, mixed}
2264
	 */
2265
	public function getOwnerAddress($store, $fallbackToLoggedInUser = true) {
2266
		if (!$this->session) {
2267
			return false;
2268
		}
2269
2270
		$storeProps = mapi_getprops($store, [PR_MAILBOX_OWNER_ENTRYID, PR_USER_ENTRYID]);
2271
2272
		$ownerEntryId = false;
2273
		if (isset($storeProps[PR_USER_ENTRYID]) && $storeProps[PR_USER_ENTRYID]) {
2274
			$ownerEntryId = $storeProps[PR_USER_ENTRYID];
2275
		}
2276
2277
		if (isset($storeProps[PR_MAILBOX_OWNER_ENTRYID]) && $storeProps[PR_MAILBOX_OWNER_ENTRYID] && !$fallbackToLoggedInUser) {
2278
			$ownerEntryId = $storeProps[PR_MAILBOX_OWNER_ENTRYID];
2279
		}
2280
2281
		if ($ownerEntryId) {
2282
			$ab = mapi_openaddressbook($this->session);
2283
2284
			$zarafaUser = mapi_ab_openentry($ab, $ownerEntryId);
2285
			if (!$zarafaUser) {
0 ignored issues
show
$zarafaUser is of type resource, thus it always evaluated to true.
Loading history...
2286
				return false;
2287
			}
2288
2289
			$ownerProps = mapi_getprops($zarafaUser, [PR_ADDRTYPE, PR_DISPLAY_NAME, PR_EMAIL_ADDRESS, PR_SEARCH_KEY]);
2290
2291
			$addrType = $ownerProps[PR_ADDRTYPE];
2292
			$name = $ownerProps[PR_DISPLAY_NAME];
2293
			$emailAddr = $ownerProps[PR_EMAIL_ADDRESS];
2294
			$searchKey = $ownerProps[PR_SEARCH_KEY];
2295
			$entryId = $ownerEntryId;
2296
2297
			return [$name, $emailAddr, $addrType, $entryId, $searchKey];
2298
		}
2299
2300
		return false;
2301
	}
2302
2303
	// Opens this session's default message store
2304
	public function openDefaultStore() {
2305
		$entryid = '';
2306
2307
		$storestable = mapi_getmsgstorestable($this->session);
2308
		$rows = mapi_table_queryallrows($storestable, [PR_ENTRYID, PR_DEFAULT_STORE]);
2309
2310
		foreach ($rows as $row) {
2311
			if (isset($row[PR_DEFAULT_STORE]) && $row[PR_DEFAULT_STORE]) {
2312
				$entryid = $row[PR_ENTRYID];
2313
				break;
2314
			}
2315
		}
2316
2317
		if (!$entryid) {
2318
			return false;
2319
		}
2320
2321
		return mapi_openmsgstore($this->session, $entryid);
2322
	}
2323
2324
	/**
2325
	 * Function which adds organizer to recipient list which is passed.
2326
	 * This function also checks if it has organizer.
2327
	 *
2328
	 * @param array $messageProps message properties
2329
	 * @param array $recipients   recipients list of message
2330
	 * @param bool  $isException  true if we are processing recipient of exception
2331
	 */
2332
	public function addOrganizer($messageProps, &$recipients, $isException = false): void {
2333
		$hasOrganizer = false;
2334
		// Check if meeting already has an organizer.
2335
		foreach ($recipients as $key => $recipient) {
2336
			if (isset($recipient[PR_RECIPIENT_FLAGS]) && $recipient[PR_RECIPIENT_FLAGS] == (recipSendable | recipOrganizer)) {
2337
				$hasOrganizer = true;
2338
			}
2339
			elseif ($isException && !isset($recipient[PR_RECIPIENT_FLAGS])) {
2340
				// Recipients for an occurrence
2341
				$recipients[$key][PR_RECIPIENT_FLAGS] = recipSendable | recipExceptionalResponse;
2342
			}
2343
		}
2344
2345
		if (!$hasOrganizer) {
2346
			// Create organizer.
2347
			$organizer = [];
2348
			$organizer[PR_ENTRYID] = $organizer[PR_RECIPIENT_ENTRYID] = $messageProps[PR_SENT_REPRESENTING_ENTRYID];
2349
			$organizer[PR_DISPLAY_NAME] = $messageProps[PR_SENT_REPRESENTING_NAME];
2350
			$organizer[PR_EMAIL_ADDRESS] = $messageProps[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
2351
			$organizer[PR_RECIPIENT_TYPE] = MAPI_TO;
2352
			$organizer[PR_RECIPIENT_DISPLAY_NAME] = $messageProps[PR_SENT_REPRESENTING_NAME];
2353
			$organizer[PR_ADDRTYPE] = empty($messageProps[PR_SENT_REPRESENTING_ADDRTYPE]) ? 'SMTP' : $messageProps[PR_SENT_REPRESENTING_ADDRTYPE];
2354
			$organizer[PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusNone;
2355
			$organizer[PR_RECIPIENT_FLAGS] = recipSendable | recipOrganizer;
2356
			$organizer[PR_SEARCH_KEY] = $messageProps[PR_SENT_REPRESENTING_SEARCH_KEY];
2357
			$organizer[PR_SMTP_ADDRESS] = $messageProps[PR_SENT_REPRESENTING_SMTP_ADDRESS] ?? $messageProps[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
2358
2359
			// Add organizer to recipients list.
2360
			array_unshift($recipients, $organizer);
2361
		}
2362
	}
2363
2364
	/**
2365
	 * Function which removes an exception/occurrence from recurrencing meeting
2366
	 * when a meeting cancellation of an occurrence is processed.
2367
	 *
2368
	 * @param mixed    $basedate basedate of an occurrence
2369
	 * @param mixed    $message  recurring item from which occurrence has to be deleted
2370
	 * @param resource $store    MAPI_MSG_Store which contains the item
2371
	 */
2372
	public function doRemoveExceptionFromCalendar($basedate, $message, $store): void {
2373
		$recurr = new Recurrence($store, $message);
2374
		$recurr->createException([], $basedate, true);
2375
		mapi_savechanges($message);
2376
	}
2377
2378
	/**
2379
	 * Function which returns basedate of an changed occurrence from globalID of meeting request.
2380
	 *
2381
	 * @param string $goid globalID
2382
	 *
2383
	 * @return false|int true if basedate is found else false it not found
2384
	 */
2385
	public function getBasedateFromGlobalID($goid) {
2386
		$hexguid = bin2hex($goid);
2387
		$hexbase = substr($hexguid, 32, 8);
2388
		$day = (int) hexdec(substr($hexbase, 6, 2));
2389
		$month = (int) hexdec(substr($hexbase, 4, 2));
2390
		$year = (int) hexdec(substr($hexbase, 0, 4));
2391
2392
		if ($day && $month && $year) {
2393
			return gmmktime(0, 0, 0, $month, $day, $year);
2394
		}
2395
2396
		return false;
2397
	}
2398
2399
	/**
2400
	 * Function which sets basedate in globalID of changed occurrence which is to be sent.
2401
	 *
2402
	 * @param string $goid     globalID
2403
	 * @param mixed  $basedate of changed occurrence
2404
	 *
2405
	 * @return false|string globalID with basedate in it
2406
	 */
2407
	public function setBasedateInGlobalID($goid, $basedate = false) {
2408
		$hexguid = bin2hex($goid);
2409
		$year = $basedate ? sprintf('%04s', dechex((int) gmdate('Y', $basedate))) : '0000';
2410
		$month = $basedate ? sprintf('%02s', dechex((int) gmdate('m', $basedate))) : '00';
2411
		$day = $basedate ? sprintf('%02s', dechex((int) gmdate('d', $basedate))) : '00';
2412
2413
		return hex2bin(strtoupper(substr($hexguid, 0, 32) . $year . $month . $day . substr($hexguid, 40)));
2414
	}
2415
2416
	/**
2417
	 * Function which replaces attachments with copy_from in copy_to.
2418
	 *
2419
	 * @param mixed $copyFrom       MAPI_message from which attachments are to be copied
2420
	 * @param mixed $copyTo         MAPI_message to which attachment are to be copied
2421
	 * @param bool  $copyExceptions if true then all exceptions should also be sent as attachments
2422
	 */
2423
	public function replaceAttachments($copyFrom, $copyTo, $copyExceptions = true): void {
2424
		/* remove all old attachments */
2425
		$attachmentTableTo = mapi_message_getattachmenttable($copyTo);
2426
		if ($attachmentTableTo) {
0 ignored issues
show
$attachmentTableTo is of type resource, thus it always evaluated to true.
Loading history...
2427
			$attachments = mapi_table_queryallrows($attachmentTableTo, [PR_ATTACH_NUM, PR_ATTACH_METHOD, PR_EXCEPTION_STARTTIME]);
2428
2429
			foreach ($attachments as $attachProps) {
2430
				/* remove exceptions too? */
2431
				if (!$copyExceptions && $attachProps[PR_ATTACH_METHOD] == ATTACH_EMBEDDED_MSG && isset($attachProps[PR_EXCEPTION_STARTTIME])) {
2432
					continue;
2433
				}
2434
				mapi_message_deleteattach($copyTo, $attachProps[PR_ATTACH_NUM]);
2435
			}
2436
		}
2437
2438
		/* copy new attachments */
2439
		$attachmentTableFrom = mapi_message_getattachmenttable($copyFrom);
2440
		if ($attachmentTableFrom) {
0 ignored issues
show
$attachmentTableFrom is of type resource, thus it always evaluated to true.
Loading history...
2441
			$attachments = mapi_table_queryallrows($attachmentTableFrom, [PR_ATTACH_NUM, PR_ATTACH_METHOD, PR_EXCEPTION_STARTTIME]);
2442
2443
			foreach ($attachments as $attachProps) {
2444
				if (!$copyExceptions && $attachProps[PR_ATTACH_METHOD] == ATTACH_EMBEDDED_MSG && isset($attachProps[PR_EXCEPTION_STARTTIME])) {
2445
					continue;
2446
				}
2447
2448
				$attachOld = mapi_message_openattach($copyFrom, (int) $attachProps[PR_ATTACH_NUM]);
2449
				$attachNewResourceMsg = mapi_message_createattach($copyTo);
2450
				mapi_copyto($attachOld, [], [], $attachNewResourceMsg, 0);
2451
				mapi_savechanges($attachNewResourceMsg);
2452
			}
2453
		}
2454
	}
2455
2456
	/**
2457
	 * Function which replaces recipients in copyTo with recipients from copyFrom.
2458
	 *
2459
	 * @param mixed $copyFrom   MAPI_message from which recipients are to be copied
2460
	 * @param mixed $copyTo     MAPI_message to which recipients are to be copied
2461
	 * @param bool  $isDelegate indicates whether delegate is processing
2462
	 *                          so don't copy delegate information to recipient table
2463
	 */
2464
	public function replaceRecipients($copyFrom, $copyTo, $isDelegate = false): void {
2465
		$recipientTable = mapi_message_getrecipienttable($copyFrom);
2466
2467
		// If delegate, then do not add the delegate in recipients
2468
		if ($isDelegate) {
2469
			$delegate = mapi_getprops($copyFrom, [PR_RECEIVED_BY_EMAIL_ADDRESS]);
2470
			$res = [
2471
				RES_PROPERTY,
2472
				[
2473
					RELOP => RELOP_NE,
2474
					ULPROPTAG => PR_EMAIL_ADDRESS,
2475
					VALUE => [PR_EMAIL_ADDRESS => $delegate[PR_RECEIVED_BY_EMAIL_ADDRESS]],
2476
				],
2477
			];
2478
			$recipients = mapi_table_queryallrows($recipientTable, $this->recipprops, $res);
2479
		}
2480
		else {
2481
			$recipients = mapi_table_queryallrows($recipientTable, $this->recipprops);
2482
		}
2483
2484
		$copyToRecipientTable = mapi_message_getrecipienttable($copyTo);
2485
		$copyToRecipientRows = mapi_table_queryallrows($copyToRecipientTable, [PR_ROWID]);
2486
2487
		mapi_message_modifyrecipients($copyTo, MODRECIP_REMOVE, $copyToRecipientRows);
2488
		mapi_message_modifyrecipients($copyTo, MODRECIP_ADD, $recipients);
2489
	}
2490
2491
	/**
2492
	 * Function creates meeting item in resource's calendar.
2493
	 *
2494
	 * @param resource $message  MAPI_message which is to create in resource's calendar
2495
	 * @param bool     $cancel   cancel meeting
2496
	 * @param mixed    $prefix   prefix for subject of meeting
2497
	 * @param mixed    $basedate
2498
	 *
2499
	 * @return (mixed|resource)[][]
2500
	 *
2501
	 * @psalm-return list<array{store: resource, folder: mixed, msg: mixed}>
2502
	 */
2503
	public function bookResources($message, $cancel, $prefix, $basedate = false): array {
2504
		if (!$this->enableDirectBooking) {
2505
			return [];
2506
		}
2507
2508
		// Get the properties of the message
2509
		$messageprops = mapi_getprops($message);
2510
2511
		$calFolder = '';
2512
2513
		if ($basedate) {
2514
			$recurrItemProps = mapi_getprops($this->message, [$this->proptags['goid'], $this->proptags['goid2'], $this->proptags['timezone_data'], $this->proptags['timezone'], PR_OWNER_APPT_ID]);
2515
2516
			$messageprops[$this->proptags['goid']] = $this->setBasedateInGlobalID($recurrItemProps[$this->proptags['goid']], $basedate);
2517
			$messageprops[$this->proptags['goid2']] = $recurrItemProps[$this->proptags['goid2']];
2518
2519
			// Delete properties which are not needed.
2520
			$deleteProps = [$this->proptags['basedate'], PR_DISPLAY_NAME, PR_ATTACHMENT_FLAGS, PR_ATTACHMENT_HIDDEN, PR_ATTACHMENT_LINKID, PR_ATTACH_FLAGS, PR_ATTACH_METHOD];
2521
			foreach ($deleteProps as $propID) {
2522
				if (isset($messageprops[$propID])) {
2523
					unset($messageprops[$propID]);
2524
				}
2525
			}
2526
2527
			if (isset($messageprops[$this->proptags['recurring']])) {
2528
				$messageprops[$this->proptags['recurring']] = false;
2529
			}
2530
2531
			// Set Outlook properties
2532
			$messageprops[$this->proptags['clipstart']] = $messageprops[$this->proptags['startdate']];
2533
			$messageprops[$this->proptags['clipend']] = $messageprops[$this->proptags['duedate']];
2534
			$messageprops[$this->proptags['timezone_data']] = $recurrItemProps[$this->proptags['timezone_data']];
2535
			$messageprops[$this->proptags['timezone']] = $recurrItemProps[$this->proptags['timezone']];
2536
			$messageprops[$this->proptags['attendee_critical_change']] = time();
2537
			$messageprops[$this->proptags['owner_critical_change']] = time();
2538
		}
2539
2540
		// Get resource recipients
2541
		$getResourcesRestriction = [
2542
			RES_PROPERTY,
2543
			[
2544
				RELOP => RELOP_EQ,	// Equals recipient type 3: Resource
2545
				ULPROPTAG => PR_RECIPIENT_TYPE,
2546
				VALUE => [PR_RECIPIENT_TYPE => MAPI_BCC],
2547
			],
2548
		];
2549
		$recipienttable = mapi_message_getrecipienttable($message);
2550
		$resourceRecipients = mapi_table_queryallrows($recipienttable, $this->recipprops, $getResourcesRestriction);
2551
2552
		$this->errorSetResource = false;
2553
		$resourceRecipData = [];
2554
2555
		// Put appointment into store resource users
2556
		$i = 0;
2557
		$len = count($resourceRecipients);
2558
		while (!$this->errorSetResource && $i < $len) {
2559
			$userStore = $this->openCustomUserStore($resourceRecipients[$i][PR_ENTRYID]);
2560
2561
			// Open root folder
2562
			$userRoot = mapi_msgstore_openentry($userStore);
2563
2564
			// Get calendar entryID
2565
			$userRootProps = mapi_getprops($userRoot, [PR_STORE_ENTRYID, PR_IPM_APPOINTMENT_ENTRYID, PR_FREEBUSY_ENTRYIDS]);
2566
2567
			// Open Calendar folder
2568
			$accessToFolder = false;
2569
2570
			try {
2571
				// @FIXME this checks delegate has access to resource's calendar folder
2572
				// but it should use boss' credentials
2573
2574
				$accessToFolder = $this->checkCalendarWriteAccess($this->store);
2575
				if ($accessToFolder) {
2576
					$calFolder = mapi_msgstore_openentry($userStore, $userRootProps[PR_IPM_APPOINTMENT_ENTRYID]);
2577
				}
2578
			}
2579
			catch (MAPIException $e) {
2580
				$e->setHandled();
2581
				$this->errorSetResource = 1; // No access
2582
			}
2583
2584
			if ($accessToFolder) {
2585
				/**
2586
				 * Get the LocalFreebusy message that contains the properties that
2587
				 * are set to accept or decline resource meeting requests.
2588
				 */
2589
				$localFreebusyMsg = FreeBusy::getLocalFreeBusyMessage($userStore);
2590
				if ($localFreebusyMsg) {
2591
					$props = mapi_getprops($localFreebusyMsg, [PR_SCHDINFO_AUTO_ACCEPT_APPTS, PR_SCHDINFO_DISALLOW_RECURRING_APPTS, PR_SCHDINFO_DISALLOW_OVERLAPPING_APPTS]);
2592
2593
					$acceptMeetingRequests = $props[PR_SCHDINFO_AUTO_ACCEPT_APPTS] ?? false;
2594
					$declineRecurringMeetingRequests = $props[PR_SCHDINFO_DISALLOW_RECURRING_APPTS] ?? false;
2595
					$declineConflictingMeetingRequests = $props[PR_SCHDINFO_DISALLOW_OVERLAPPING_APPTS] ?? false;
2596
2597
					if (!$acceptMeetingRequests) {
2598
						/*
2599
						 * When a resource has not been set to automatically accept meeting requests,
2600
						 * the meeting request has to be sent to him rather than being put directly into
2601
						 * his calendar. No error should be returned.
2602
						 */
2603
						// $errorSetResource = 2;
2604
						$this->nonAcceptingResources[] = $resourceRecipients[$i];
2605
					}
2606
					else {
2607
						if ($declineRecurringMeetingRequests && !$cancel) {
2608
							// Check if appointment is recurring
2609
							if ($messageprops[$this->proptags['recurring']]) {
2610
								$this->errorSetResource = 3;
2611
							}
2612
						}
2613
						if ($declineConflictingMeetingRequests && !$cancel) {
2614
							// Check for conflicting items
2615
							if ($calFolder && $this->isMeetingConflicting($message, $userStore, $calFolder)) {
2616
								$this->errorSetResource = 4; // Conflict
2617
							}
2618
						}
2619
					}
2620
				}
2621
			}
2622
2623
			if (!$this->errorSetResource && $accessToFolder) {
2624
				/**
2625
				 * First search on GlobalID(0x3)
2626
				 * If (recurring and occurrence) If Resource was booked for only this occurrence then Resource should have only this occurrence in Calendar and not whole series.
2627
				 * If (normal meeting) then GlobalID(0x3) and CleanGlobalID(0x23) are same, so doesn't matter if search is based on GlobalID.
2628
				 */
2629
				$rows = $this->findCalendarItems($messageprops[$this->proptags['goid']], $calFolder);
2630
2631
				/*
2632
				 * If no entry is found then
2633
				 * 1) Resource doesn't have meeting in Calendar. Seriously!!
2634
				 * OR
2635
				 * 2) We were looking for occurrence item but Resource has whole series
2636
				 */
2637
				if (empty($rows)) {
2638
					/**
2639
					 * Now search on CleanGlobalID(0x23) WHY???
2640
					 * Because we are looking recurring item.
2641
					 *
2642
					 * Possible results of this search
2643
					 * 1) If Resource was booked for more than one occurrences then this search will return all those occurrence because search is perform on CleanGlobalID
2644
					 * 2) If Resource was booked for whole series then it should return series.
2645
					 */
2646
					$rows = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder, true);
2647
2648
					$newResourceMsg = false;
2649
					if (!empty($rows)) {
2650
						// Since we are looking for recurring item, open every result and check for 'recurring' property.
2651
						foreach ($rows as $row) {
2652
							$ResourceMsg = mapi_msgstore_openentry($userStore, $row);
2653
							$ResourceMsgProps = mapi_getprops($ResourceMsg, [$this->proptags['recurring']]);
2654
2655
							if (isset($ResourceMsgProps[$this->proptags['recurring']]) && $ResourceMsgProps[$this->proptags['recurring']]) {
2656
								$newResourceMsg = $ResourceMsg;
2657
								break;
2658
							}
2659
						}
2660
					}
2661
2662
					// Still no results found. I giveup, create new message.
2663
					if (!$newResourceMsg) {
2664
						$newResourceMsg = mapi_folder_createmessage($calFolder);
2665
					}
2666
				}
2667
				else {
2668
					$newResourceMsg = mapi_msgstore_openentry($userStore, $rows[0]);
2669
				}
2670
2671
				// Prefix the subject if needed
2672
				if ($prefix && isset($messageprops[PR_SUBJECT])) {
2673
					$messageprops[PR_SUBJECT] = $prefix . $messageprops[PR_SUBJECT];
2674
				}
2675
2676
				// Set status to cancelled if needed
2677
				$messageprops[$this->proptags['busystatus']] = fbBusy; // The default status (Busy)
2678
				if ($cancel) {
2679
					$messageprops[$this->proptags['meetingstatus']] = olMeetingCanceled; // The meeting has been canceled
2680
					$messageprops[$this->proptags['busystatus']] = fbFree; // Free
2681
				}
2682
				else {
2683
					$messageprops[$this->proptags['meetingstatus']] = olMeetingReceived; // The recipient is receiving the request
2684
				}
2685
				$messageprops[$this->proptags['responsestatus']] = olResponseAccepted; // The resource automatically accepts the appointment
2686
2687
				$messageprops[PR_MESSAGE_CLASS] = 'IPM.Appointment';
2688
2689
				// Remove the PR_ICON_INDEX as it is not needed in the sent message.
2690
				$messageprops[PR_ICON_INDEX] = null;
2691
				$messageprops[PR_RESPONSE_REQUESTED] = true;
2692
2693
				// get the store of organizer, in case of delegates it will be delegate store
2694
				$defaultStore = $this->openDefaultStore();
2695
2696
				$storeProps = mapi_getprops($this->store, [PR_ENTRYID]);
2697
				$defaultStoreProps = mapi_getprops($defaultStore, [PR_ENTRYID]);
2698
2699
				// @FIXME use entryid comparison functions here
2700
				if ($storeProps[PR_ENTRYID] !== $defaultStoreProps[PR_ENTRYID]) {
2701
					// get delegate information
2702
					$addrInfo = $this->getOwnerAddress($defaultStore, false);
2703
2704
					if (!empty($addrInfo)) {
2705
						[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $addrInfo;
2706
2707
						$messageprops[PR_SENDER_EMAIL_ADDRESS] = $owneremailaddr;
2708
						$messageprops[PR_SENDER_NAME] = $ownername;
2709
						$messageprops[PR_SENDER_ADDRTYPE] = $owneraddrtype;
2710
						$messageprops[PR_SENDER_ENTRYID] = $ownerentryid;
2711
						$messageprops[PR_SENDER_SEARCH_KEY] = $ownersearchkey;
2712
					}
2713
2714
					// get delegator information
2715
					$addrInfo = $this->getOwnerAddress($this->store, false);
2716
2717
					if (!empty($addrInfo)) {
2718
						[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $addrInfo;
2719
2720
						$messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS] = $owneremailaddr;
2721
						$messageprops[PR_SENT_REPRESENTING_NAME] = $ownername;
2722
						$messageprops[PR_SENT_REPRESENTING_ADDRTYPE] = $owneraddrtype;
2723
						$messageprops[PR_SENT_REPRESENTING_ENTRYID] = $ownerentryid;
2724
						$messageprops[PR_SENT_REPRESENTING_SEARCH_KEY] = $ownersearchkey;
2725
					}
2726
				}
2727
				else {
2728
					// get organizer information
2729
					$addrInfo = $this->getOwnerAddress($this->store);
2730
2731
					if (!empty($addrInfo)) {
2732
						[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $addrInfo;
2733
2734
						$messageprops[PR_SENDER_EMAIL_ADDRESS] = $owneremailaddr;
2735
						$messageprops[PR_SENDER_NAME] = $ownername;
2736
						$messageprops[PR_SENDER_ADDRTYPE] = $owneraddrtype;
2737
						$messageprops[PR_SENDER_ENTRYID] = $ownerentryid;
2738
						$messageprops[PR_SENDER_SEARCH_KEY] = $ownersearchkey;
2739
2740
						$messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS] = $owneremailaddr;
2741
						$messageprops[PR_SENT_REPRESENTING_NAME] = $ownername;
2742
						$messageprops[PR_SENT_REPRESENTING_ADDRTYPE] = $owneraddrtype;
2743
						$messageprops[PR_SENT_REPRESENTING_ENTRYID] = $ownerentryid;
2744
						$messageprops[PR_SENT_REPRESENTING_SEARCH_KEY] = $ownersearchkey;
2745
					}
2746
				}
2747
2748
				$messageprops[$this->proptags['replytime']] = time();
2749
2750
				if ($basedate && isset($ResourceMsgProps[$this->proptags['recurring']]) && $ResourceMsgProps[$this->proptags['recurring']]) {
2751
					$recurr = new Recurrence($userStore, $newResourceMsg);
2752
2753
					// Copy recipients list
2754
					$reciptable = mapi_message_getrecipienttable($message);
2755
					$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
2756
2757
					// add owner to recipient table
2758
					$this->addOrganizer($messageprops, $recips, true);
2759
2760
					// Update occurrence
2761
					if ($recurr->isException($basedate)) {
2762
						$recurr->modifyException($messageprops, $basedate, $recips);
2763
					}
2764
					else {
2765
						$recurr->createException($messageprops, $basedate, false, $recips);
2766
					}
2767
				}
2768
				else {
2769
					mapi_setprops($newResourceMsg, $messageprops);
2770
2771
					// Copy attachments
2772
					$this->replaceAttachments($message, $newResourceMsg);
2773
2774
					// Copy all recipients too
2775
					$this->replaceRecipients($message, $newResourceMsg);
2776
2777
					// Now add organizer also to recipient table
2778
					$recips = [];
2779
					$this->addOrganizer($messageprops, $recips);
2780
2781
					mapi_message_modifyrecipients($newResourceMsg, MODRECIP_ADD, $recips);
2782
				}
2783
2784
				mapi_savechanges($newResourceMsg);
2785
2786
				$resourceRecipData[] = [
2787
					'store' => $userStore,
2788
					'folder' => $calFolder,
2789
					'msg' => $newResourceMsg,
2790
				];
2791
				$this->includesResources = true;
2792
			}
2793
			else {
2794
				/*
2795
				 * If no other errors occurred and you have no access to the
2796
				 * folder of the resource, throw an error=1.
2797
				 */
2798
				if (!$this->errorSetResource) {
2799
					$this->errorSetResource = 1;
2800
				}
2801
2802
				for ($j = 0, $len = count($resourceRecipData); $j < $len; ++$j) {
2803
					// Get the EntryID
2804
					$props = mapi_message_getprops($resourceRecipData[$j]['msg']);
2805
2806
					mapi_folder_deletemessages($resourceRecipData[$j]['folder'], [$props[PR_ENTRYID]], DELETE_HARD_DELETE);
2807
				}
2808
				$this->recipientDisplayname = $resourceRecipients[$i][PR_DISPLAY_NAME];
2809
			}
2810
			++$i;
2811
		}
2812
2813
		$recipienttable = mapi_message_getrecipienttable($message);
2814
		$resourceRecipients = mapi_table_queryallrows($recipienttable, $this->recipprops);
2815
		if (!empty($resourceRecipients)) {
2816
			// Set Tracking status of resource recipients to olResponseAccepted (3)
2817
			for ($i = 0, $len = count($resourceRecipients); $i < $len; ++$i) {
2818
				if (isset($resourceRecipients[$i][PR_RECIPIENT_TYPE]) && $resourceRecipients[$i][PR_RECIPIENT_TYPE] == MAPI_BCC) {
2819
					$resourceRecipients[$i][PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusAccepted;
2820
					$resourceRecipients[$i][PR_RECIPIENT_TRACKSTATUS_TIME] = time();
2821
				}
2822
			}
2823
			mapi_message_modifyrecipients($message, MODRECIP_MODIFY, $resourceRecipients);
2824
		}
2825
2826
		return $resourceRecipData;
2827
	}
2828
2829
	/**
2830
	 * Function which save an exception into recurring item.
2831
	 *
2832
	 * @param resource $recurringItem  reference to MAPI_message of recurring item
2833
	 * @param resource $occurrenceItem reference to MAPI_message of occurrence
2834
	 * @param string   $basedate       basedate of occurrence
2835
	 * @param bool     $move           if true then occurrence item is deleted
2836
	 * @param bool     $tentative      true if user has tentatively accepted it or false if user has accepted it
2837
	 * @param bool     $userAction     true if user has manually responded to meeting request
2838
	 * @param resource $store          user store
2839
	 * @param bool     $isDelegate     true if delegate is processing this meeting request
2840
	 */
2841
	public function acceptException(&$recurringItem, &$occurrenceItem, $basedate, $move, $tentative, $userAction, $store, $isDelegate = false): void {
2842
		$recurr = new Recurrence($store, $recurringItem);
2843
2844
		// Copy properties from meeting request
2845
		$exception_props = mapi_getprops($occurrenceItem);
2846
2847
		// Copy recipients list
2848
		$reciptable = mapi_message_getrecipienttable($occurrenceItem);
2849
		// If delegate, then do not add the delegate in recipients
2850
		if ($isDelegate) {
2851
			$delegate = mapi_getprops($this->message, [PR_RECEIVED_BY_EMAIL_ADDRESS]);
2852
			$res = [
2853
				RES_PROPERTY,
2854
				[
2855
					RELOP => RELOP_NE,
2856
					ULPROPTAG => PR_EMAIL_ADDRESS,
2857
					VALUE => [PR_EMAIL_ADDRESS => $delegate[PR_RECEIVED_BY_EMAIL_ADDRESS]],
2858
				],
2859
			];
2860
			$recips = mapi_table_queryallrows($reciptable, $this->recipprops, $res);
2861
		}
2862
		else {
2863
			$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
2864
		}
2865
2866
		// add owner to recipient table
2867
		$this->addOrganizer($exception_props, $recips, true);
2868
2869
		// add delegator to meetings
2870
		if ($isDelegate) {
2871
			$this->addDelegator($exception_props, $recips);
2872
		}
2873
2874
		$exception_props[$this->proptags['meetingstatus']] = olMeetingReceived;
2875
		$exception_props[$this->proptags['responsestatus']] = $userAction ? ($tentative ? olResponseTentative : olResponseAccepted) : olResponseNotResponded;
2876
2877
		if (isset($exception_props[$this->proptags['intendedbusystatus']])) {
2878
			if ($tentative && $exception_props[$this->proptags['intendedbusystatus']] !== fbFree) {
2879
				$exception_props[$this->proptags['busystatus']] = fbTentative;
2880
			}
2881
			else {
2882
				$exception_props[$this->proptags['busystatus']] = $exception_props[$this->proptags['intendedbusystatus']];
2883
			}
2884
			// we already have intendedbusystatus value in $exception_props so no need to copy it
2885
		}
2886
		else {
2887
			$exception_props[$this->proptags['busystatus']] = $tentative ? fbTentative : fbBusy;
2888
		}
2889
2890
		if ($userAction) {
2891
			$addrInfo = $this->getOwnerAddress($this->store);
2892
2893
			// if user has responded then set replytime and name
2894
			$exception_props[$this->proptags['replytime']] = time();
2895
			if (!empty($addrInfo)) {
2896
				$exception_props[$this->proptags['apptreplyname']] = $addrInfo[0];
2897
			}
2898
		}
2899
2900
		if ($recurr->isException($basedate)) {
2901
			$recurr->modifyException($exception_props, $basedate, $recips, $occurrenceItem);
2902
		}
2903
		else {
2904
			$recurr->createException($exception_props, $basedate, false, $recips, $occurrenceItem);
2905
		}
2906
2907
		// Move the occurrenceItem to the waste basket
2908
		if ($move) {
2909
			$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
2910
			$sourcefolder = mapi_msgstore_openentry($store, $exception_props[PR_PARENT_ENTRYID]);
2911
			mapi_folder_copymessages($sourcefolder, [$exception_props[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
2912
		}
2913
2914
		mapi_savechanges($recurringItem);
2915
	}
2916
2917
	/**
2918
	 * Function which merges an exception mapi message to recurring message.
2919
	 * This will be used when we receive recurring meeting request and we already have an exception message
2920
	 * of same meeting in calendar and we need to remove that exception message and add it to attachment table
2921
	 * of recurring meeting.
2922
	 *
2923
	 * @param resource $recurringItem  reference to MAPI_message of recurring item
2924
	 * @param resource $occurrenceItem reference to MAPI_message of occurrence
2925
	 * @param mixed    $basedate       basedate of occurrence
2926
	 * @param resource $store          user store
2927
	 */
2928
	public function mergeException(&$recurringItem, &$occurrenceItem, $basedate, $store): void {
2929
		$recurr = new Recurrence($store, $recurringItem);
2930
2931
		// Copy properties from meeting request
2932
		$exception_props = mapi_getprops($occurrenceItem);
2933
2934
		// Get recipient list from message and add it to exception attachment
2935
		$reciptable = mapi_message_getrecipienttable($occurrenceItem);
2936
		$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
2937
2938
		if ($recurr->isException($basedate)) {
2939
			$recurr->modifyException($exception_props, $basedate, $recips, $occurrenceItem);
2940
		}
2941
		else {
2942
			$recurr->createException($exception_props, $basedate, false, $recips, $occurrenceItem);
2943
		}
2944
2945
		// Move the occurrenceItem to the waste basket
2946
		$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
2947
		$sourcefolder = mapi_msgstore_openentry($store, $exception_props[PR_PARENT_ENTRYID]);
2948
		mapi_folder_copymessages($sourcefolder, [$exception_props[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
2949
2950
		mapi_savechanges($recurringItem);
2951
	}
2952
2953
	/**
2954
	 * Function which submits meeting request based on arguments passed to it.
2955
	 *
2956
	 * @param resource $message        MAPI_message whose meeting request is to be sent
2957
	 * @param bool     $cancel         if true send request, else send cancellation
2958
	 * @param mixed    $prefix         subject prefix
2959
	 * @param mixed    $basedate       basedate for an occurrence
2960
	 * @param mixed    $recurObject    recurrence object of mr
2961
	 * @param bool     $copyExceptions When sending update mail for recurring item then we don't send exceptions in attachments
2962
	 * @param mixed    $modifiedRecips
2963
	 * @param mixed    $deletedRecips
2964
	 */
2965
	public function submitMeetingRequest($message, $cancel, $prefix, $basedate = false, $recurObject = false, $copyExceptions = true, $modifiedRecips = false, $deletedRecips = false): void {
2966
		$newmessageprops = $messageprops = mapi_getprops($this->message);
2967
		$new = $this->createOutgoingMessage();
2968
2969
		// Copy the entire message into the new meeting request message
2970
		if ($basedate) {
2971
			// messageprops contains properties of whole recurring series
2972
			// and newmessageprops contains properties of exception item
2973
			$newmessageprops = mapi_getprops($message);
2974
2975
			// Ensure that the correct basedate is set in the new message
2976
			$newmessageprops[$this->proptags['basedate']] = $basedate;
2977
2978
			// Set isRecurring to false, because this is an exception
2979
			$newmessageprops[$this->proptags['recurring']] = false;
2980
2981
			// set LID_IS_EXCEPTION to true
2982
			$newmessageprops[$this->proptags['is_exception']] = true;
2983
2984
			// Set to high importance
2985
			if ($cancel) {
2986
				$newmessageprops[PR_IMPORTANCE] = IMPORTANCE_HIGH;
2987
			}
2988
2989
			// Set startdate and enddate of exception
2990
			if ($cancel && $recurObject) {
2991
				$newmessageprops[$this->proptags['startdate']] = $recurObject->getOccurrenceStart($basedate);
2992
				$newmessageprops[$this->proptags['duedate']] = $recurObject->getOccurrenceEnd($basedate);
2993
			}
2994
2995
			// Set basedate in guid (0x3)
2996
			$newmessageprops[$this->proptags['goid']] = $this->setBasedateInGlobalID($messageprops[$this->proptags['goid2']], $basedate);
2997
			$newmessageprops[$this->proptags['goid2']] = $messageprops[$this->proptags['goid2']];
2998
			$newmessageprops[PR_OWNER_APPT_ID] = $messageprops[PR_OWNER_APPT_ID];
2999
3000
			// Get deleted recipiets from exception msg
3001
			$restriction = [
3002
				RES_AND,
3003
				[
3004
					[
3005
						RES_BITMASK,
3006
						[
3007
							ULTYPE => BMR_NEZ,
3008
							ULPROPTAG => PR_RECIPIENT_FLAGS,
3009
							ULMASK => recipExceptionalDeleted,
3010
						],
3011
					],
3012
					[
3013
						RES_BITMASK,
3014
						[
3015
							ULTYPE => BMR_EQZ,
3016
							ULPROPTAG => PR_RECIPIENT_FLAGS,
3017
							ULMASK => recipOrganizer,
3018
						],
3019
					],
3020
				],
3021
			];
3022
3023
			// In direct-booking mode, we don't need to send cancellations to resources
3024
			if ($this->enableDirectBooking) {
3025
				$restriction[1][] = [
3026
					RES_PROPERTY,
3027
					[
3028
						RELOP => RELOP_NE,	// Does not equal recipient type: MAPI_BCC (Resource)
3029
						ULPROPTAG => PR_RECIPIENT_TYPE,
3030
						VALUE => [PR_RECIPIENT_TYPE => MAPI_BCC],
3031
					],
3032
				];
3033
			}
3034
3035
			$recipienttable = mapi_message_getrecipienttable($message);
3036
			$recipients = mapi_table_queryallrows($recipienttable, $this->recipprops, $restriction);
3037
3038
			if (!$deletedRecips) {
3039
				$deletedRecips = array_merge([], $recipients);
3040
			}
3041
			else {
3042
				$deletedRecips = array_merge($deletedRecips, $recipients);
3043
			}
3044
		}
3045
3046
		// Remove the PR_ICON_INDEX as it is not needed in the sent message.
3047
		$newmessageprops[PR_ICON_INDEX] = null;
3048
		$newmessageprops[PR_RESPONSE_REQUESTED] = true;
3049
3050
		// PR_START_DATE and PR_END_DATE will be used by outlook to show the position in the calendar
3051
		$newmessageprops[PR_START_DATE] = $newmessageprops[$this->proptags['startdate']];
3052
		$newmessageprops[PR_END_DATE] = $newmessageprops[$this->proptags['duedate']];
3053
3054
		// Set updatecounter/AppointmentSequenceNumber
3055
		// get the value of latest updatecounter for the whole series and use it
3056
		$newmessageprops[$this->proptags['updatecounter']] = $messageprops[$this->proptags['last_updatecounter']];
3057
3058
		$meetingTimeInfo = $this->getMeetingTimeInfo();
3059
3060
		if ($meetingTimeInfo) {
3061
			// Needs to unset PR_HTML and PR_RTF_COMPRESSED props
3062
			// because while canceling meeting requests with edit text
3063
			// will override the PR_BODY because body value is not consistent with
3064
			// PR_HTML and PR_RTF_COMPRESSED value so in this case PR_RTF_COMPRESSED will
3065
			// get priority which override the PR_BODY value.
3066
			unset($newmessageprops[PR_HTML], $newmessageprops[PR_RTF_COMPRESSED]);
3067
3068
			$newmessageprops[PR_BODY] = $meetingTimeInfo;
3069
		}
3070
3071
		// Send all recurrence info in mail, if this is a recurrence meeting.
3072
		if (isset($messageprops[$this->proptags['recurring']]) && $messageprops[$this->proptags['recurring']]) {
3073
			if (!empty($messageprops[$this->proptags['recurring_pattern']])) {
3074
				$newmessageprops[$this->proptags['recurring_pattern']] = $messageprops[$this->proptags['recurring_pattern']];
3075
			}
3076
			$newmessageprops[$this->proptags['recurrence_data']] = $messageprops[$this->proptags['recurrence_data']];
3077
			$newmessageprops[$this->proptags['timezone_data']] = $messageprops[$this->proptags['timezone_data']];
3078
			$newmessageprops[$this->proptags['timezone']] = $messageprops[$this->proptags['timezone']];
3079
3080
			if ($recurObject) {
3081
				$this->generateRecurDates($recurObject, $messageprops, $newmessageprops);
3082
			}
3083
		}
3084
3085
		if (isset($newmessageprops[$this->proptags['counter_proposal']])) {
3086
			unset($newmessageprops[$this->proptags['counter_proposal']]);
3087
		}
3088
3089
		// Prefix the subject if needed
3090
		if ($prefix && isset($newmessageprops[PR_SUBJECT])) {
3091
			$newmessageprops[PR_SUBJECT] = $prefix . $newmessageprops[PR_SUBJECT];
3092
		}
3093
3094
		if (isset($newmessageprops[$this->proptags['categories']]) &&
3095
			!empty($newmessageprops[$this->proptags['categories']])) {
3096
			unset($newmessageprops[$this->proptags['categories']]);
3097
		}
3098
		mapi_setprops($new, $newmessageprops);
3099
3100
		// Copy attachments
3101
		$this->replaceAttachments($message, $new, $copyExceptions);
3102
3103
		// Retrieve only those recipient who should receive this meeting request.
3104
		$stripResourcesRestriction = [
3105
			RES_AND,
3106
			[
3107
				[
3108
					RES_BITMASK,
3109
					[
3110
						ULTYPE => BMR_EQZ,
3111
						ULPROPTAG => PR_RECIPIENT_FLAGS,
3112
						ULMASK => recipExceptionalDeleted,
3113
					],
3114
				],
3115
				[
3116
					RES_BITMASK,
3117
					[
3118
						ULTYPE => BMR_EQZ,
3119
						ULPROPTAG => PR_RECIPIENT_FLAGS,
3120
						ULMASK => recipOrganizer,
3121
					],
3122
				],
3123
			],
3124
		];
3125
3126
		// In direct-booking mode, resources do not receive a meeting request
3127
		if ($this->enableDirectBooking) {
3128
			$stripResourcesRestriction[1][] = [
3129
				RES_PROPERTY,
3130
				[
3131
					RELOP => RELOP_NE,	// Does not equal recipient type: MAPI_BCC (Resource)
3132
					ULPROPTAG => PR_RECIPIENT_TYPE,
3133
					VALUE => [PR_RECIPIENT_TYPE => MAPI_BCC],
3134
				],
3135
			];
3136
		}
3137
3138
		// If no recipients were explicitly provided, we will send the update to all
3139
		// recipients from the meeting.
3140
		if ($modifiedRecips === false) {
3141
			$recipienttable = mapi_message_getrecipienttable($message);
3142
			$modifiedRecips = mapi_table_queryallrows($recipienttable, $this->recipprops, $stripResourcesRestriction);
3143
3144
			if ($basedate && empty($modifiedRecips)) {
3145
				// Retrieve full list
3146
				$recipienttable = mapi_message_getrecipienttable($this->message);
3147
				$modifiedRecips = mapi_table_queryallrows($recipienttable, $this->recipprops);
3148
3149
				// Save recipients in exceptions
3150
				mapi_message_modifyrecipients($message, MODRECIP_ADD, $modifiedRecips);
3151
3152
				// Now retrieve only those recipient who should receive this meeting request.
3153
				$modifiedRecips = mapi_table_queryallrows($recipienttable, $this->recipprops, $stripResourcesRestriction);
3154
			}
3155
		}
3156
3157
		// @TODO: handle nonAcceptingResources
3158
		/*
3159
		 * Add resource recipients that did not automatically accept the meeting request.
3160
		 * (note: meaning that they did not decline the meeting request)
3161
		 */ /*
3162
		for($i=0;$i<count($this->nonAcceptingResources);$i++){
3163
			$recipients[] = $this->nonAcceptingResources[$i];
3164
		}*/
3165
3166
		if (!empty($modifiedRecips)) {
3167
			// Strip out the sender/'owner' recipient
3168
			mapi_message_modifyrecipients($new, MODRECIP_ADD, $modifiedRecips);
3169
3170
			// Set some properties that are different in the sent request than
3171
			// in the item in our calendar
3172
3173
			// we should store busystatus value to intendedbusystatus property, because busystatus for outgoing meeting request
3174
			// should always be fbTentative
3175
			$newmessageprops[$this->proptags['intendedbusystatus']] = $newmessageprops[$this->proptags['busystatus']] ?? $messageprops[$this->proptags['busystatus']];
3176
			$newmessageprops[$this->proptags['busystatus']] = fbTentative; // The default status when not accepted
3177
			$newmessageprops[$this->proptags['responsestatus']] = olResponseNotResponded; // The recipient has not responded yet
3178
			$newmessageprops[$this->proptags['attendee_critical_change']] = time();
3179
			$newmessageprops[$this->proptags['owner_critical_change']] = time();
3180
			$newmessageprops[$this->proptags['meetingtype']] = mtgRequest;
3181
3182
			if ($cancel) {
3183
				$newmessageprops[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Canceled';
3184
				$newmessageprops[$this->proptags['meetingstatus']] = olMeetingCanceled; // It's a cancel request
3185
				$newmessageprops[$this->proptags['busystatus']] = fbFree; // set the busy status as free
3186
			}
3187
			else {
3188
				$newmessageprops[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Request';
3189
				$newmessageprops[$this->proptags['meetingstatus']] = olMeetingReceived; // The recipient is receiving the request
3190
			}
3191
3192
			mapi_setprops($new, $newmessageprops);
3193
			mapi_savechanges($new);
3194
3195
			// Submit message to non-resource recipients
3196
			mapi_message_submitmessage($new);
3197
		}
3198
3199
		// Search through the deleted recipients, and see if any of them is also
3200
		// listed as a recipient to whom we have sent an update. As we don't
3201
		// want to send a cancellation message to recipients who will also receive
3202
		// an meeting update, we have to filter those recipients out.
3203
		if ($deletedRecips) {
3204
			$tmp = [];
3205
3206
			foreach ($deletedRecips as $delRecip) {
3207
				$found = false;
3208
3209
				// Search if the deleted recipient can be found inside
3210
				// the updated recipients as well.
3211
				foreach ($modifiedRecips as $recip) {
3212
					if ($this->compareABEntryIDs($recip[PR_ENTRYID], $delRecip[PR_ENTRYID])) {
3213
						$found = true;
3214
						break;
3215
					}
3216
				}
3217
3218
				// If the recipient was not found, it truly is deleted,
3219
				// and we can safely send a cancellation message
3220
				if (!$found) {
3221
					$tmp[] = $delRecip;
3222
				}
3223
			}
3224
3225
			$deletedRecips = $tmp;
3226
		}
3227
3228
		// Send cancellation to deleted attendees
3229
		if ($deletedRecips) {
3230
			$new = $this->createOutgoingMessage();
3231
3232
			mapi_message_modifyrecipients($new, MODRECIP_ADD, $deletedRecips);
3233
3234
			$newmessageprops[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Canceled';
3235
			$newmessageprops[$this->proptags['meetingstatus']] = olMeetingCanceled; // It's a cancel request
3236
			$newmessageprops[$this->proptags['busystatus']] = fbFree; // set the busy status as free
3237
			$newmessageprops[PR_IMPORTANCE] = IMPORTANCE_HIGH;	// HIGH Importance
3238
			if (isset($newmessageprops[PR_SUBJECT])) {
3239
				$newmessageprops[PR_SUBJECT] = dgettext('zarafa', 'Canceled') . ': ' . $newmessageprops[PR_SUBJECT];
3240
			}
3241
3242
			mapi_setprops($new, $newmessageprops);
3243
			mapi_savechanges($new);
3244
3245
			// Submit message to non-resource recipients
3246
			mapi_message_submitmessage($new);
3247
		}
3248
3249
		// Set properties on meeting object in calendar
3250
		// Set requestsent to 'true' (turns on 'tracking', etc)
3251
		$props = [];
3252
		$props[$this->proptags['meetingstatus']] = olMeeting;
3253
		$props[$this->proptags['responsestatus']] = olResponseOrganized;
3254
		// Only set the 'requestsent' property if it wasn't set previously yet,
3255
		// this ensures we will not accidentally set it from true to false.
3256
		if (!isset($messageprops[$this->proptags['requestsent']]) || $messageprops[$this->proptags['requestsent']] !== true) {
3257
			$props[$this->proptags['requestsent']] = !empty($modifiedRecips) || ($this->includesResources && !$this->errorSetResource);
3258
		}
3259
		$props[$this->proptags['attendee_critical_change']] = time();
3260
		$props[$this->proptags['owner_critical_change']] = time();
3261
		$props[$this->proptags['meetingtype']] = mtgRequest;
3262
		// save the new updatecounter to exception/recurring series/normal meeting
3263
		$props[$this->proptags['updatecounter']] = $newmessageprops[$this->proptags['updatecounter']];
3264
3265
		// PR_START_DATE and PR_END_DATE will be used by outlook to show the position in the calendar
3266
		$props[PR_START_DATE] = $messageprops[$this->proptags['startdate']];
3267
		$props[PR_END_DATE] = $messageprops[$this->proptags['duedate']];
3268
3269
		mapi_setprops($message, $props);
3270
3271
		// saving of these properties on calendar item should be handled by caller function
3272
		// based on sending meeting request was successful or not
3273
	}
3274
3275
	/**
3276
	 * OL2007 uses these 4 properties to specify occurrence that should be updated.
3277
	 * ical generates RECURRENCE-ID property based on exception's basedate (PidLidExceptionReplaceTime),
3278
	 * but OL07 doesn't send this property, so ical will generate RECURRENCE-ID property based on date
3279
	 * from GlobalObjId and time from StartRecurTime property, so we are sending basedate property and
3280
	 * also additionally we are sending these properties.
3281
	 * Ref: MS-OXCICAL 2.2.1.20.20 Property: RECURRENCE-ID.
3282
	 *
3283
	 * @param object $recurObject     instance of recurrence class for this message
3284
	 * @param array  $messageprops    properties of meeting object that is going to be sent
3285
	 * @param array  $newmessageprops properties of meeting request/response that is going to be sent
3286
	 */
3287
	public function generateRecurDates($recurObject, $messageprops, &$newmessageprops): void {
3288
		if ($messageprops[$this->proptags['startdate']] && $messageprops[$this->proptags['duedate']]) {
3289
			$startDate = date('Y:n:j:G:i:s', $recurObject->fromGMT($recurObject->tz, $messageprops[$this->proptags['startdate']]));
3290
			$endDate = date('Y:n:j:G:i:s', $recurObject->fromGMT($recurObject->tz, $messageprops[$this->proptags['duedate']]));
3291
3292
			$startDate = explode(':', $startDate);
3293
			$endDate = explode(':', $endDate);
3294
3295
			// [0] => year, [1] => month, [2] => day, [3] => hour, [4] => minutes, [5] => seconds
3296
			// RecurStartDate = year * 512 + month_number * 32 + day_number
3297
			$newmessageprops[$this->proptags['start_recur_date']] = (((int) $startDate[0]) * 512) + (((int) $startDate[1]) * 32) + ((int) $startDate[2]);
3298
			// RecurStartTime = hour * 4096 + minutes * 64 + seconds
3299
			$newmessageprops[$this->proptags['start_recur_time']] = (((int) $startDate[3]) * 4096) + (((int) $startDate[4]) * 64) + ((int) $startDate[5]);
3300
3301
			$newmessageprops[$this->proptags['end_recur_date']] = (((int) $endDate[0]) * 512) + (((int) $endDate[1]) * 32) + ((int) $endDate[2]);
3302
			$newmessageprops[$this->proptags['end_recur_time']] = (((int) $endDate[3]) * 4096) + (((int) $endDate[4]) * 64) + ((int) $endDate[5]);
3303
		}
3304
	}
3305
3306
	/**
3307
	 * Function will create a new outgoing message that will be used to send meeting mail.
3308
	 *
3309
	 * @param mixed $store (optional) store that is used when creating response, if delegate is creating outgoing mail
3310
	 *                     then this would point to delegate store
3311
	 *
3312
	 * @return resource outgoing mail that is created and can be used for sending it
3313
	 */
3314
	public function createOutgoingMessage($store = false) {
3315
		// get logged in user's store that will be used to send mail, for delegate this will be
3316
		// delegate store
3317
		$userStore = $this->openDefaultStore();
3318
3319
		$sentprops = [];
3320
		$outbox = $this->openDefaultOutbox($userStore);
3321
3322
		$outgoing = mapi_folder_createmessage($outbox);
3323
3324
		// check if $store is set and it is not equal to $defaultStore (means its the delegation case)
3325
		if ($store !== false) {
3326
			$storeProps = mapi_getprops($store, [PR_ENTRYID]);
3327
			$userStoreProps = mapi_getprops($userStore, [PR_ENTRYID]);
3328
3329
			// @FIXME use entryid comparison functions here
3330
			if ($storeProps[PR_ENTRYID] !== $userStoreProps[PR_ENTRYID]) {
3331
				// get the delegator properties and set it into outgoing mail
3332
				$delegatorDetails = $this->getOwnerAddress($store, false);
3333
3334
				if (!empty($delegatorDetails)) {
3335
					[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $delegatorDetails;
3336
					$sentprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS] = $owneremailaddr;
3337
					$sentprops[PR_SENT_REPRESENTING_NAME] = $ownername;
3338
					$sentprops[PR_SENT_REPRESENTING_ADDRTYPE] = $owneraddrtype;
3339
					$sentprops[PR_SENT_REPRESENTING_ENTRYID] = $ownerentryid;
3340
					$sentprops[PR_SENT_REPRESENTING_SEARCH_KEY] = $ownersearchkey;
3341
				}
3342
3343
				// get the delegate properties and set it into outgoing mail
3344
				$delegateDetails = $this->getOwnerAddress($userStore, false);
3345
3346
				if (!empty($delegateDetails)) {
3347
					[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $delegateDetails;
3348
					$sentprops[PR_SENDER_EMAIL_ADDRESS] = $owneremailaddr;
3349
					$sentprops[PR_SENDER_NAME] = $ownername;
3350
					$sentprops[PR_SENDER_ADDRTYPE] = $owneraddrtype;
3351
					$sentprops[PR_SENDER_ENTRYID] = $ownerentryid;
3352
					$sentprops[PR_SENDER_SEARCH_KEY] = $ownersearchkey;
3353
				}
3354
			}
3355
		}
3356
		else {
3357
			// normal user is sending mail, so both set of properties will be same
3358
			$userDetails = $this->getOwnerAddress($userStore);
3359
3360
			if (!empty($userDetails)) {
3361
				[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $userDetails;
3362
				$sentprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS] = $owneremailaddr;
3363
				$sentprops[PR_SENT_REPRESENTING_NAME] = $ownername;
3364
				$sentprops[PR_SENT_REPRESENTING_ADDRTYPE] = $owneraddrtype;
3365
				$sentprops[PR_SENT_REPRESENTING_ENTRYID] = $ownerentryid;
3366
				$sentprops[PR_SENT_REPRESENTING_SEARCH_KEY] = $ownersearchkey;
3367
3368
				$sentprops[PR_SENDER_EMAIL_ADDRESS] = $owneremailaddr;
3369
				$sentprops[PR_SENDER_NAME] = $ownername;
3370
				$sentprops[PR_SENDER_ADDRTYPE] = $owneraddrtype;
3371
				$sentprops[PR_SENDER_ENTRYID] = $ownerentryid;
3372
				$sentprops[PR_SENDER_SEARCH_KEY] = $ownersearchkey;
3373
			}
3374
		}
3375
3376
		$sentprops[PR_SENTMAIL_ENTRYID] = $this->getDefaultSentmailEntryID($userStore);
3377
3378
		mapi_setprops($outgoing, $sentprops);
3379
3380
		return $outgoing;
3381
	}
3382
3383
	/**
3384
	 * Function which checks that meeting in attendee's calendar is already updated
3385
	 * and we are checking an old meeting request. This function also will update property
3386
	 * meetingtype to indicate that its out of date meeting request.
3387
	 *
3388
	 * @return bool true if meeting request is outofdate else false if it is new
3389
	 */
3390
	public function isMeetingOutOfDate() {
3391
		$result = false;
3392
3393
		$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS, $this->proptags['goid'], $this->proptags['goid2'], $this->proptags['updatecounter'], $this->proptags['meetingtype'], $this->proptags['owner_critical_change']]);
3394
3395
		if (!$this->isMeetingRequest($props[PR_MESSAGE_CLASS])) {
3396
			return $result;
3397
		}
3398
3399
		if (isset($props[$this->proptags['meetingtype']]) && ($props[$this->proptags['meetingtype']] & mtgOutOfDate) == mtgOutOfDate) {
3400
			return true;
3401
		}
3402
3403
		// get the basedate to check for exception
3404
		$basedate = $this->getBasedateFromGlobalID($props[$this->proptags['goid']]);
3405
3406
		$calendarItem = $this->getCorrespondentCalendarItem(true);
3407
3408
		// if basedate is provided and we could not find the item then it could be that we are checking
3409
		// an exception so get the exception and check it
3410
		if ($basedate !== false && $calendarItem !== false) {
3411
			$exception = $this->getExceptionItem($calendarItem, $basedate);
3412
3413
			if ($exception !== false) {
0 ignored issues
show
The condition $exception !== false is always true.
Loading history...
3414
				// we are able to find the exception compare with it
3415
				$calendarItem = $exception;
3416
			}
3417
			// we are not able to find exception, could mean that a significant change has occurred on series
3418
			// and it deleted all exceptions, so compare with series
3419
			// $calendarItem already contains reference to series
3420
		}
3421
3422
		if ($calendarItem !== false) {
3423
			$calendarItemProps = mapi_getprops($calendarItem, [
3424
				$this->proptags['owner_critical_change'],
3425
				$this->proptags['updatecounter'],
3426
			]);
3427
3428
			$updateCounter = (isset($calendarItemProps[$this->proptags['updatecounter']]) && $props[$this->proptags['updatecounter']] < $calendarItemProps[$this->proptags['updatecounter']]);
3429
3430
			$criticalChange = (isset($calendarItemProps[$this->proptags['owner_critical_change']]) && $props[$this->proptags['owner_critical_change']] < $calendarItemProps[$this->proptags['owner_critical_change']]);
3431
3432
			if ($updateCounter || $criticalChange) {
3433
				// meeting request is out of date, set properties to indicate this
3434
				mapi_setprops($this->message, [$this->proptags['meetingtype'] => mtgOutOfDate, PR_ICON_INDEX => 1033]);
3435
				mapi_savechanges($this->message);
3436
3437
				$result = true;
3438
			}
3439
		}
3440
3441
		return $result;
3442
	}
3443
3444
	/**
3445
	 * Function which checks that if we have received a meeting response for an updated meeting in organizer's calendar.
3446
	 *
3447
	 * @param mixed $basedate basedate of the exception if we want to compare with exception
3448
	 *
3449
	 * @return bool true if meeting request is updated later
3450
	 */
3451
	public function isMeetingUpdated($basedate = false) {
3452
		$result = false;
3453
3454
		$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS, $this->proptags['updatecounter']]);
3455
3456
		if (!$this->isMeetingRequestResponse($props[PR_MESSAGE_CLASS])) {
3457
			return $result;
3458
		}
3459
3460
		$calendarItem = $this->getCorrespondentCalendarItem(true);
3461
3462
		if ($calendarItem !== false) {
3463
			// basedate is provided so open exception
3464
			if ($basedate !== false) {
3465
				$exception = $this->getExceptionItem($calendarItem, $basedate);
3466
3467
				if ($exception !== false) {
0 ignored issues
show
The condition $exception !== false is always true.
Loading history...
3468
					// we are able to find the exception compare with it
3469
					$calendarItem = $exception;
3470
				}
3471
				// we are not able to find exception, could mean that a significant change has occurred on series
3472
				// and it deleted all exceptions, so compare with series
3473
				// $calendarItem already contains reference to series
3474
			}
3475
3476
			if ($calendarItem !== false) {
0 ignored issues
show
The condition $calendarItem !== false is always true.
Loading history...
3477
				$calendarItemProps = mapi_getprops($calendarItem, [$this->proptags['updatecounter']]);
3478
3479
				/*
3480
				 * if(message_counter < appointment_counter) meeting object is newer then meeting response (meeting is updated)
3481
				 * if(message_counter >= appointment_counter) meeting is not updated, do normal processing
3482
				 */
3483
				if (isset($calendarItemProps[$this->proptags['updatecounter']], $props[$this->proptags['updatecounter']])) {
3484
					if ($props[$this->proptags['updatecounter']] < $calendarItemProps[$this->proptags['updatecounter']]) {
3485
						$result = true;
3486
					}
3487
				}
3488
			}
3489
		}
3490
3491
		return $result;
3492
	}
3493
3494
	/**
3495
	 * Checks if there has been any significant changes on appointment/meeting item.
3496
	 * Significant changes be:
3497
	 * 1) startdate has been changed
3498
	 * 2) duedate has been changed OR
3499
	 * 3) recurrence pattern has been created, modified or removed.
3500
	 *
3501
	 * @param mixed $oldProps
3502
	 * @param mixed $basedate
3503
	 * @param mixed $isRecurrenceChanged for change in recurrence pattern.
3504
	 *                                   true means Recurrence pattern has been changed,
3505
	 *                                   so clear all attendees response
3506
	 */
3507
	public function checkSignificantChanges($oldProps, $basedate, $isRecurrenceChanged = false) {
3508
		$message = null;
3509
		$attach = null;
3510
3511
		// If basedate is specified then we need to open exception message to clear recipient responses
3512
		if ($basedate) {
3513
			$recurrence = new Recurrence($this->store, $this->message);
3514
			if ($recurrence->isException($basedate)) {
3515
				$attach = $recurrence->getExceptionAttachment($basedate);
3516
				if ($attach) {
3517
					$message = mapi_attach_openobj($attach, MAPI_MODIFY);
3518
				}
3519
			}
3520
		}
3521
		else {
3522
			// use normal message or recurring series message
3523
			$message = $this->message;
3524
		}
3525
3526
		if (!$message) {
3527
			return;
3528
		}
3529
3530
		$newProps = mapi_getprops($message, [$this->proptags['startdate'], $this->proptags['duedate'], $this->proptags['updatecounter']]);
3531
3532
		// Check whether message is updated or not.
3533
		if (isset($newProps[$this->proptags['updatecounter']]) && $newProps[$this->proptags['updatecounter']] == 0) {
3534
			return;
3535
		}
3536
3537
		if (($newProps[$this->proptags['startdate']] != $oldProps[$this->proptags['startdate']]) ||
3538
				($newProps[$this->proptags['duedate']] != $oldProps[$this->proptags['duedate']]) ||
3539
				$isRecurrenceChanged) {
3540
			$this->clearRecipientResponse($message);
3541
3542
			mapi_setprops($message, [$this->proptags['owner_critical_change'] => time()]);
3543
3544
			mapi_savechanges($message);
3545
			if ($attach) { // Also save attachment Object.
3546
				mapi_savechanges($attach);
3547
			}
3548
		}
3549
	}
3550
3551
	/**
3552
	 * Clear responses of all attendees who have replied in past.
3553
	 *
3554
	 * @param resource $message on which responses should be cleared
3555
	 */
3556
	public function clearRecipientResponse($message): void {
3557
		$recipTable = mapi_message_getrecipienttable($message);
3558
		$recipsRows = mapi_table_queryallrows($recipTable, $this->recipprops);
3559
		for ($i = 0, $recipsCnt = mapi_table_getrowcount($recipTable); $i < $recipsCnt; ++$i) {
3560
			// Clear track status for everyone in the recipients table
3561
			$recipsRows[$i][PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusNone;
3562
		}
3563
		mapi_message_modifyrecipients($message, MODRECIP_MODIFY, $recipsRows);
3564
	}
3565
3566
	/**
3567
	 * Function returns correspondent calendar item attached with the meeting request/response/cancellation.
3568
	 * This will only check for actual MAPIMessages in calendar folder, so if a meeting request is
3569
	 * for exception then this function will return recurring series for that meeting request
3570
	 * after that you need to use getExceptionItem function to get exception item that will be
3571
	 * fetched from the attachment table of recurring series MAPIMessage.
3572
	 *
3573
	 * @param bool $open boolean to indicate the function should return entryid or MAPIMessage. Defaults to true.
3574
	 *
3575
	 * @return bool|resource resource of calendar item
3576
	 */
3577
	public function getCorrespondentCalendarItem($open = true) {
3578
		$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS, $this->proptags['goid'], $this->proptags['goid2'], PR_RCVD_REPRESENTING_ENTRYID]);
3579
3580
		if (!$this->isMeetingRequest($props[PR_MESSAGE_CLASS]) && !$this->isMeetingRequestResponse($props[PR_MESSAGE_CLASS]) && !$this->isMeetingCancellation($props[PR_MESSAGE_CLASS])) {
3581
			// can work only with meeting requests/responses/cancellations
3582
			return false;
3583
		}
3584
3585
		// there is no goid - no items can be found - aborting
3586
		if (empty($props[$this->proptags['goid']])) {
3587
			return false;
3588
		}
3589
		$globalId = $props[$this->proptags['goid']];
3590
3591
		$store = $this->store;
3592
		$calFolder = $this->openDefaultCalendar();
3593
		// If Delegate is processing Meeting Request/Response for Delegator then retrieve Delegator's store and calendar.
3594
		if (isset($props[PR_RCVD_REPRESENTING_ENTRYID])) {
3595
			$delegatorStore = $this->getDelegatorStore($props[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
3596
			if (!empty($delegatorStore['store'])) {
3597
				$store = $delegatorStore['store'];
3598
			}
3599
			if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
3600
				$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
3601
			}
3602
		}
3603
3604
		$basedate = $this->getBasedateFromGlobalID($globalId);
3605
3606
		/**
3607
		 * First search for any appointments which correspond to the $globalId,
3608
		 * this can be the entire series (if the Meeting Request refers to the
3609
		 * entire series), or an particular Occurrence (if the meeting Request
3610
		 * contains a basedate).
3611
		 *
3612
		 * If we cannot find a corresponding item, and the $globalId contains
3613
		 * a $basedate, it might imply that a new exception will have to be
3614
		 * created for a series which is present in the calendar, we can look
3615
		 * that one up by searching for the $cleanGlobalId.
3616
		 */
3617
		$entryids = $this->findCalendarItems($globalId, $calFolder);
3618
		if ($basedate !== false && empty($entryids)) {
3619
			// only search if a goid2 is available
3620
			if (!empty($props[$this->proptags['goid2']])) {
3621
				$cleanGlobalId = $props[$this->proptags['goid2']];
3622
				$entryids = $this->findCalendarItems($cleanGlobalId, $calFolder, true);
3623
			}
3624
		}
3625
3626
		// there should be only one item returned
3627
		if (!empty($entryids) && count($entryids) === 1) {
3628
			// return only entryid
3629
			if ($open === false) {
3630
				return $entryids[0];
3631
			}
3632
3633
			// open calendar item and return it
3634
			if ($store) {
3635
				return mapi_msgstore_openentry($store, $entryids[0]);
3636
			}
3637
		}
3638
3639
		// no items found in calendar
3640
		return false;
3641
	}
3642
3643
	/**
3644
	 * Function returns exception item based on the basedate passed.
3645
	 *
3646
	 * @param mixed $recurringMessage Resource of Recurring meeting from calendar
3647
	 * @param mixed $basedate         basedate of exception that needs to be returned
3648
	 * @param mixed $store            store that contains the recurring calendar item
3649
	 *
3650
	 * @return entryid or MAPIMessage resource of exception item
3651
	 */
3652
	public function getExceptionItem($recurringMessage, $basedate, $store = false) {
3653
		$occurItem = false;
3654
3655
		$props = mapi_getprops($this->message, [PR_RCVD_REPRESENTING_ENTRYID, $this->proptags['recurring']]);
3656
3657
		// check if the passed item is recurring series
3658
		if (isset($props[$this->proptags['recurring']]) && $props[$this->proptags['recurring']] !== false) {
3659
			return false;
3660
		}
3661
3662
		if ($store === false) {
3663
			$store = $this->store;
3664
			// If Delegate is processing Meeting Request/Response for Delegator then retrieve Delegator's store and calendar.
3665
			if (isset($props[PR_RCVD_REPRESENTING_ENTRYID])) {
3666
				$delegatorStore = $this->getDelegatorStore($props[PR_RCVD_REPRESENTING_ENTRYID]);
3667
				if (!empty($delegatorStore['store'])) {
3668
					$store = $delegatorStore['store'];
3669
				}
3670
			}
3671
		}
3672
3673
		$recurr = new Recurrence($store, $recurringMessage);
3674
		$attach = $recurr->getExceptionAttachment($basedate);
3675
		if ($attach) {
3676
			$occurItem = mapi_attach_openobj($attach);
3677
		}
3678
3679
		return $occurItem;
3680
	}
3681
3682
	/**
3683
	 * Function which checks whether received meeting request is either conflicting with other appointments or not.
3684
	 *
3685
	 * @param false|resource $message
3686
	 * @param false|resource $userStore
3687
	 * @param mixed          $calFolder calendar folder for conflict checking
3688
	 *
3689
	 * @return bool|int
3690
	 *
3691
	 * @psalm-return bool|int<1, max>
3692
	 */
3693
	public function isMeetingConflicting($message = false, $userStore = false, $calFolder = false) {
3694
		$returnValue = false;
3695
		$noOfInstances = 0;
3696
3697
		if ($message === false) {
3698
			$message = $this->message;
3699
		}
3700
3701
		$messageProps = mapi_getprops(
3702
			$message,
3703
			[
3704
				PR_MESSAGE_CLASS,
3705
				$this->proptags['goid'],
3706
				$this->proptags['goid2'],
3707
				$this->proptags['startdate'],
3708
				$this->proptags['duedate'],
3709
				$this->proptags['recurring'],
3710
				$this->proptags['clipstart'],
3711
				$this->proptags['clipend'],
3712
				PR_RCVD_REPRESENTING_ENTRYID,
3713
				$this->proptags['basedate'],
3714
				PR_RCVD_REPRESENTING_NAME,
3715
			]
3716
		);
3717
3718
		if ($userStore === false) {
3719
			$userStore = $this->store;
3720
3721
			// check if delegate is processing the response
3722
			if (isset($messageProps[PR_RCVD_REPRESENTING_ENTRYID])) {
3723
				$delegatorStore = $this->getDelegatorStore($messageProps[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
3724
3725
				if (!empty($delegatorStore['store'])) {
3726
					$userStore = $delegatorStore['store'];
3727
				}
3728
				if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
3729
					$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
3730
				}
3731
			}
3732
		}
3733
3734
		if ($calFolder === false) {
3735
			$calFolder = $this->openDefaultCalendar($userStore);
3736
		}
3737
3738
		if ($calFolder) {
3739
			// Meeting request is recurring, so get all occurrence and check for each occurrence whether it conflicts with other appointments in Calendar.
3740
			if (isset($messageProps[$this->proptags['recurring']]) && $messageProps[$this->proptags['recurring']] === true) {
3741
				// Apply recurrence class and retrieve all occurrences(max: 30 occurrence because recurrence can also be set as 'no end date')
3742
				$recurr = new Recurrence($userStore, $message);
3743
				$items = $recurr->getItems($messageProps[$this->proptags['clipstart']], $messageProps[$this->proptags['clipend']] * (24 * 24 * 60), 30);
3744
3745
				foreach ($items as $item) {
3746
					// Get all items in the timeframe that we want to book, and get the goid and busystatus for each item
3747
					$calendarItems = $recurr->getCalendarItems($userStore, $calFolder, $item[$this->proptags['startdate']], $item[$this->proptags['duedate']], [$this->proptags['goid'], $this->proptags['busystatus']]);
3748
3749
					foreach ($calendarItems as $calendarItem) {
3750
						if ($calendarItem[$this->proptags['busystatus']] !== fbFree) {
3751
							/*
3752
							 * Only meeting requests have globalID, normal appointments do not have globalID
3753
							 * so if any normal appointment if found then it is assumed to be conflict.
3754
							 */
3755
							if (isset($calendarItem[$this->proptags['goid']])) {
3756
								if ($calendarItem[$this->proptags['goid']] !== $messageProps[$this->proptags['goid']]) {
3757
									++$noOfInstances;
3758
									break;
3759
								}
3760
							}
3761
							else {
3762
								++$noOfInstances;
3763
								break;
3764
							}
3765
						}
3766
					}
3767
				}
3768
3769
				if ($noOfInstances > 0) {
3770
					$returnValue = $noOfInstances;
3771
				}
3772
			}
3773
			else {
3774
				// Get all items in the timeframe that we want to book, and get the goid and busystatus for each item
3775
				$items = getCalendarItems($userStore, $calFolder, $messageProps[$this->proptags['startdate']], $messageProps[$this->proptags['duedate']], [$this->proptags['goid'], $this->proptags['busystatus']]);
3776
3777
				if (isset($messageProps[$this->proptags['basedate']]) && !empty($messageProps[$this->proptags['basedate']])) {
3778
					$basedate = $messageProps[$this->proptags['basedate']];
3779
					// Get the goid2 from recurring MR which further used to
3780
					// check the resource conflicts item.
3781
					$recurrItemProps = mapi_getprops($this->message, [$this->proptags['goid2']]);
3782
					$messageProps[$this->proptags['goid']] = $this->setBasedateInGlobalID($recurrItemProps[$this->proptags['goid2']], $basedate);
3783
					$messageProps[$this->proptags['goid2']] = $recurrItemProps[$this->proptags['goid2']];
3784
				}
3785
3786
				foreach ($items as $item) {
3787
					if ($item[$this->proptags['busystatus']] !== fbFree) {
3788
						if (isset($item[$this->proptags['goid']])) {
3789
							if (($item[$this->proptags['goid']] !== $messageProps[$this->proptags['goid']]) &&
3790
								($item[$this->proptags['goid']] !== $messageProps[$this->proptags['goid2']])) {
3791
								$returnValue = true;
3792
								break;
3793
							}
3794
						}
3795
						else {
3796
							$returnValue = true;
3797
							break;
3798
						}
3799
					}
3800
				}
3801
			}
3802
		}
3803
3804
		return $returnValue;
3805
	}
3806
3807
	/**
3808
	 * Function which adds organizer to recipient list which is passed.
3809
	 * This function also checks if it has organizer.
3810
	 *
3811
	 * @param array $messageProps message properties
3812
	 * @param array $recipients   recipients list of message
3813
	 */
3814
	public function addDelegator($messageProps, &$recipients): void {
3815
		$hasDelegator = false;
3816
		// Check if meeting already has an organizer.
3817
		foreach ($recipients as $key => $recipient) {
3818
			if (isset($messageProps[PR_RCVD_REPRESENTING_EMAIL_ADDRESS]) && $recipient[PR_EMAIL_ADDRESS] == $messageProps[PR_RCVD_REPRESENTING_EMAIL_ADDRESS]) {
3819
				$hasDelegator = true;
3820
			}
3821
		}
3822
3823
		if (!$hasDelegator) {
3824
			// Create delegator.
3825
			$delegator = [];
3826
			$delegator[PR_ENTRYID] = $messageProps[PR_RCVD_REPRESENTING_ENTRYID];
3827
			$delegator[PR_DISPLAY_NAME] = $messageProps[PR_RCVD_REPRESENTING_NAME];
3828
			$delegator[PR_EMAIL_ADDRESS] = $messageProps[PR_RCVD_REPRESENTING_EMAIL_ADDRESS];
3829
			$delegator[PR_RECIPIENT_TYPE] = MAPI_TO;
3830
			$delegator[PR_RECIPIENT_DISPLAY_NAME] = $messageProps[PR_RCVD_REPRESENTING_NAME];
3831
			$delegator[PR_ADDRTYPE] = empty($messageProps[PR_RCVD_REPRESENTING_ADDRTYPE]) ? 'SMTP' : $messageProps[PR_RCVD_REPRESENTING_ADDRTYPE];
3832
			$delegator[PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusNone;
3833
			$delegator[PR_RECIPIENT_FLAGS] = recipSendable;
3834
			$delegator[PR_SEARCH_KEY] = $messageProps[PR_RCVD_REPRESENTING_SEARCH_KEY];
3835
3836
			// Add organizer to recipients list.
3837
			array_unshift($recipients, $delegator);
3838
		}
3839
	}
3840
3841
	/**
3842
	 * Function will return delegator's store and calendar folder for processing meetings.
3843
	 *
3844
	 * @param string $receivedRepresentingEntryId entryid of the delegator user
3845
	 * @param array  $foldersToOpen               contains list of folder types that should be returned in result
3846
	 *
3847
	 * @return resource[] contains store of the delegator and resource of folders if $foldersToOpen is not empty
3848
	 *
3849
	 * @psalm-return array<resource>
3850
	 */
3851
	public function getDelegatorStore($receivedRepresentingEntryId, $foldersToOpen = []): array {
3852
		$returnData = [];
3853
3854
		$delegatorStore = $this->openCustomUserStore($receivedRepresentingEntryId);
3855
		$returnData['store'] = $delegatorStore;
3856
3857
		if (!empty($foldersToOpen)) {
3858
			for ($index = 0, $len = count($foldersToOpen); $index < $len; ++$index) {
3859
				$folderType = $foldersToOpen[$index];
3860
3861
				// first try with default folders
3862
				$folder = $this->openDefaultFolder($folderType, $delegatorStore);
3863
3864
				// if folder not found then try with base folders
3865
				if ($folder === false) {
3866
					$folder = $this->openBaseFolder($folderType, $delegatorStore);
3867
				}
3868
3869
				if ($folder === false) {
3870
					// we are still not able to get the folder so give up
3871
					continue;
3872
				}
3873
3874
				$returnData[$folderType] = $folder;
3875
			}
3876
		}
3877
3878
		return $returnData;
3879
	}
3880
3881
	/**
3882
	 * Function returns extra info about meeting timing along with message body
3883
	 * which will be included in body while sending meeting request/response.
3884
	 *
3885
	 * @return false|string $meetingTimeInfo info about meeting timing along with message body
3886
	 */
3887
	public function getMeetingTimeInfo() {
3888
		return $this->meetingTimeInfo;
3889
	}
3890
3891
	/**
3892
	 * Function sets extra info about meeting timing along with message body
3893
	 * which will be included in body while sending meeting request/response.
3894
	 *
3895
	 * @param string $meetingTimeInfo info about meeting timing along with message body
3896
	 */
3897
	public function setMeetingTimeInfo($meetingTimeInfo): void {
3898
		$this->meetingTimeInfo = $meetingTimeInfo;
3899
	}
3900
3901
	/**
3902
	 * Helper function which is use to get local categories of all occurrence.
3903
	 *
3904
	 * @param mixed $calendarItem meeting request item
3905
	 * @param mixed $store        store containing calendar folder
3906
	 * @param mixed $calFolder    calendar folder
3907
	 *
3908
	 * @return array $localCategories which contain array of basedate along with categories
3909
	 */
3910
	public function getLocalCategories($calendarItem, $store, $calFolder) {
3911
		$calendarItemProps = mapi_getprops($calendarItem);
3912
		$recurrence = new Recurrence($store, $calendarItem);
3913
3914
		// Retrieve all occurrences(max: 30 occurrence because recurrence can also be set as 'no end date')
3915
		$items = $recurrence->getItems($calendarItemProps[$this->proptags['clipstart']], $calendarItemProps[$this->proptags['clipend']] * (24 * 24 * 60), 30);
3916
		$localCategories = [];
3917
3918
		foreach ($items as $item) {
3919
			$recurrenceItems = $recurrence->getCalendarItems($store, $calFolder, $item[$this->proptags['startdate']], $item[$this->proptags['duedate']], [$this->proptags['goid'], $this->proptags['busystatus'], $this->proptags['categories']]);
3920
			foreach ($recurrenceItems as $recurrenceItem) {
3921
				// Check if occurrence is exception then get the local categories of that occurrence.
3922
				if (isset($recurrenceItem[$this->proptags['goid']]) && $recurrenceItem[$this->proptags['goid']] == $calendarItemProps[$this->proptags['goid']]) {
3923
					$exceptionAttach = $recurrence->getExceptionAttachment($recurrenceItem['basedate']);
3924
3925
					if ($exceptionAttach) {
3926
						$exception = mapi_attach_openobj($exceptionAttach, 0);
3927
						$exceptionProps = mapi_getprops($exception, [$this->proptags['categories']]);
3928
						if (isset($exceptionProps[$this->proptags['categories']])) {
3929
							$localCategories[$recurrenceItem['basedate']] = $exceptionProps[$this->proptags['categories']];
3930
						}
3931
					}
3932
				}
3933
			}
3934
		}
3935
3936
		return $localCategories;
3937
	}
3938
3939
	/**
3940
	 * Helper function which is use to apply local categories on respective occurrences.
3941
	 *
3942
	 * @param mixed $calendarItem    meeting request item
3943
	 * @param mixed $store           store containing calendar folder
3944
	 * @param array $localCategories array contains basedate and array of categories
3945
	 */
3946
	public function applyLocalCategories($calendarItem, $store, $localCategories): void {
3947
		$calendarItemProps = mapi_getprops($calendarItem, [PR_PARENT_ENTRYID, PR_ENTRYID]);
3948
		$message = mapi_msgstore_openentry($store, $calendarItemProps[PR_ENTRYID]);
3949
		$recurrence = new Recurrence($store, $message);
3950
3951
		// Check for all occurrence if it is exception then modify the exception by setting up categories,
3952
		// Otherwise create new exception with categories.
3953
		foreach ($localCategories as $key => $value) {
3954
			if ($recurrence->isException($key)) {
3955
				$recurrence->modifyException([$this->proptags['categories'] => $value], $key);
3956
			}
3957
			else {
3958
				$recurrence->createException([$this->proptags['categories'] => $value], $key, false);
3959
			}
3960
			mapi_savechanges($message);
3961
		}
3962
	}
3963
}
3964