Passed
Push — master ( ae800f...172061 )
by
unknown
02:27
created

Meetingrequest::checkCalendarWriteAccess()   B

Complexity

Conditions 8
Paths 20

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 8
eloc 18
c 2
b 1
f 0
nc 20
nop 1
dl 0
loc 31
rs 8.4444
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-2025 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 false|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(false|string $messageClass = false): bool {
223
		if ($messageClass === false) {
224
			$messageClass = mapi_getprops($this->message, [PR_MESSAGE_CLASS])[PR_MESSAGE_CLASS] ?? false;
225
		}
226
227
		return $messageClass !== false && stripos($messageClass, 'ipm.schedule.meeting.request') === 0;
228
	}
229
230
	/**
231
	 * Returns TRUE if the message pointed to is a returning meeting request response.
232
	 *
233
	 * @param false|string $messageClass message class to use for checking
234
	 *
235
	 * @return bool returns true if this is a meeting request else false
236
	 */
237
	public function isMeetingRequestResponse(false|string $messageClass = false): bool {
238
		if ($messageClass === false) {
239
			$messageClass = mapi_getprops($this->message, [PR_MESSAGE_CLASS])[PR_MESSAGE_CLASS] ?? false;
240
		}
241
242
		return $messageClass !== false && stripos($messageClass, 'ipm.schedule.meeting.resp') === 0;
243
	}
244
245
	/**
246
	 * Returns TRUE if the message pointed to is a cancellation request.
247
	 *
248
	 * @param false|string $messageClass message class to use for checking
249
	 *
250
	 * @return bool returns true if this is a meeting request else false
251
	 */
252
	public function isMeetingCancellation(false|string $messageClass = false): bool {
253
		if ($messageClass === false) {
254
			$messageClass = mapi_getprops($this->message, [PR_MESSAGE_CLASS])[PR_MESSAGE_CLASS] ?? false;
255
		}
256
257
		return $messageClass !== false && stripos($messageClass, 'ipm.schedule.meeting.canceled') === 0;
258
	}
259
260
	/**
261
	 * Function is used to get the last update counter of meeting request.
262
	 *
263
	 * @return false|int false when last_updatecounter not found else return last_updatecounter
264
	 */
265
	public function getLastUpdateCounter(): false|int {
266
		$calendarItemProps = mapi_getprops($this->message, [$this->proptags['last_updatecounter']]);
267
268
		return $calendarItemProps[$this->proptags['last_updatecounter']] ?? false;
269
	}
270
271
	/**
272
	 * Process an incoming meeting request response. This updates the appointment
273
	 * in your calendar to show whether the user has accepted or declined.
274
	 */
275
	public function processMeetingRequestResponse(): void {
276
		if (!$this->isMeetingRequestResponse()) {
277
			return;
278
		}
279
280
		if (!$this->isLocalOrganiser()) {
281
			return;
282
		}
283
284
		// Get information we need from the response message
285
		$messageprops = mapi_getprops($this->message, [
286
			$this->proptags['goid'],
287
			$this->proptags['goid2'],
288
			PR_OWNER_APPT_ID,
289
			PR_SENT_REPRESENTING_EMAIL_ADDRESS,
290
			PR_SENT_REPRESENTING_NAME,
291
			PR_SENT_REPRESENTING_ADDRTYPE,
292
			PR_SENT_REPRESENTING_ENTRYID,
293
			PR_SENT_REPRESENTING_SEARCH_KEY,
294
			PR_MESSAGE_DELIVERY_TIME,
295
			PR_MESSAGE_CLASS,
296
			PR_PROCESSED,
297
			PR_RCVD_REPRESENTING_ENTRYID,
298
			$this->proptags['proposed_start_whole'],
299
			$this->proptags['proposed_end_whole'],
300
			$this->proptags['proposed_duration'],
301
			$this->proptags['counter_proposal'],
302
			$this->proptags['attendee_critical_change'],
303
		]);
304
305
		$goid2 = $messageprops[$this->proptags['goid2']];
306
307
		if (!isset($goid2) || !isset($messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS])) {
308
			return;
309
		}
310
311
		// Find basedate in GlobalID(0x3), this can be a response for an occurrence
312
		$basedate = $this->getBasedateFromGlobalID($messageprops[$this->proptags['goid']]);
313
314
		$userStore = $this->store;
315
		// check if delegate is processing the response
316
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID])) {
317
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
318
			if (!empty($delegatorStore['store'])) {
319
				$userStore = $delegatorStore['store'];
320
			}
321
		}
322
323
		// check for calendar access
324
		$this->ensureCalendarWriteAccess($userStore);
325
326
		$calendarItem = $this->getCorrespondentCalendarItem(true);
327
328
		// Open the calendar items, and update all the recipients of the calendar item that match
329
		// the email address of the response.
330
		if ($calendarItem !== false) {
331
			$this->processResponse($userStore, $calendarItem, $basedate, $messageprops);
332
		}
333
	}
334
335
	/**
336
	 * Process every incoming MeetingRequest response.This updates the appointment
337
	 * in your calendar to show whether the user has accepted or declined.
338
	 *
339
	 * @param resource $store        contains the userStore in which the meeting is created
340
	 * @param mixed    $calendarItem resource of the calendar item for which this response has arrived
341
	 * @param mixed    $basedate     if present the create an exception
342
	 * @param array    $messageprops contains message properties
343
	 */
344
	public function processResponse(mixed $store, mixed $calendarItem, mixed $basedate, array $messageprops): ?false {
345
		$senderentryid = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
346
		$messageclass = $messageprops[PR_MESSAGE_CLASS];
347
		$deliverytime = $messageprops[PR_MESSAGE_DELIVERY_TIME];
348
		$recurringItem = 0;
349
350
		// Open the calendar item, find the sender in the recipient table and update all the recipients of the calendar item that match
351
		// the email address of the response.
352
		$calendarItemProps = mapi_getprops($calendarItem, [$this->proptags['recurring'], PR_STORE_ENTRYID, PR_PARENT_ENTRYID, PR_ENTRYID, $this->proptags['updatecounter']]);
353
354
		// check if meeting response is already processed
355
		if (isset($messageprops[PR_PROCESSED]) && $messageprops[PR_PROCESSED] === true) {
356
			// meeting is already processed
357
			return null;
358
		}
359
		mapi_setprops($this->message, [PR_PROCESSED => true]);
360
		mapi_savechanges($this->message);
361
362
		// if meeting is updated in organizer's calendar then we don't need to process
363
		// old response
364
		if ($this->isMeetingUpdated($basedate)) {
365
			return null;
366
		}
367
368
		// If basedate is found, then create/modify exception msg and do processing
369
		if ($basedate && !empty($calendarItemProps[$this->proptags['recurring']])) {
370
			$recurr = new Recurrence($store, $calendarItem);
371
372
			// Copy properties from meeting request
373
			$exception_props = mapi_getprops($this->message, [
374
				PR_OWNER_APPT_ID,
375
				$this->proptags['proposed_start_whole'],
376
				$this->proptags['proposed_end_whole'],
377
				$this->proptags['proposed_duration'],
378
				$this->proptags['counter_proposal'],
379
			]);
380
381
			// Create/modify exception
382
			if ($recurr->isException($basedate)) {
383
				$recurr->modifyException($exception_props, $basedate);
384
			}
385
			else {
386
				// When we are creating an exception we need copy recipients from main recurring item
387
				$recips = $this->getMessageRecipients($calendarItem);
388
389
				// Retrieve actual start/due dates from calendar item.
390
				$exception_props[$this->proptags['startdate']] = $recurr->getOccurrenceStart($basedate);
391
				$exception_props[$this->proptags['duedate']] = $recurr->getOccurrenceEnd($basedate);
392
393
				$recurr->createException($exception_props, $basedate, false, $recips);
394
			}
395
396
			mapi_savechanges($calendarItem);
397
398
			$attach = $recurr->getExceptionAttachment($basedate);
399
			if ($attach) {
400
				$recurringItem = $calendarItem;
401
				$calendarItem = mapi_attach_openobj($attach, MAPI_MODIFY);
402
			}
403
			else {
404
				return false;
405
			}
406
		}
407
408
		// Get the recipients of the calendar item
409
		$recipients = $this->getMessageRecipients($calendarItem);
410
411
		// FIXME we should look at the updatecounter property and compare it
412
		// to the counter in the recipient to see if this update is actually
413
		// newer than the status in the calendar item
414
		$found = false;
415
416
		$totalrecips = 0;
417
		$acceptedrecips = 0;
418
		foreach ($recipients as $recipient) {
419
			++$totalrecips;
420
			// external recipients might not have entryid
421
			if (!isset($recipient[PR_ENTRYID]) &&
422
				$recipient[PR_EMAIL_ADDRESS] == $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS]) {
423
				$recipient[PR_ENTRYID] = $senderentryid;
424
			}
425
			if (isset($recipient[PR_ENTRYID]) && $this->compareABEntryIDs($recipient[PR_ENTRYID], $senderentryid)) {
426
				$found = true;
427
428
				/*
429
				 * If value of attendee_critical_change on meeting response mail is less than PR_RECIPIENT_TRACKSTATUS_TIME
430
				 * on the corresponding recipientRow of meeting then we ignore this response mail.
431
				 */
432
				if (isset($recipient[PR_RECIPIENT_TRACKSTATUS_TIME]) && ($messageprops[$this->proptags['attendee_critical_change']] < $recipient[PR_RECIPIENT_TRACKSTATUS_TIME])) {
433
					continue;
434
				}
435
436
				// The email address matches, update the row
437
				$recipient[PR_RECIPIENT_TRACKSTATUS] = $this->getTrackStatus($messageclass);
438
				if (isset($messageprops[$this->proptags['attendee_critical_change']])) {
439
					$recipient[PR_RECIPIENT_TRACKSTATUS_TIME] = $messageprops[$this->proptags['attendee_critical_change']];
440
				}
441
442
				// If this is a counter proposal, set the proposal properties in the recipient row
443
				if (isset($messageprops[$this->proptags['counter_proposal']]) && $messageprops[$this->proptags['counter_proposal']]) {
444
					$recipient[PR_RECIPIENT_PROPOSEDSTARTTIME] = $messageprops[$this->proptags['proposed_start_whole']];
445
					$recipient[PR_RECIPIENT_PROPOSEDENDTIME] = $messageprops[$this->proptags['proposed_end_whole']];
446
					$recipient[PR_RECIPIENT_PROPOSED] = $messageprops[$this->proptags['counter_proposal']];
447
				}
448
449
				// Update the recipient information
450
				mapi_message_modifyrecipients($calendarItem, MODRECIP_REMOVE, [$recipient]);
451
				mapi_message_modifyrecipients($calendarItem, MODRECIP_ADD, [$recipient]);
452
			}
453
			if (isset($recipient[PR_RECIPIENT_TRACKSTATUS]) && $recipient[PR_RECIPIENT_TRACKSTATUS] == olRecipientTrackStatusAccepted) {
454
				++$acceptedrecips;
455
			}
456
		}
457
458
		// If the recipient was not found in the original calendar item,
459
		// then add the recpient as a new optional recipient
460
		if (!$found) {
461
			$recipient = [];
462
			$recipient[PR_ENTRYID] = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
463
			$recipient[PR_EMAIL_ADDRESS] = $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
464
			$recipient[PR_DISPLAY_NAME] = $messageprops[PR_SENT_REPRESENTING_NAME];
465
			$recipient[PR_ADDRTYPE] = $messageprops[PR_SENT_REPRESENTING_ADDRTYPE];
466
			$recipient[PR_RECIPIENT_TYPE] = MAPI_CC;
467
			$recipient[PR_SEARCH_KEY] = $messageprops[PR_SENT_REPRESENTING_SEARCH_KEY];
468
			$recipient[PR_RECIPIENT_TRACKSTATUS] = $this->getTrackStatus($messageclass);
469
			$recipient[PR_RECIPIENT_TRACKSTATUS_TIME] = $deliverytime;
470
471
			// If this is a counter proposal, set the proposal properties in the recipient row
472
			if (isset($messageprops[$this->proptags['counter_proposal']])) {
473
				$recipient[PR_RECIPIENT_PROPOSEDSTARTTIME] = $messageprops[$this->proptags['proposed_start_whole']];
474
				$recipient[PR_RECIPIENT_PROPOSEDENDTIME] = $messageprops[$this->proptags['proposed_end_whole']];
475
				$recipient[PR_RECIPIENT_PROPOSED] = $messageprops[$this->proptags['counter_proposal']];
476
			}
477
478
			mapi_message_modifyrecipients($calendarItem, MODRECIP_ADD, [$recipient]);
479
			++$totalrecips;
480
			if ($recipient[PR_RECIPIENT_TRACKSTATUS] == olRecipientTrackStatusAccepted) {
481
				++$acceptedrecips;
482
			}
483
		}
484
485
		// TODO: Update counter proposal number property on message
486
		/*
487
		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.
488
		*/
489
		// If this is a counter proposal, set the counter proposal indicator boolean
490
		if (isset($messageprops[$this->proptags['counter_proposal']])) {
491
			$props = [$this->proptags['counter_proposal'] => (bool) $messageprops[$this->proptags['counter_proposal']]];
492
			mapi_setprops($calendarItem, $props);
493
		}
494
495
		mapi_savechanges($calendarItem);
496
		if (isset($attach)) {
497
			mapi_savechanges($attach);
498
			mapi_savechanges($recurringItem);
0 ignored issues
show
Bug introduced by
It seems like $recurringItem can also be of type integer; however, parameter $any of mapi_savechanges() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

498
			mapi_savechanges(/** @scrutinizer ignore-type */ $recurringItem);
Loading history...
499
		}
500
501
		return null;
502
	}
503
504
	/**
505
	 * Process an incoming meeting request cancellation. This updates the
506
	 * appointment in your calendar to show that the meeting has been cancelled.
507
	 */
508
	public function processMeetingCancellation(): void {
509
		if (!$this->isMeetingCancellation()) {
510
			return;
511
		}
512
513
		if ($this->isLocalOrganiser()) {
514
			return;
515
		}
516
517
		if (!$this->isInCalendar()) {
518
			return;
519
		}
520
521
		$listProperties = $this->proptags;
522
		$listProperties['subject'] = PR_SUBJECT;
523
		$listProperties['sent_representing_name'] = PR_SENT_REPRESENTING_NAME;
524
		$listProperties['sent_representing_address_type'] = PR_SENT_REPRESENTING_ADDRTYPE;
525
		$listProperties['sent_representing_email_address'] = PR_SENT_REPRESENTING_EMAIL_ADDRESS;
526
		$listProperties['sent_representing_entryid'] = PR_SENT_REPRESENTING_ENTRYID;
527
		$listProperties['sent_representing_search_key'] = PR_SENT_REPRESENTING_SEARCH_KEY;
528
		$listProperties['rcvd_representing_name'] = PR_RCVD_REPRESENTING_NAME;
529
		$listProperties['rcvd_representing_address_type'] = PR_RCVD_REPRESENTING_ADDRTYPE;
530
		$listProperties['rcvd_representing_email_address'] = PR_RCVD_REPRESENTING_EMAIL_ADDRESS;
531
		$listProperties['rcvd_representing_entryid'] = PR_RCVD_REPRESENTING_ENTRYID;
532
		$listProperties['rcvd_representing_search_key'] = PR_RCVD_REPRESENTING_SEARCH_KEY;
533
		$messageProps = mapi_getprops($this->message, $listProperties);
534
535
		$goid = $messageProps[$this->proptags['goid']];	// GlobalID (0x3)
536
		if (!isset($goid)) {
537
			return;
538
		}
539
540
		$store = $this->store;
541
		// get delegator store, if delegate is processing this cancellation
542
		if (isset($messageProps[PR_RCVD_REPRESENTING_ENTRYID])) {
543
			$delegatorStore = $this->getDelegatorStore($messageProps[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
544
			if (!empty($delegatorStore['store'])) {
545
				$store = $delegatorStore['store'];
546
			}
547
		}
548
549
		// check for calendar access
550
		$this->ensureCalendarWriteAccess($store);
551
552
		$calendarItem = $this->getCorrespondentCalendarItem(true);
553
		$basedate = $this->getBasedateFromGlobalID($goid);
554
555
		if ($calendarItem !== false) {
556
			// if basedate is provided and we could not find the item then it could be that we are processing
557
			// an exception so get the exception and process it
558
			if ($basedate) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $basedate of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
559
				$calendarItemProps = mapi_getprops($calendarItem, [$this->proptags['recurring']]);
560
				if ($calendarItemProps[$this->proptags['recurring']] === true) {
561
					$recurr = new Recurrence($store, $calendarItem);
562
563
					// Set message class
564
					$messageProps[PR_MESSAGE_CLASS] = 'IPM.Appointment';
565
566
					if ($recurr->isException($basedate)) {
567
						$recurr->modifyException($messageProps, $basedate);
568
					}
569
					else {
570
						$recurr->createException($messageProps, $basedate);
571
					}
572
				}
573
			}
574
			else {
575
				// set the properties of the cancellation object
576
				mapi_setprops($calendarItem, $messageProps);
577
			}
578
579
			mapi_savechanges($calendarItem);
580
		}
581
	}
582
583
	/**
584
	 * Returns true if the corresponding calendar items exists in the celendar folder for this
585
	 * meeting request/response/cancellation.
586
	 */
587
	public function isInCalendar(): bool {
588
		// @TODO check for deleted exceptions
589
		return $this->getCorrespondentCalendarItem(false) !== false;
590
	}
591
592
	/**
593
	 * Accepts the meeting request by moving the item to the calendar
594
	 * and sending a confirmation message back to the sender. If $tentative
595
	 * is TRUE, then the item is accepted tentatively. After accepting, you
596
	 * can't use this class instance any more. The message is closed. If you
597
	 * specify TRUE for 'move', then the item is actually moved (from your
598
	 * inbox probably) to the calendar. If you don't, it is copied into
599
	 * your calendar.
600
	 *
601
	 * @param bool  $tentative            true if user as tentative accepted the meeting
602
	 * @param bool  $sendresponse         true if a response has to be sent to organizer
603
	 * @param bool  $move                 true if the meeting request should be moved to the deleted items after processing
604
	 * @param mixed $newProposedStartTime contains starttime if user has proposed other time
605
	 * @param mixed $newProposedEndTime   contains endtime if user has proposed other time
606
	 * @param mixed $basedate             start of day of occurrence for which user has accepted the recurrent meeting
607
	 * @param bool  $isImported           true to indicate that MR is imported from .ics or .vcs file else it false.
608
	 *
609
	 * @return bool|string $entryid entryid of item which created/updated in calendar
610
	 */
611
	public function doAccept(bool $tentative, bool $sendresponse, bool $move, mixed $newProposedStartTime = false, mixed $newProposedEndTime = false, mixed $body = false, mixed $userAction = false, mixed $store = false, mixed $basedate = false, bool $isImported = false): bool|string {
612
		if ($this->isLocalOrganiser()) {
613
			return false;
614
		}
615
616
		// Remove any previous calendar items with this goid and appt id
617
		$messageprops = mapi_getprops($this->message, [PR_ENTRYID, PR_PARENT_ENTRYID,
618
			PR_MESSAGE_CLASS, $this->proptags['goid'], $this->proptags['updatecounter'],
619
			PR_PROCESSED, PR_RCVD_REPRESENTING_ENTRYID, PR_SENDER_ENTRYID,
620
			PR_SENT_REPRESENTING_ENTRYID, PR_RECEIVED_BY_ENTRYID]);
621
622
		// do not process meeting requests in sent items folder
623
		$sentItemsEntryid = $this->getDefaultSentmailEntryID();
624
		if (isset($messageprops[PR_PARENT_ENTRYID]) &&
625
			$sentItemsEntryid !== false &&
626
			$sentItemsEntryid == $messageprops[PR_PARENT_ENTRYID]) {
627
			return false;
628
		}
629
630
		$calFolder = $this->openDefaultCalendar();
631
		$store = $this->store;
632
		// If this meeting request is received by a delegate then open delegator's store.
633
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID], $messageprops[PR_RECEIVED_BY_ENTRYID]) &&
634
			!compareEntryIds($messageprops[PR_RCVD_REPRESENTING_ENTRYID], $messageprops[PR_RECEIVED_BY_ENTRYID])) {
635
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
636
			if (!empty($delegatorStore['store'])) {
637
				$store = $delegatorStore['store'];
638
			}
639
			if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
640
				$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
641
			}
642
		}
643
644
		// check for calendar access
645
		$this->ensureCalendarWriteAccess($store);
646
647
		// if meeting is out dated then don't process it
648
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS]) && $this->isMeetingOutOfDate()) {
649
			return false;
650
		}
651
652
		/*
653
		 *	if this function is called automatically with meeting request object then there will be
654
		 *	two possibilitites
655
		 *	1) meeting request is opened first time, in this case make a tentative appointment in
656
		 *		recipient's calendar
657
		 *	2) after this every subsequent request to open meeting request will not do any processing
658
		 */
659
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS]) && $userAction === false) {
660
			if (isset($messageprops[PR_PROCESSED]) && $messageprops[PR_PROCESSED] === true) {
661
				// if meeting request is already processed then don't do anything
662
				return false;
663
			}
664
665
			// if correspondent calendar item is already processed then don't do anything
666
			$calendarItem = $this->getCorrespondentCalendarItem();
667
			if ($calendarItem) {
668
				$calendarItemProps = mapi_getprops($calendarItem, [PR_PROCESSED]);
669
				if (isset($calendarItemProps[PR_PROCESSED]) && $calendarItemProps[PR_PROCESSED] === true) {
670
					// mark meeting-request mail as processed as well
671
					mapi_setprops($this->message, [PR_PROCESSED => true]);
672
					mapi_savechanges($this->message);
673
674
					return false;
675
				}
676
			}
677
		}
678
679
		// Retrieve basedate from globalID, if it is not received as argument
680
		if (!$basedate) {
681
			$basedate = $this->getBasedateFromGlobalID($messageprops[$this->proptags['goid']]);
682
		}
683
684
		// set counter proposal properties in calendar item when proposing new time
685
		$proposeNewTimeProps = [];
686
		if ($newProposedStartTime && $newProposedEndTime) {
687
			$proposeNewTimeProps[$this->proptags['proposed_start_whole']] = $newProposedStartTime;
688
			$proposeNewTimeProps[$this->proptags['proposed_end_whole']] = $newProposedEndTime;
689
			$proposeNewTimeProps[$this->proptags['proposed_duration']] = round($newProposedEndTime - $newProposedStartTime) / 60;
690
			$proposeNewTimeProps[$this->proptags['counter_proposal']] = true;
691
		}
692
693
		// While sender is receiver then we have to process the meeting request as per the intended busy status
694
		// instead of tentative, and accept the same as per the intended busystatus.
695
		$senderEntryId = $messageprops[PR_SENT_REPRESENTING_ENTRYID] ?? $messageprops[PR_SENDER_ENTRYID];
696
		if (isset($messageprops[PR_RECEIVED_BY_ENTRYID]) && compareEntryIds($senderEntryId, $messageprops[PR_RECEIVED_BY_ENTRYID])) {
697
			$entryid = $this->accept(false, $sendresponse, $move, $proposeNewTimeProps, $body, true, $store, $calFolder, $basedate);
698
		}
699
		else {
700
			$entryid = $this->accept($tentative, $sendresponse, $move, $proposeNewTimeProps, $body, $userAction, $store, $calFolder, $basedate);
701
		}
702
703
		// if we have first time processed this meeting then set PR_PROCESSED property
704
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS]) && $userAction === false && $isImported === false) {
705
			if (!isset($messageprops[PR_PROCESSED]) || $messageprops[PR_PROCESSED] != true) {
706
				// set processed flag
707
				mapi_setprops($this->message, [PR_PROCESSED => true]);
708
				mapi_savechanges($this->message);
709
			}
710
		}
711
712
		return $entryid;
713
	}
714
715
	/**
716
	 * @param (float|mixed|true)[] $proposeNewTimeProps
717
	 * @param resource             $calFolder
718
	 *
719
	 * @psalm-param array<float|mixed|true> $proposeNewTimeProps
720
	 */
721
	public function accept(bool $tentative, bool $sendresponse, bool $move, array $proposeNewTimeProps, mixed $body, bool $userAction, mixed $store, mixed $calFolder, mixed $basedate = false): mixed {
722
		$messageprops = mapi_getprops($this->message);
723
		$isDelegate = $this->isMessageFromDelegate($messageprops);
724
		$entryid = '';
725
726
		if ($sendresponse) {
727
			$this->createResponse($tentative ? olResponseTentative : olResponseAccepted, $proposeNewTimeProps, $body, $store, $basedate, $calFolder);
728
		}
729
730
		/*
731
		 * Further processing depends on what user is receiving. User can receive recurring item, a single occurrence or a normal meeting.
732
		 * 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.
733
		 * 2) If single occurrence then find occurrence itself using globalID and if item is not found then use cleanGlobalID to find main recurring item
734
		 * 3) Normal meeting req are handled normally as they were handled previously.
735
		 *
736
		 * 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
737
		 * 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.
738
		 * If user is responding from calendar then item is opened and properties are set such as meetingstatus, responsestatus, busystatus etc.
739
		 */
740
		if ($this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS])) {
741
			// This meeting request item is recurring, so find all occurrences and saves them all as exceptions to this meeting request item.
742
			if (!empty($messageprops[$this->proptags['recurring']]) && $basedate === false) {
743
				$calendarItem = false;
744
745
				// Find main recurring item based on GlobalID (0x3)
746
				$items = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
747
				if (is_array($items)) {
748
					foreach ($items as $entryid) {
749
						$calendarItem = mapi_msgstore_openentry($store, $entryid);
750
					}
751
				}
752
753
				$processed = false;
754
				if (!$calendarItem) {
755
					// Recurring item not found, so create new meeting in Calendar
756
					$calendarItem = mapi_folder_createmessage($calFolder);
757
				}
758
				else {
759
					// we have found the main recurring item, check if this meeting request is already processed
760
					if (isset($messageprops[PR_PROCESSED]) && $messageprops[PR_PROCESSED] === true) {
761
						// only set required properties, other properties are already copied when processing this meeting request
762
						// for the first time
763
						$processed = true;
764
					}
765
					// While we applying updates of MR then all local categories will be removed,
766
					// So get the local categories of all occurrence before applying update from organiser.
767
					$localCategories = $this->getLocalCategories($calendarItem, $store, $calFolder);
768
				}
769
770
				if (!$processed) {
771
					// get all the properties and copy that to calendar item
772
					$props = mapi_getprops($this->message);
773
					// reset the PidLidMeetingType to Unspecified for outlook display the item
774
					$props[$this->proptags['meetingtype']] = mtgEmpty;
775
					// Correct reminder time (some clients like OL can generate wrong flagdueby time)
776
					$this->correctReminderTime($props);
777
				}
778
				else {
779
					// only get required properties so we will not overwrite existing updated properties from calendar
780
					$props = mapi_getprops($this->message, [PR_ENTRYID]);
781
				}
782
783
				$props[PR_MESSAGE_CLASS] = 'IPM.Appointment';
784
				// When meeting requests are generated by third-party solutions, we might be missing the updatecounter property.
785
				if (!isset($props[$this->proptags['updatecounter']])) {
786
					$props[$this->proptags['updatecounter']] = 0;
787
				}
788
				$props[$this->proptags['meetingstatus']] = olMeetingReceived;
789
				$props[$this->proptags['responsestatus']] = $this->determineResponseStatus($userAction, $tentative);
790
791
				$props[$this->proptags['busystatus']] = $this->calculateBusyStatus($tentative, $props);
792
793
				if ($userAction) {
794
					$this->setReplyTimeAndName($props);
795
				}
796
797
				mapi_setprops($calendarItem, $props);
798
799
				// we have already processed attachments and recipients, so no need to do it again
800
				if (!$processed) {
801
					// Copy attachments too
802
					$this->replaceAttachments($this->message, $calendarItem);
803
					// Copy recipients too
804
					$this->replaceRecipients($this->message, $calendarItem, $isDelegate);
805
				}
806
807
				// Find all occurrences based on CleanGlobalID (0x23)
808
				// there will be no exceptions left if $processed is true, but even if it doesn't hurt to recheck
809
				$items = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder, true);
810
				if (is_array($items)) {
811
					// Save all existing occurrence as exceptions
812
					foreach ($items as $entryid) {
813
						// Open occurrence
814
						$occurrenceItem = mapi_msgstore_openentry($store, $entryid);
815
816
						// Save occurrence into main recurring item as exception
817
						if ($occurrenceItem) {
818
							$occurrenceItemProps = mapi_getprops($occurrenceItem, [$this->proptags['goid'], $this->proptags['recurring']]);
819
820
							// Find basedate of occurrence item
821
							$basedate = $this->getBasedateFromGlobalID($occurrenceItemProps[$this->proptags['goid']]);
822
							if ($basedate && $occurrenceItemProps[$this->proptags['recurring']] != true) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $basedate of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
823
								$this->mergeException($calendarItem, $occurrenceItem, $basedate, $store);
824
							}
825
						}
826
					}
827
				}
828
829
				if (!isset($props[$this->proptags["recurring_pattern"]])) {
830
					$recurr = new Recurrence($store, $calendarItem);
831
					$recurr->saveRecurrencePattern();
832
				}
833
834
				mapi_savechanges($calendarItem);
835
836
				// After applying update of organiser all local categories of occurrence was removed,
837
				// So if local categories exist then apply it on respective occurrence.
838
				if (!empty($localCategories)) {
839
					$this->applyLocalCategories($calendarItem, $store, $localCategories);
840
				}
841
842
				if ($move) {
843
					// open wastebasket of currently logged in user and move the meeting request to it
844
					// for delegates this will be delegate's wastebasket folder
845
					$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
846
					$sourcefolder = $this->openParentFolder();
847
					mapi_folder_copymessages($sourcefolder, [$props[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
848
				}
849
850
				$entryid = $props[PR_ENTRYID];
851
			}
852
			else {
853
				/**
854
				 * This meeting request is not recurring, so can be an exception or normal meeting.
855
				 * If exception then find main recurring item and update exception
856
				 * If main recurring item is not found then put exception into Calendar as normal meeting.
857
				 */
858
				$calendarItem = false;
859
860
				// We found basedate in GlobalID of this meeting request, so this meeting request if for an occurrence.
861
				if ($basedate) {
862
					// Find main recurring item from CleanGlobalID of this meeting request
863
					$items = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
864
					if (is_array($items)) {
865
						foreach ($items as $entryid) {
866
							$calendarItem = mapi_msgstore_openentry($store, $entryid);
867
						}
868
					}
869
870
					// Main recurring item is found, so now update exception
871
					if ($calendarItem) {
872
						$this->acceptException($calendarItem, $this->message, $basedate, $move, $tentative, $userAction, $store, $isDelegate);
873
						$calendarItemProps = mapi_getprops($calendarItem, [PR_ENTRYID]);
874
						$entryid = $calendarItemProps[PR_ENTRYID];
875
					}
876
				}
877
878
				if (!$calendarItem) {
879
					$items = $this->findCalendarItems($messageprops[$this->proptags['goid']], $calFolder);
880
					if (is_array($items)) {
881
						// Get local categories before deleting MR.
882
						$message = mapi_msgstore_openentry($store, $items[0]);
883
						$localCategories = mapi_getprops($message, [$this->proptags['categories']]);
884
						mapi_folder_deletemessages($calFolder, $items);
885
					}
886
887
					if ($move) {
888
						// All we have to do is open the default calendar,
889
						// set the message class correctly to be an appointment item
890
						// and move it to the calendar folder
891
						$sourcefolder = $this->openParentFolder();
892
893
						// create a new calendar message, and copy the message to there,
894
						// since we want to delete (move to wastebasket) the original message
895
						$old_entryid = mapi_getprops($this->message, [PR_ENTRYID]);
896
						$calmsg = mapi_folder_createmessage($calFolder);
897
						mapi_copyto($this->message, [], [], $calmsg); /* includes attachments and recipients */
898
						// reset the PidLidMeetingType to Unspecified for outlook display the item
899
						$tmp_props = [];
900
						$tmp_props[$this->proptags['meetingtype']] = mtgEmpty;
901
						// OL needs this field always being set, or it will not display item
902
						$tmp_props[$this->proptags['recurring']] = false;
903
						mapi_setprops($calmsg, $tmp_props);
904
905
						// After creating new MR, If local categories exist then apply it on new MR.
906
						if (!empty($localCategories)) {
907
							mapi_setprops($calmsg, $localCategories);
908
						}
909
910
						$calItemProps = [];
911
						$calItemProps[PR_MESSAGE_CLASS] = 'IPM.Appointment';
912
913
						/*
914
						 * the client which has sent this meeting request can generate wrong flagdueby
915
						 * time (mainly OL), so regenerate that property so we will always show reminder
916
						 * on right time
917
						 */
918
						if (isset($messageprops[$this->proptags['reminderminutes']])) {
919
							$calItemProps[$this->proptags['flagdueby']] = $messageprops[$this->proptags['startdate']] - ($messageprops[$this->proptags['reminderminutes']] * 60);
920
						}
921
922
						$calItemProps[$this->proptags['busystatus']] = $this->calculateBusyStatus($tentative, $messageprops);
923
						if (isset($messageprops[$this->proptags['intendedbusystatus']])) {
924
							$calItemProps[$this->proptags['intendedbusystatus']] = $messageprops[$this->proptags['intendedbusystatus']];
925
						}
926
927
						$calItemProps[$this->proptags['responsestatus']] = $this->determineResponseStatus($userAction, $tentative);
928
						if ($userAction) {
929
							$this->setReplyTimeAndName($calItemProps);
930
						}
931
932
						$calItemProps[$this->proptags['recurring_pattern']] = '';
933
						$calItemProps[$this->proptags['alldayevent']] = $messageprops[$this->proptags['alldayevent']] ?? false;
934
						$calItemProps[$this->proptags['private']] = $messageprops[$this->proptags['private']] ?? false;
935
						$calItemProps[$this->proptags['meetingstatus']] = $messageprops[$this->proptags['meetingstatus']] ?? olMeetingReceived;
936
						if (isset($messageprops[$this->proptags['startdate']])) {
937
							$calItemProps[$this->proptags['commonstart']] = $calItemProps[$this->proptags['startdate']] = $messageprops[$this->proptags['startdate']];
938
						}
939
						if (isset($messageprops[$this->proptags['duedate']])) {
940
							$calItemProps[$this->proptags['commonend']] = $calItemProps[$this->proptags['duedate']] = $messageprops[$this->proptags['duedate']];
941
						}
942
943
						mapi_setprops($calmsg, $proposeNewTimeProps + $calItemProps);
944
945
						// get properties which stores owner information in meeting request mails
946
						$props = mapi_getprops($calmsg, [
947
							PR_SENT_REPRESENTING_ENTRYID,
948
							PR_SENT_REPRESENTING_NAME,
949
							PR_SENT_REPRESENTING_EMAIL_ADDRESS,
950
							PR_SENT_REPRESENTING_ADDRTYPE,
951
							PR_SENT_REPRESENTING_SEARCH_KEY,
952
							PR_SENT_REPRESENTING_SMTP_ADDRESS,
953
						]);
954
955
						// add owner to recipient table
956
						$recips = [];
957
						$this->addOrganizer($props, $recips);
958
						mapi_message_modifyrecipients($calmsg, MODRECIP_ADD, $recips);
959
						mapi_savechanges($calmsg);
960
961
						// Move the message to the wastebasket
962
						$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
963
						mapi_folder_copymessages($sourcefolder, [$old_entryid[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
964
965
						$messageprops = mapi_getprops($calmsg, [PR_ENTRYID]);
966
						$entryid = $messageprops[PR_ENTRYID];
967
					}
968
					else {
969
						// Create a new appointment with duplicate properties and recipient, but as an IPM.Appointment
970
						$new = mapi_folder_createmessage($calFolder);
971
						$props = mapi_getprops($this->message);
972
973
						$props[$this->proptags['recurring_pattern']] = '';
974
						$props[$this->proptags['alldayevent']] ??= false;
975
						$props[$this->proptags['private']] ??= false;
976
						$props[$this->proptags['meetingstatus']] ??= olMeetingReceived;
977
						if (isset($props[$this->proptags['startdate']])) {
978
							$props[$this->proptags['commonstart']] = $props[$this->proptags['startdate']];
979
						}
980
						if (isset($props[$this->proptags['duedate']])) {
981
							$props[$this->proptags['commonend']] = $props[$this->proptags['duedate']];
982
						}
983
984
						$props[PR_MESSAGE_CLASS] = 'IPM.Appointment';
985
						// reset the PidLidMeetingType to Unspecified for outlook display the item
986
						$props[$this->proptags['meetingtype']] = mtgEmpty;
987
						// OL needs this field always being set, or it will not display item
988
						$props[$this->proptags['recurring']] = false;
989
990
						// After creating new MR, If local categories exist then apply it on new MR.
991
						if (!empty($localCategories)) {
992
							mapi_setprops($new, $localCategories);
993
						}
994
995
						/*
996
						 * the client which has sent this meeting request can generate wrong flagdueby
997
						 * time (mainly OL), so regenerate that property so we will always show reminder
998
						 * on right time
999
						 */
1000
						$this->correctReminderTime($props);
1001
1002
						// When meeting requests are generated by third-party solutions, we might be missing the updatecounter property.
1003
						if (!isset($props[$this->proptags['updatecounter']])) {
1004
							$props[$this->proptags['updatecounter']] = 0;
1005
						}
1006
						$props[$this->proptags['responsestatus']] = $this->determineResponseStatus($userAction, $tentative);
1007
1008
						$props[$this->proptags['busystatus']] = $this->calculateBusyStatus($tentative, $props);
1009
1010
						if ($userAction) {
1011
							$this->setReplyTimeAndName($props);
1012
						}
1013
1014
						mapi_setprops($new, $proposeNewTimeProps + $props);
1015
1016
						// If delegate, then do not add the delegate in recipients
1017
						if ($isDelegate) {
1018
							$delegate = mapi_getprops($this->message, [PR_RECEIVED_BY_EMAIL_ADDRESS]);
1019
							$res = [
1020
								RES_PROPERTY,
1021
								[
1022
									RELOP => RELOP_NE,
1023
									ULPROPTAG => PR_EMAIL_ADDRESS,
1024
									VALUE => [PR_EMAIL_ADDRESS => $delegate[PR_RECEIVED_BY_EMAIL_ADDRESS]],
1025
								],
1026
							];
1027
							$recips = $this->getMessageRecipients($this->message, $res);
1028
						}
1029
						else {
1030
							$recips = $this->getMessageRecipients($this->message);
1031
						}
1032
1033
						$this->addOrganizer($props, $recips);
1034
						mapi_message_modifyrecipients($new, MODRECIP_ADD, $recips);
1035
						mapi_savechanges($new);
1036
1037
						$props = mapi_getprops($new, [PR_ENTRYID]);
1038
						$entryid = $props[PR_ENTRYID];
1039
					}
1040
				}
1041
			}
1042
		}
1043
		else {
1044
			// Here only properties are set on calendaritem, because user is responding from calendar.
1045
			$props = [];
1046
			$props[$this->proptags['responsestatus']] = $tentative ? olResponseTentative : olResponseAccepted;
1047
1048
			$props[$this->proptags['busystatus']] = $this->calculateBusyStatus($tentative, $messageprops);
1049
			if (isset($messageprops[$this->proptags['intendedbusystatus']])) {
1050
				$props[$this->proptags['intendedbusystatus']] = $messageprops[$this->proptags['intendedbusystatus']];
1051
			}
1052
1053
			$props[$this->proptags['meetingstatus']] = olMeetingReceived;
1054
1055
			$this->setReplyTimeAndName($props);
1056
1057
			if ($basedate) {
1058
				$recurr = new Recurrence($store, $this->message);
1059
1060
				// Copy recipients list
1061
				$reciptable = mapi_message_getrecipienttable($this->message);
1062
				$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
1063
1064
				if ($recurr->isException($basedate)) {
1065
					$recurr->modifyException($proposeNewTimeProps + $props, $basedate, $recips);
1066
				}
1067
				else {
1068
					$props[$this->proptags['startdate']] = $recurr->getOccurrenceStart($basedate);
1069
					$props[$this->proptags['duedate']] = $recurr->getOccurrenceEnd($basedate);
1070
1071
					$props[PR_SENT_REPRESENTING_EMAIL_ADDRESS] = $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
1072
					$props[PR_SENT_REPRESENTING_NAME] = $messageprops[PR_SENT_REPRESENTING_NAME];
1073
					$props[PR_SENT_REPRESENTING_ADDRTYPE] = $messageprops[PR_SENT_REPRESENTING_ADDRTYPE];
1074
					$props[PR_SENT_REPRESENTING_ENTRYID] = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
1075
					$props[PR_SENT_REPRESENTING_SEARCH_KEY] = $messageprops[PR_SENT_REPRESENTING_SEARCH_KEY];
1076
1077
					$recurr->createException($proposeNewTimeProps + $props, $basedate, false, $recips);
1078
				}
1079
			}
1080
			else {
1081
				mapi_setprops($this->message, $proposeNewTimeProps + $props);
1082
			}
1083
			mapi_savechanges($this->message);
1084
1085
			$entryid = $messageprops[PR_ENTRYID];
1086
		}
1087
1088
		return $entryid;
1089
	}
1090
1091
	/**
1092
	 * Declines the meeting request by moving the item to the deleted
1093
	 * items folder and sending a decline message. After declining, you
1094
	 * can't use this class instance any more. The message is closed.
1095
	 * When an occurrence is decline then false is returned because that
1096
	 * occurrence is deleted not the recurring item.
1097
	 *
1098
	 * @param bool  $sendresponse true if a response has to be sent to organizer
1099
	 * @param mixed $basedate     if specified contains starttime of day of an occurrence
1100
	 *
1101
	 * @return bool true if item is deleted from Calendar else false
1102
	 */
1103
	public function doDecline(bool $sendresponse, mixed $basedate = false, mixed $body = false): bool {
1104
		if ($this->isLocalOrganiser()) {
1105
			return false;
1106
		}
1107
1108
		$result = false;
1109
		$calendaritem = false;
1110
1111
		// Remove any previous calendar items with this goid and appt id
1112
		$messageprops = mapi_getprops($this->message, [$this->proptags['goid'], $this->proptags['goid2'], PR_RCVD_REPRESENTING_ENTRYID]);
1113
1114
		['store' => $store, 'calFolder' => $calFolder] = $this->resolveDelegateStoreAndCalendar($messageprops);
1115
1116
		// check for calendar access before deleting the calendar item
1117
		$this->ensureCalendarWriteAccess($store);
1118
1119
		$goid = $messageprops[$this->proptags['goid']];
1120
1121
		// First, find the items in the calendar by GlobalObjid (0x3)
1122
		$entryids = $this->findCalendarItems($goid, $calFolder);
1123
1124
		if (!$basedate) {
1125
			$basedate = $this->getBasedateFromGlobalID($goid);
1126
		}
1127
1128
		if ($sendresponse) {
1129
			$this->createResponse(olResponseDeclined, [], $body, $store, $basedate, $calFolder);
1130
		}
1131
1132
		if ($basedate) {
1133
			// use CleanGlobalObjid (0x23)
1134
			$calendaritems = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
1135
1136
			if (is_array($calendaritems)) {
1137
				foreach ($calendaritems as $entryid) {
1138
					// Open each calendar item and set the properties of the cancellation object
1139
					$calendaritem = mapi_msgstore_openentry($store, $entryid);
1140
1141
					// Recurring item is found, now delete exception
1142
					if ($calendaritem) {
1143
						$this->doRemoveExceptionFromCalendar($basedate, $calendaritem, $store);
1144
						$result = true;
1145
					}
1146
				}
1147
			}
1148
1149
			if ($this->isMeetingRequest()) {
1150
				$calendaritem = false;
1151
			}
1152
		}
1153
1154
		if (!$calendaritem) {
1155
			$calendar = $this->openDefaultCalendar($store);
1156
1157
			if (!empty($entryids)) {
1158
				mapi_folder_deletemessages($calendar, $entryids);
1159
			}
1160
1161
			// All we have to do to decline, is to move the item to the waste basket
1162
			$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1163
			$sourcefolder = $this->openParentFolder();
1164
1165
			$messageprops = mapi_getprops($this->message, [PR_ENTRYID]);
1166
1167
			// Release the message
1168
			$this->message = null;
1169
1170
			// Move the message to the waste basket
1171
			mapi_folder_copymessages($sourcefolder, [$messageprops[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1172
1173
			$result = true;
1174
		}
1175
1176
		return $result;
1177
	}
1178
1179
	/**
1180
	 * Removes a meeting request from the calendar when the user presses the
1181
	 * 'remove from calendar' button in response to a meeting cancellation.
1182
	 *
1183
	 * @param mixed $basedate if specified contains starttime of day of an occurrence
1184
	 */
1185
	public function doRemoveFromCalendar(mixed $basedate): ?false {
1186
		if ($this->isLocalOrganiser()) {
1187
			return false;
1188
		}
1189
1190
		$messageprops = mapi_getprops($this->message, [PR_ENTRYID, $this->proptags['goid'], PR_RCVD_REPRESENTING_ENTRYID, PR_MESSAGE_CLASS]);
1191
1192
		$goid = $messageprops[$this->proptags['goid']];
1193
1194
		$store = $this->store;
0 ignored issues
show
Unused Code introduced by
The assignment to $store is dead and can be removed.
Loading history...
1195
		['store' => $store, 'calFolder' => $calFolder] = $this->resolveDelegateStoreAndCalendar($messageprops);
1196
1197
		// check for calendar access before deleting the calendar item
1198
		$this->ensureCalendarWriteAccess($store);
1199
1200
		$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1201
		// get the source folder of the meeting message
1202
		$sourcefolder = $this->openParentFolder();
1203
1204
		// Check if the message is a meeting request in the inbox or a calendaritem by checking the message class
1205
		if ($this->isMeetingCancellation($messageprops[PR_MESSAGE_CLASS])) {
1206
			// get the basedate to check for exception
1207
			$basedate = $this->getBasedateFromGlobalID($goid);
1208
1209
			$calendarItem = $this->getCorrespondentCalendarItem(true);
1210
1211
			if ($calendarItem !== false) {
1212
				// basedate is provided so open exception
1213
				if ($basedate) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $basedate of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
1214
					$exception = $this->getExceptionItem($calendarItem, $basedate);
1215
1216
					if ($exception !== false) {
1217
						// exception found, remove it from calendar
1218
						$this->doRemoveExceptionFromCalendar($basedate, $calendarItem, $store);
1219
					}
1220
				}
1221
				else {
1222
					// remove normal / recurring series from calendar
1223
					$entryids = mapi_getprops($calendarItem, [PR_ENTRYID]);
1224
1225
					$entryids = [$entryids[PR_ENTRYID]];
1226
1227
					mapi_folder_copymessages($calFolder, $entryids, $wastebasket, MESSAGE_MOVE);
1228
				}
1229
			}
1230
1231
			// Release the message, because we are going to move it to wastebasket
1232
			$this->message = null;
1233
1234
			// Move the cancellation mail to wastebasket
1235
			mapi_folder_copymessages($sourcefolder, [$messageprops[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1236
		}
1237
		else {
1238
			// Here only properties are set on calendaritem, because user is responding from calendar.
1239
			if ($basedate) {
1240
				// remove the occurrence
1241
				$this->doRemoveExceptionFromCalendar($basedate, $this->message, $store);
1242
			}
1243
			else {
1244
				// remove normal/recurring meeting item.
1245
				// Move the message to the waste basket
1246
				mapi_folder_copymessages($sourcefolder, [$messageprops[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1247
			}
1248
		}
1249
1250
		return null;
1251
	}
1252
1253
	/**
1254
	 * Function can be used to cancel any existing meeting and send cancellation mails to attendees.
1255
	 * Should only be called from meeting object from calendar.
1256
	 *
1257
	 * @param mixed $basedate (optional) basedate of occurrence which should be cancelled
1258
	 *
1259
	 * @FIXME cancellation mail is also sent to attendee which has declined the meeting
1260
	 * @FIXME don't send canellation mail when cancelling meeting from past
1261
	 */
1262
	public function doCancelInvitation(false|int $basedate = false): void {
1263
		if (!$this->isLocalOrganiser()) {
1264
			return;
1265
		}
1266
1267
		// check write access for delegate
1268
		if ($this->checkCalendarWriteAccess($this->store) !== true) {
1269
			// Throw an exception that we don't have write permissions on calendar folder,
1270
			// error message will be filled by module
1271
			throw new MAPIException(_("Insufficient permissions"), MAPI_E_NO_ACCESS);
1272
		}
1273
1274
		$messageProps = mapi_getprops($this->message, [PR_ENTRYID, $this->proptags['recurring']]);
1275
1276
		if (!empty($messageProps[$this->proptags['recurring']])) {
1277
			// cancellation of recurring series or one occurrence
1278
			$recurrence = new Recurrence($this->store, $this->message);
1279
1280
			// if basedate is specified then we are cancelling only one occurrence, so create exception for that occurrence
1281
			if ($basedate) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $basedate of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
1282
				$recurrence->createException([], $basedate, true);
1283
			}
1284
1285
			// update the meeting request
1286
			$this->updateMeetingRequest();
1287
1288
			// send cancellation mails
1289
			$this->sendMeetingRequest(true, _('Canceled') . ': ', $basedate);
1290
1291
			// save changes in the message
1292
			mapi_savechanges($this->message);
1293
		}
1294
		else {
1295
			// cancellation of normal meeting request
1296
			// Send the cancellation
1297
			$this->updateMeetingRequest();
1298
			$this->sendMeetingRequest(true, _('Canceled') . ': ');
1299
1300
			// save changes in the message
1301
			mapi_savechanges($this->message);
1302
		}
1303
1304
		// if basedate is specified then we have already created exception of it so nothing should be done now
1305
		// but when cancelling normal / recurring meeting request we need to remove meeting from calendar
1306
		if ($basedate === false) {
1307
			// get the wastebasket folder, for delegate this will give wastebasket of delegate
1308
			$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
1309
1310
			// get the source folder of the meeting message
1311
			$sourcefolder = $this->openParentFolder();
1312
1313
			// Move the message to the deleted items
1314
			mapi_folder_copymessages($sourcefolder, [$messageProps[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
1315
		}
1316
	}
1317
1318
	/**
1319
	 * Convert epoch to MAPI FileTime, number of 100-nanosecond units since
1320
	 * the start of January 1, 1601.
1321
	 * https://msdn.microsoft.com/en-us/library/office/cc765906.aspx.
1322
	 *
1323
	 * @param int $epoch the current epoch
1324
	 *
1325
	 * @return int the MAPI FileTime equalevent to the given epoch time
1326
	 */
1327
	public function epochToMapiFileTime(int $epoch): int {
1328
		$nanoseconds_between_epoch = 116444736000000000;
1329
1330
		return ($epoch * 10000000) + $nanoseconds_between_epoch;
1331
	}
1332
1333
	/**
1334
	 * Sets the properties in the message so that is can be sent
1335
	 * as a meeting request. The caller has to submit the message. This
1336
	 * is only used for new MeetingRequests. Pass the appointment item as $message
1337
	 * in the constructor to do this.
1338
	 *
1339
	 * @param mixed $basedate
1340
	 */
1341
	public function setMeetingRequest(false|int $basedate = false): void {
1342
		$props = mapi_getprops($this->message, [$this->proptags['updatecounter']]);
1343
1344
		// Create a new global id for this item
1345
		// https://msdn.microsoft.com/en-us/library/ee160198(v=exchg.80).aspx
1346
		$goid = pack('H*', '040000008200E00074C5B7101A82E00800000000');
1347
		/*
1348
		$year = gmdate('Y');
1349
		$month = gmdate('n');
1350
		$day = gmdate('j');
1351
		$goid .= pack('n', $year);
1352
		$goid .= pack('C', $month);
1353
		$goid .= pack('C', $day);
1354
		*/
1355
		// Creation Time
1356
		$time = $this->epochToMapiFileTime(time());
1357
		$goid .= pack('V', $time & 0xFFFFFFFF);
1358
		$goid .= pack('V', $time >> 32);
1359
		// 8 Zeros
1360
		$goid .= pack('H*', '0000000000000000');
1361
		// Length of the random data
1362
		$goid .= pack('V', 16);
1363
		// Random data.
1364
		for ($i = 0; $i < 16; ++$i) {
1365
			$goid .= chr(random_int(0, 255));
1366
		}
1367
1368
		// Create a new appointment id for this item
1369
		$apptid = random_int(0, mt_getrandmax());
1370
1371
		$props[PR_OWNER_APPT_ID] = $apptid;
1372
		$props[PR_ICON_INDEX] = 1026;
1373
		$props[$this->proptags['goid']] = $goid;
1374
		$props[$this->proptags['goid2']] = $goid;
1375
1376
		if (!isset($props[$this->proptags['updatecounter']])) {
1377
			$props[$this->proptags['updatecounter']] = 0;			// OL also starts sequence no with zero.
1378
			$props[$this->proptags['last_updatecounter']] = 0;
1379
		}
1380
1381
		mapi_setprops($this->message, $props);
1382
	}
1383
1384
	/**
1385
	 * Sends a meeting request by copying it to the outbox, converting
1386
	 * the message class, adding some properties that are required only
1387
	 * for sending the message and submitting the message. Set cancel to
1388
	 * true if you wish to completely cancel the meeting request. You can
1389
	 * specify an optional 'prefix' to prefix the sent message, which is normally
1390
	 * 'Canceled: '.
1391
	 *
1392
	 * @return (int|mixed)[]|true
1393
	 *
1394
	 * @psalm-return array{error: 1|3|4, displayname: mixed}|true
1395
	 */
1396
	public function sendMeetingRequest(mixed $cancel, mixed $prefix = false, mixed $basedate = false, mixed $modifiedRecips = false, mixed $deletedRecips = false): array|true {
0 ignored issues
show
Bug introduced by
The type true was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1397
		$this->includesResources = false;
1398
		$this->nonAcceptingResources = [];
1399
1400
		// Get the properties of the message
1401
		$messageprops = mapi_getprops($this->message, [$this->proptags['recurring']]);
1402
1403
		/*
1404
		 * Submit message to non-resource recipients
1405
		 */
1406
		// Set BusyStatus to olTentative (1)
1407
		// Set MeetingStatus to olMeetingReceived
1408
		// Set ResponseStatus to olResponseNotResponded
1409
1410
		/*
1411
		 * While sending recurrence meeting exceptions are not sent as attachments
1412
		 * because first all exceptions are sent and then recurrence meeting is sent.
1413
		 */
1414
		if (isset($messageprops[$this->proptags['recurring']]) && $messageprops[$this->proptags['recurring']] && !$basedate) {
1415
			// Book resource
1416
			$this->bookResources($this->message, $cancel, $prefix);
1417
1418
			if (!$this->errorSetResource) {
1419
				$recurr = new Recurrence($this->openDefaultStore(), $this->message);
1420
1421
				// First send meetingrequest for recurring item
1422
				$this->submitMeetingRequest($this->message, $cancel, $prefix, false, $recurr, false, $modifiedRecips, $deletedRecips);
1423
1424
				// Then send all meeting request for all exceptions
1425
				$exceptions = $recurr->getAllExceptions();
1426
				if ($exceptions) {
1427
					foreach ($exceptions as $exceptionBasedate) {
1428
						$attach = $recurr->getExceptionAttachment($exceptionBasedate);
1429
1430
						if ($attach) {
1431
							$occurrenceItem = mapi_attach_openobj($attach, MAPI_MODIFY);
1432
							$this->submitMeetingRequest($occurrenceItem, $cancel, false, $exceptionBasedate, $recurr, false, $modifiedRecips, $deletedRecips);
0 ignored issues
show
Bug introduced by
$occurrenceItem of type resource is incompatible with the type resource expected by parameter $message of Meetingrequest::submitMeetingRequest(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1432
							$this->submitMeetingRequest(/** @scrutinizer ignore-type */ $occurrenceItem, $cancel, false, $exceptionBasedate, $recurr, false, $modifiedRecips, $deletedRecips);
Loading history...
1433
							mapi_savechanges($attach);
1434
						}
1435
					}
1436
				}
1437
			}
1438
		}
1439
		else {
1440
			// Basedate found, an exception is to be sent
1441
			if ($basedate) {
1442
				$recurr = new Recurrence($this->openDefaultStore(), $this->message);
1443
1444
				if ($cancel) {
1445
					// @TODO: remove occurrence from Resource's Calendar if resource was booked for whole series
1446
					$this->submitMeetingRequest($this->message, $cancel, $prefix, $basedate, $recurr, false);
1447
				}
1448
				else {
1449
					$attach = $recurr->getExceptionAttachment($basedate);
1450
1451
					if ($attach) {
1452
						$occurrenceItem = mapi_attach_openobj($attach, MAPI_MODIFY);
1453
1454
						// Book resource for this occurrence
1455
						$resourceRecipData = $this->bookResources($occurrenceItem, $cancel, $prefix, $basedate);
0 ignored issues
show
Bug introduced by
$occurrenceItem of type resource is incompatible with the type resource expected by parameter $message of Meetingrequest::bookResources(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1455
						$resourceRecipData = $this->bookResources(/** @scrutinizer ignore-type */ $occurrenceItem, $cancel, $prefix, $basedate);
Loading history...
Unused Code introduced by
The assignment to $resourceRecipData is dead and can be removed.
Loading history...
1456
1457
						if (!$this->errorSetResource) {
1458
							// Save all previous changes
1459
							mapi_savechanges($this->message);
1460
1461
							$this->submitMeetingRequest($occurrenceItem, $cancel, $prefix, $basedate, $recurr, true, $modifiedRecips, $deletedRecips);
1462
							mapi_savechanges($occurrenceItem);
1463
							mapi_savechanges($attach);
1464
						}
1465
					}
1466
				}
1467
			}
1468
			else {
1469
				// This is normal meeting
1470
				$resourceRecipData = $this->bookResources($this->message, $cancel, $prefix);
1471
1472
				if (!$this->errorSetResource) {
1473
					$this->submitMeetingRequest($this->message, $cancel, $prefix, false, false, false, $modifiedRecips, $deletedRecips);
1474
				}
1475
			}
1476
		}
1477
1478
		if (isset($this->errorSetResource) && $this->errorSetResource) {
1479
			return [
1480
				'error' => $this->errorSetResource,
1481
				'displayname' => $this->recipientDisplayname,
1482
			];
1483
		}
1484
1485
		return true;
0 ignored issues
show
Bug Best Practice introduced by
The expression return true returns the type true which is incompatible with the type-hinted return array|true.
Loading history...
1486
	}
1487
1488
	/**
1489
	 * Updates the message after an update has been performed (for example,
1490
	 * changing the time of the meeting). This must be called before re-sending
1491
	 * the meeting request. You can also call this function instead of 'setMeetingRequest()'
1492
	 * as it will automatically call setMeetingRequest on this object if it is the first
1493
	 * call to this function.
1494
	 *
1495
	 * @param mixed $basedate
1496
	 */
1497
	public function updateMeetingRequest(false|int $basedate = false): void {
1498
		$messageprops = mapi_getprops($this->message, [$this->proptags['last_updatecounter'], $this->proptags['goid']]);
1499
1500
		if (!isset($messageprops[$this->proptags['goid']])) {
1501
			$this->setMeetingRequest($basedate);
1502
		}
1503
		else {
1504
			$counter = ($messageprops[$this->proptags['last_updatecounter']] ?? 0) + 1;
1505
1506
			// increment value of last_updatecounter, last_updatecounter will be common for recurring series
1507
			// so even if you sending an exception only you need to update the last_updatecounter in the recurring series message
1508
			// this way we can make sure that every time we will be using a uniwue number for every operation
1509
			mapi_setprops($this->message, [$this->proptags['last_updatecounter'] => $counter]);
1510
		}
1511
	}
1512
1513
	/**
1514
	 * Returns TRUE if we are the organiser of the meeting. Can be used with any type of meeting object.
1515
	 */
1516
	public function isLocalOrganiser(): bool {
1517
		$props = mapi_getprops($this->message, [$this->proptags['goid'], PR_MESSAGE_CLASS]);
1518
1519
		// Determine which item to check based on message class
1520
		$messageClass = $props[PR_MESSAGE_CLASS] ?? '';
1521
		$isMeetingMessage = $this->isMeetingRequest($messageClass) ||
1522
							$this->isMeetingRequestResponse($messageClass) ||
1523
							$this->isMeetingCancellation($messageClass);
1524
1525
		$calendarItem = $isMeetingMessage ?
1526
			$this->getCorrespondentCalendarItem(true) : // Meeting request/response/cancellation mail
1527
			$this->message;  // Calendar item
1528
1529
		// Even if we have received request/response for exception/occurrence then also
1530
		// we can check recurring series for organizer, no need to check with exception/occurrence
1531
		if ($calendarItem === false) {
1532
			return false;
1533
		}
1534
1535
		$messageProps = mapi_getprops($calendarItem, [$this->proptags['responsestatus']]);
1536
1537
		return isset($messageProps[$this->proptags['responsestatus']]) &&
1538
			   $messageProps[$this->proptags['responsestatus']] === olResponseOrganized;
1539
	}
1540
1541
	/*
1542
	 * Support functions - INTERNAL ONLY
1543
	 ***************************************************************************************************
1544
	 */
1545
1546
	/**
1547
	 * Return the tracking status of a recipient based on the IPM class (passed).
1548
	 *
1549
	 * @return int tracking status constant
1550
	 */
1551
	public function getTrackStatus(string $class): int {
1552
		return match ($class) {
1553
			'IPM.Schedule.Meeting.Resp.Pos' => olRecipientTrackStatusAccepted,
1554
			'IPM.Schedule.Meeting.Resp.Tent' => olRecipientTrackStatusTentative,
1555
			'IPM.Schedule.Meeting.Resp.Neg' => olRecipientTrackStatusDeclined,
1556
			default => olRecipientTrackStatusNone,
1557
		};
1558
	}
1559
1560
	/**
1561
	 * Function returns MAPIFolder resource of the folder that currently holds this meeting/meeting request
1562
	 * object.
1563
	 */
1564
	public function openParentFolder(): mixed {
1565
		$messageprops = mapi_getprops($this->message, [PR_PARENT_ENTRYID]);
1566
1567
		return mapi_msgstore_openentry($this->store, $messageprops[PR_PARENT_ENTRYID]);
1568
	}
1569
1570
	/**
1571
	 * Function will return resource of the default calendar folder of store.
1572
	 *
1573
	 * @param mixed $store {optional} user store whose default calendar should be opened
1574
	 *
1575
	 * @return resource default calendar folder of store
1576
	 */
1577
	public function openDefaultCalendar(mixed $store = false): mixed {
1578
		return $this->openDefaultFolder(PR_IPM_APPOINTMENT_ENTRYID, $store);
1579
	}
1580
1581
	/**
1582
	 * Function will return resource of the default outbox folder of store.
1583
	 *
1584
	 * @param mixed $store {optional} user store whose default outbox should be opened
1585
	 *
1586
	 * @return resource default outbox folder of store
1587
	 */
1588
	public function openDefaultOutbox(mixed $store = false): mixed {
1589
		return $this->openBaseFolder(PR_IPM_OUTBOX_ENTRYID, $store);
1590
	}
1591
1592
	/**
1593
	 * Function will return resource of the default wastebasket folder of store.
1594
	 *
1595
	 * @param mixed $store {optional} user store whose default wastebasket should be opened
1596
	 *
1597
	 * @return resource default wastebasket folder of store
1598
	 */
1599
	public function openDefaultWastebasket(mixed $store = false): mixed {
1600
		return $this->openBaseFolder(PR_IPM_WASTEBASKET_ENTRYID, $store);
1601
	}
1602
1603
	/**
1604
	 * Function will return resource of the default calendar folder of store.
1605
	 *
1606
	 * @param mixed $store {optional} user store whose default calendar should be opened
1607
	 *
1608
	 * @return bool|string default calendar folder of store
1609
	 */
1610
	public function getDefaultWastebasketEntryID(mixed $store = false): bool|string {
1611
		return $this->getBaseEntryID(PR_IPM_WASTEBASKET_ENTRYID, $store);
1612
	}
1613
1614
	/**
1615
	 * Function will return resource of the default sent mail folder of store.
1616
	 *
1617
	 * @param mixed $store {optional} user store whose default sent mail should be opened
1618
	 *
1619
	 * @return bool|string default sent mail folder of store
1620
	 */
1621
	public function getDefaultSentmailEntryID(mixed $store = false): bool|string {
1622
		return $this->getBaseEntryID(PR_IPM_SENTMAIL_ENTRYID, $store);
1623
	}
1624
1625
	/**
1626
	 * Function will return entryid of any default folder of store. This method is useful when you want
1627
	 * to get entryid of folder which is stored as properties of inbox folder
1628
	 * (PR_IPM_APPOINTMENT_ENTRYID, PR_IPM_CONTACT_ENTRYID, PR_IPM_DRAFTS_ENTRYID, PR_IPM_JOURNAL_ENTRYID, PR_IPM_NOTE_ENTRYID, PR_IPM_TASK_ENTRYID).
1629
	 *
1630
	 * @param int   $prop  proptag of the folder for which we want to get entryid
1631
	 * @param mixed $store {optional} user store from which we need to get entryid of default folder
1632
	 *
1633
	 * @return bool|string entryid of folder pointed by $prop
1634
	 */
1635
	public function getDefaultFolderEntryID(int $prop, mixed $store = false): bool|string {
1636
		try {
1637
			$inbox = mapi_msgstore_getreceivefolder($store ?: $this->store);
1638
			$inboxprops = mapi_getprops($inbox, [$prop]);
1639
1640
			return $inboxprops[$prop] ?? false;
1641
		}
1642
		catch (MAPIException $e) {
1643
			// public store doesn't support this method
1644
			if ($e->getCode() == MAPI_E_NO_SUPPORT) {
1645
				// don't propagate this error to parent handlers, if store doesn't support it
1646
				$e->setHandled();
1647
			}
1648
		}
1649
1650
		return false;
1651
	}
1652
1653
	/**
1654
	 * Function will return resource of any default folder of store.
1655
	 *
1656
	 * @param int   $prop  proptag of the folder that we want to open
1657
	 * @param mixed $store {optional} user store from which we need to open default folder
1658
	 *
1659
	 * @return resource default folder of store
1660
	 */
1661
	public function openDefaultFolder(int $prop, mixed $store = false): mixed {
1662
		$entryid = $this->getDefaultFolderEntryID($prop, $store);
1663
1664
		return $entryid === false ? false : mapi_msgstore_openentry($store ?: $this->store, $entryid);
0 ignored issues
show
Bug introduced by
It seems like $entryid can also be of type true; however, parameter $entryid of mapi_msgstore_openentry() does only seem to accept null|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1664
		return $entryid === false ? false : mapi_msgstore_openentry($store ?: $this->store, /** @scrutinizer ignore-type */ $entryid);
Loading history...
Bug Best Practice introduced by
The expression return $entryid === fals...$this->store, $entryid) returns the type false|resource which is incompatible with the documented return type resource.
Loading history...
1665
	}
1666
1667
	/**
1668
	 * Function will return entryid of default folder from store. This method is useful when you want
1669
	 * to get entryid of folder which is stored as store properties
1670
	 * (PR_IPM_FAVORITES_ENTRYID, PR_IPM_OUTBOX_ENTRYID, PR_IPM_SENTMAIL_ENTRYID, PR_IPM_WASTEBASKET_ENTRYID).
1671
	 *
1672
	 * @param int   $prop  proptag of the folder whose entryid we want to get
1673
	 * @param mixed $store {optional} user store from which we need to get entryid of default folder
1674
	 *
1675
	 * @return bool|string entryid of default folder from store
1676
	 */
1677
	public function getBaseEntryID(int $prop, mixed $store = false): bool|string {
1678
		$storeprops = mapi_getprops($store ?: $this->store, [$prop]);
1679
1680
		return $storeprops[$prop] ?? false;
1681
	}
1682
1683
	/**
1684
	 * Function will return resource of any default folder of store.
1685
	 *
1686
	 * @param int   $prop  proptag of the folder that we want to open
1687
	 * @param mixed $store {optional} user store from which we need to open default folder
1688
	 *
1689
	 * @return resource default folder of store
1690
	 */
1691
	public function openBaseFolder(int $prop, mixed $store = false): mixed {
1692
		$entryid = $this->getBaseEntryID($prop, $store);
1693
1694
		return $entryid === false ? false : mapi_msgstore_openentry($store ?: $this->store, $entryid);
0 ignored issues
show
Bug introduced by
It seems like $entryid can also be of type true; however, parameter $entryid of mapi_msgstore_openentry() does only seem to accept null|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1694
		return $entryid === false ? false : mapi_msgstore_openentry($store ?: $this->store, /** @scrutinizer ignore-type */ $entryid);
Loading history...
Bug Best Practice introduced by
The expression return $entryid === fals...$this->store, $entryid) returns the type false|resource which is incompatible with the documented return type resource.
Loading history...
1695
	}
1696
1697
	/**
1698
	 * Function checks whether user has access over the specified folder or not.
1699
	 *
1700
	 * @param string $entryid entryid The entryid of the folder to check
1701
	 * @param mixed  $store   (optional) store from which folder should be opened
1702
	 *
1703
	 * @return bool true if user has an access over the folder, false if not
1704
	 */
1705
	public function checkFolderWriteAccess(string $entryid, mixed $store = false): bool {
1706
		if (empty($entryid)) {
1707
			return false;
1708
		}
1709
1710
		$store = $store ?: $this->store;
1711
1712
		try {
1713
			$folder = mapi_msgstore_openentry($store, $entryid);
1714
			$folderProps = mapi_getprops($folder, [PR_ACCESS]);
1715
1716
			return ($folderProps[PR_ACCESS] & MAPI_ACCESS_CREATE_CONTENTS) === MAPI_ACCESS_CREATE_CONTENTS;
1717
		}
1718
		catch (MAPIException $e) {
1719
			// We don't have rights to open folder, so return false
1720
			if ($e->getCode() == MAPI_E_NO_ACCESS) {
1721
				return false;
1722
			}
1723
1724
			// Rethrow other errors
1725
			throw $e;
1726
		}
1727
	}
1728
1729
	/**
1730
	 * Function checks whether user has access over the specified folder or not.
1731
	 *
1732
	 * @return bool true if user has an access over the folder, false if not
1733
	 */
1734
	public function checkCalendarWriteAccess(mixed $store = false): bool {
1735
		if ($store === false) {
1736
			$messageProps = mapi_getprops($this->message, [PR_RCVD_REPRESENTING_ENTRYID]);
1737
			$store = $this->store;
1738
			// If this meeting request is received by a delegate then open delegator's store.
1739
			if (isset($messageProps[PR_RCVD_REPRESENTING_ENTRYID])) {
1740
				$delegatorStore = $this->getDelegatorStore($messageProps[PR_RCVD_REPRESENTING_ENTRYID]);
1741
				if (!empty($delegatorStore['store'])) {
1742
					$store = $delegatorStore['store'];
1743
				}
1744
			}
1745
		}
1746
1747
		// If the store is a public folder, the calendar folder is the PARENT_ENTRYID of the calendar item
1748
		$provider = mapi_getprops($store, [PR_MDB_PROVIDER]);
1749
		if (isset($provider[PR_MDB_PROVIDER]) && $provider[PR_MDB_PROVIDER] === ZARAFA_STORE_PUBLIC_GUID) {
1750
			$entryid = mapi_getprops($this->message, [PR_PARENT_ENTRYID]);
1751
			$entryid = $entryid[PR_PARENT_ENTRYID];
1752
		}
1753
		else {
1754
			$entryid = $this->getDefaultFolderEntryID(PR_IPM_APPOINTMENT_ENTRYID, $store);
1755
			if ($entryid === false) {
1756
				$entryid = $this->getBaseEntryID(PR_IPM_APPOINTMENT_ENTRYID, $store);
1757
			}
1758
1759
			if ($entryid === false) {
1760
				return false;
1761
			}
1762
		}
1763
1764
		return $this->checkFolderWriteAccess($entryid, $store);
1765
	}
1766
1767
	/**
1768
	 * Function will resolve the user and open its store.
1769
	 *
1770
	 * @param string $ownerentryid the entryid of the user
1771
	 *
1772
	 * @return resource store of the user
1773
	 */
1774
	public function openCustomUserStore(string $ownerentryid): mixed {
1775
		$ab = mapi_openaddressbook($this->session);
1776
1777
		try {
1778
			$mailuser = mapi_ab_openentry($ab, $ownerentryid);
1779
			if (!$mailuser) {
0 ignored issues
show
introduced by
$mailuser is of type resource, thus it always evaluated to true.
Loading history...
1780
				error_log(sprintf("Unable to open ab entry: 0x%08X", mapi_last_hresult()));
1781
1782
				return null;
1783
			}
1784
		}
1785
		catch (MAPIException) {
1786
			return null;
1787
		}
1788
1789
		$mailuserprops = mapi_getprops($mailuser, [PR_EMAIL_ADDRESS]);
1790
		$storeid = mapi_msgstore_createentryid($this->store, $mailuserprops[PR_EMAIL_ADDRESS]);
1791
1792
		return mapi_openmsgstore($this->session, $storeid);
0 ignored issues
show
Bug Best Practice introduced by
The expression return mapi_openmsgstore...his->session, $storeid) returns the type resource which is incompatible with the documented return type resource.
Loading history...
1793
	}
1794
1795
	/**
1796
	 * Function which sends response to organizer when attendee accepts, declines or proposes new time to a received meeting request.
1797
	 *
1798
	 * @param int   $status              response status of attendee
1799
	 * @param array $proposeNewTimeProps properties of attendee's proposal
1800
	 * @param mixed $body
1801
	 * @param mixed $store
1802
	 * @param mixed $basedate            date of occurrence which attendee has responded
1803
	 * @param mixed $calFolder
1804
	 */
1805
	public function createResponse($status, $proposeNewTimeProps, $body, $store, $basedate, $calFolder): void {
1806
		$messageprops = mapi_getprops($this->message, [
1807
			PR_SENT_REPRESENTING_ENTRYID,
1808
			PR_SENT_REPRESENTING_EMAIL_ADDRESS,
1809
			PR_SENT_REPRESENTING_ADDRTYPE,
1810
			PR_SENT_REPRESENTING_NAME,
1811
			PR_SENT_REPRESENTING_SEARCH_KEY,
1812
			$this->proptags['goid'],
1813
			$this->proptags['goid2'],
1814
			$this->proptags['location'],
1815
			$this->proptags['startdate'],
1816
			$this->proptags['duedate'],
1817
			$this->proptags['recurring'],
1818
			$this->proptags['recurring_pattern'],
1819
			$this->proptags['recurrence_data'],
1820
			$this->proptags['timezone_data'],
1821
			$this->proptags['timezone'],
1822
			$this->proptags['updatecounter'],
1823
			PR_SUBJECT,
1824
			PR_MESSAGE_CLASS,
1825
			PR_OWNER_APPT_ID,
1826
			$this->proptags['is_exception'],
1827
		]);
1828
1829
		$props = [];
1830
1831
		if ($basedate !== false && !$this->isMeetingRequest($messageprops[PR_MESSAGE_CLASS])) {
1832
			// we are creating response from a recurring calendar item object
1833
			// We found basedate,so opened occurrence and get properties.
1834
			$recurr = new Recurrence($store, $this->message);
1835
			$exception = $recurr->getExceptionAttachment($basedate);
1836
1837
			if ($exception) {
1838
				// Exception found, Now retrieve properties
1839
				$imessage = mapi_attach_openobj($exception, 0);
1840
				$imsgprops = mapi_getprops($imessage);
1841
1842
				// If location is provided, copy it to the response
1843
				if (isset($imsgprops[$this->proptags['location']])) {
1844
					$messageprops[$this->proptags['location']] = $imsgprops[$this->proptags['location']];
1845
				}
1846
1847
				// Update $messageprops with timings of occurrence
1848
				$messageprops[$this->proptags['startdate']] = $imsgprops[$this->proptags['startdate']];
1849
				$messageprops[$this->proptags['duedate']] = $imsgprops[$this->proptags['duedate']];
1850
1851
				// Meeting related properties
1852
				$props[$this->proptags['meetingstatus']] = $imsgprops[$this->proptags['meetingstatus']];
1853
				$props[$this->proptags['responsestatus']] = $imsgprops[$this->proptags['responsestatus']];
1854
				$props[PR_SUBJECT] = $imsgprops[PR_SUBJECT];
1855
			}
1856
			else {
1857
				// Exceptions is deleted.
1858
				// Update $messageprops with timings of occurrence
1859
				$messageprops[$this->proptags['startdate']] = $recurr->getOccurrenceStart($basedate);
1860
				$messageprops[$this->proptags['duedate']] = $recurr->getOccurrenceEnd($basedate);
1861
1862
				$props[$this->proptags['meetingstatus']] = olNonMeeting;
1863
				$props[$this->proptags['responsestatus']] = olResponseNone;
1864
			}
1865
1866
			$props[$this->proptags['recurring']] = false;
1867
			$props[$this->proptags['is_exception']] = true;
1868
		}
1869
		else {
1870
			// we are creating a response from meeting request mail (it could be recurring or non-recurring)
1871
			// Send all recurrence info in response, if this is a recurrence meeting.
1872
			$isRecurring = !empty($messageprops[$this->proptags['recurring']]);
1873
			$isException = !empty($messageprops[$this->proptags['is_exception']]);
1874
			if ($isRecurring || $isException) {
1875
				if ($isRecurring) {
1876
					$props[$this->proptags['recurring']] = $messageprops[$this->proptags['recurring']];
1877
				}
1878
				if ($isException) {
1879
					$props[$this->proptags['is_exception']] = $messageprops[$this->proptags['is_exception']];
1880
				}
1881
				$calendaritems = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder);
1882
1883
				$calendaritem = mapi_msgstore_openentry($store, $calendaritems[0]);
1884
				$recurr = new Recurrence($store, $calendaritem);
1885
			}
1886
		}
1887
1888
		// we are sending a response for recurring meeting request (or exception), so set some required properties
1889
		if (isset($recurr) && $recurr) {
1890
			if (!empty($messageprops[$this->proptags['recurring_pattern']])) {
1891
				$props[$this->proptags['recurring_pattern']] = $messageprops[$this->proptags['recurring_pattern']];
1892
			}
1893
1894
			if (!empty($messageprops[$this->proptags['recurrence_data']])) {
1895
				$props[$this->proptags['recurrence_data']] = $messageprops[$this->proptags['recurrence_data']];
1896
			}
1897
1898
			$props[$this->proptags['timezone_data']] = $messageprops[$this->proptags['timezone_data']];
1899
			$props[$this->proptags['timezone']] = $messageprops[$this->proptags['timezone']];
1900
1901
			$this->generateRecurDates($recurr, $messageprops, $props);
1902
		}
1903
1904
		// Create a response message
1905
		$recip = [];
1906
		$recip[PR_ENTRYID] = $messageprops[PR_SENT_REPRESENTING_ENTRYID];
1907
		$recip[PR_EMAIL_ADDRESS] = $messageprops[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
1908
		$recip[PR_ADDRTYPE] = $messageprops[PR_SENT_REPRESENTING_ADDRTYPE];
1909
		$recip[PR_DISPLAY_NAME] = $messageprops[PR_SENT_REPRESENTING_NAME];
1910
		$recip[PR_RECIPIENT_TYPE] = MAPI_TO;
1911
		$recip[PR_SEARCH_KEY] = $messageprops[PR_SENT_REPRESENTING_SEARCH_KEY];
1912
1913
		$subjectprefix = '';
1914
		$classpostfix = '';
1915
1916
		switch ($status) {
1917
			case olResponseAccepted:
1918
				$classpostfix = 'Pos';
1919
				$subjectprefix = _('Accepted');
1920
				break;
1921
1922
			case olResponseDeclined:
1923
				$classpostfix = 'Neg';
1924
				$subjectprefix = _('Declined');
1925
				break;
1926
1927
			case olResponseTentative:
1928
				$classpostfix = 'Tent';
1929
				$subjectprefix = _('Tentatively accepted');
1930
				break;
1931
		}
1932
1933
		if (!empty($proposeNewTimeProps)) {
1934
			// if attendee has proposed new time then change subject prefix
1935
			$subjectprefix = _('New Time Proposed');
1936
		}
1937
1938
		$props[PR_SUBJECT] = $subjectprefix . ': ' . $messageprops[PR_SUBJECT];
1939
1940
		$props[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Resp.' . $classpostfix;
1941
		if (isset($messageprops[PR_OWNER_APPT_ID])) {
1942
			$props[PR_OWNER_APPT_ID] = $messageprops[PR_OWNER_APPT_ID];
1943
		}
1944
1945
		// Set GlobalId AND CleanGlobalId, if exception then also set basedate into GlobalId(0x3).
1946
		$props[$this->proptags['goid']] = $this->setBasedateInGlobalID(
1947
			$messageprops[$this->proptags['goid2']],
1948
			$basedate,
1949
			isset($recurr) && $recurr instanceof BaseRecurrence ? $recurr : null
1950
		);
1951
		$props[$this->proptags['goid2']] = $messageprops[$this->proptags['goid2']];
1952
		$props[$this->proptags['updatecounter']] = $messageprops[$this->proptags['updatecounter']] ?? 0;
1953
1954
		if (!empty($proposeNewTimeProps)) {
1955
			// merge proposal properties to message properties which will be sent to organizer
1956
			$props = $proposeNewTimeProps + $props;
1957
		}
1958
1959
		// Set body message in Appointment
1960
		if (isset($body)) {
1961
			$props[PR_BODY] = $this->getMeetingTimeInfo() ?: $body;
1962
		}
1963
1964
		// PR_START_DATE/PR_END_DATE is used in the UI in Outlook on the response message
1965
		$props[PR_START_DATE] = $messageprops[$this->proptags['startdate']];
1966
		$props[PR_END_DATE] = $messageprops[$this->proptags['duedate']];
1967
1968
		// Set startdate and duedate in response mail.
1969
		$props[$this->proptags['startdate']] = $messageprops[$this->proptags['startdate']];
1970
		$props[$this->proptags['duedate']] = $messageprops[$this->proptags['duedate']];
1971
1972
		// responselocation is used in the UI in Outlook on the response message
1973
		if (isset($messageprops[$this->proptags['location']])) {
1974
			$props[$this->proptags['responselocation']] = $messageprops[$this->proptags['location']];
1975
			$props[$this->proptags['location']] = $messageprops[$this->proptags['location']];
1976
		}
1977
1978
		$message = $this->createOutgoingMessage($store);
1979
1980
		mapi_setprops($message, $props);
1981
		mapi_message_modifyrecipients($message, MODRECIP_ADD, [$recip]);
1982
		mapi_savechanges($message);
1983
		mapi_message_submitmessage($message);
1984
	}
1985
1986
	/**
1987
	 * Function which finds items in calendar based on globalId and cleanGlobalId.
1988
	 *
1989
	 * @param string $goid             GlobalID(0x3) of item
1990
	 * @param mixed  $calendar         MAPI_folder of user (optional)
1991
	 * @param bool   $useCleanGlobalId if true then search should be performed on cleanGlobalId(0x23) else globalId(0x3)
1992
	 *
1993
	 * @return mixed
1994
	 */
1995
	public function findCalendarItems(string $goid, mixed $calendar = false, bool $useCleanGlobalId = false): ?array {
1996
		if ($calendar === false) {
1997
			// Open the Calendar
1998
			$calendar = $this->openDefaultCalendar();
1999
		}
2000
2001
		// Find the item by restricting all items to the correct ID
2002
		$restrict = [
2003
			RES_PROPERTY,
2004
			[
2005
				RELOP => RELOP_EQ,
2006
				ULPROPTAG => ($useCleanGlobalId === true ? $this->proptags['goid2'] : $this->proptags['goid']),
2007
				VALUE => $goid,
2008
			],
2009
		];
2010
2011
		$calendarcontents = mapi_folder_getcontentstable($calendar);
2012
2013
		$rows = mapi_table_queryallrows($calendarcontents, [PR_ENTRYID], $restrict);
2014
2015
		if (empty($rows)) {
2016
			return null;
2017
		}
2018
2019
		// In principle, there should only be one row, but we'll handle them all just in case
2020
		return array_column($rows, PR_ENTRYID);
2021
	}
2022
2023
	// Returns TRUE if both entryid's are equal. Equality is defined by both entryid's pointing at the
2024
	// same SMTP address when converted to SMTP
2025
	public function compareABEntryIDs(string $entryid1, string $entryid2): bool {
2026
		// If the session was not passed, just do a 'normal' compare.
2027
		if (!$this->session) {
2028
			return $entryid1 == $entryid2;
2029
		}
2030
2031
		return $this->getSMTPAddress($entryid1) == $this->getSMTPAddress($entryid2);
2032
	}
2033
2034
	// Gets the SMTP address of the passed addressbook entryid
2035
	public function getSMTPAddress(string $entryid): false|string {
2036
		if (!$this->session) {
2037
			return false;
2038
		}
2039
2040
		try {
2041
			$ab = mapi_openaddressbook($this->session);
2042
			$abitem = mapi_ab_openentry($ab, $entryid);
2043
2044
			if (!$abitem) {
0 ignored issues
show
introduced by
$abitem is of type resource, thus it always evaluated to true.
Loading history...
2045
				return '';
2046
			}
2047
		}
2048
		catch (MAPIException) {
2049
			return '';
2050
		}
2051
2052
		$props = mapi_getprops($abitem, [PR_ADDRTYPE, PR_EMAIL_ADDRESS, PR_SMTP_ADDRESS]);
2053
2054
		if ($props[PR_ADDRTYPE] == 'SMTP') {
2055
			return $props[PR_EMAIL_ADDRESS];
2056
		}
2057
2058
		return $props[PR_SMTP_ADDRESS];
2059
	}
2060
2061
	/**
2062
	 * Gets the properties associated with the owner of the passed store:
2063
	 * PR_DISPLAY_NAME, PR_EMAIL_ADDRESS, PR_ADDRTYPE, PR_ENTRYID, PR_SEARCH_KEY.
2064
	 *
2065
	 * @param mixed $store                  message store
2066
	 * @param bool  $fallbackToLoggedInUser If true then return properties of logged in user instead of mailbox owner.
2067
	 *                                      Not used when passed store is public store.
2068
	 *                                      For public store we are always returning logged in user's info.
2069
	 *
2070
	 * @return array|false properties of logged in user in an array in sequence of display_name, email address, address type, entryid and search key
2071
	 *
2072
	 * @psalm-return false|list{mixed, mixed, mixed, mixed, mixed}
2073
	 */
2074
	public function getOwnerAddress(mixed $store, bool $fallbackToLoggedInUser = true): array|false {
2075
		if (!$this->session) {
2076
			return false;
2077
		}
2078
2079
		$storeProps = mapi_getprops($store, [PR_MAILBOX_OWNER_ENTRYID, PR_USER_ENTRYID]);
2080
2081
		// Determine owner entry ID: use mailbox owner if not falling back, otherwise use user entry ID
2082
		$ownerEntryId = (!$fallbackToLoggedInUser && !empty($storeProps[PR_MAILBOX_OWNER_ENTRYID])) ?
2083
			$storeProps[PR_MAILBOX_OWNER_ENTRYID] :
2084
			($storeProps[PR_USER_ENTRYID] ?? false);
2085
2086
		if (!$ownerEntryId) {
2087
			return false;
2088
		}
2089
2090
		$ab = mapi_openaddressbook($this->session);
2091
2092
		$zarafaUser = mapi_ab_openentry($ab, $ownerEntryId);
2093
		if (!$zarafaUser) {
0 ignored issues
show
introduced by
$zarafaUser is of type resource, thus it always evaluated to true.
Loading history...
2094
			return false;
2095
		}
2096
2097
		$ownerProps = mapi_getprops($zarafaUser, [PR_ADDRTYPE, PR_DISPLAY_NAME, PR_EMAIL_ADDRESS, PR_SEARCH_KEY]);
2098
2099
		return [
2100
			$ownerProps[PR_DISPLAY_NAME],
2101
			$ownerProps[PR_EMAIL_ADDRESS],
2102
			$ownerProps[PR_ADDRTYPE],
2103
			$ownerEntryId,
2104
			$ownerProps[PR_SEARCH_KEY],
2105
		];
2106
	}
2107
2108
	// Opens this session's default message store
2109
	public function openDefaultStore(): mixed {
2110
		$entryid = '';
2111
2112
		$storestable = mapi_getmsgstorestable($this->session);
2113
		$rows = mapi_table_queryallrows($storestable, [PR_ENTRYID, PR_DEFAULT_STORE]);
2114
2115
		foreach ($rows as $row) {
2116
			if (isset($row[PR_DEFAULT_STORE]) && $row[PR_DEFAULT_STORE]) {
2117
				$entryid = $row[PR_ENTRYID];
2118
				break;
2119
			}
2120
		}
2121
2122
		if (!$entryid) {
2123
			return false;
2124
		}
2125
2126
		return mapi_openmsgstore($this->session, $entryid);
2127
	}
2128
2129
	/**
2130
	 * Function which adds organizer to recipient list which is passed.
2131
	 * This function also checks if it has organizer.
2132
	 *
2133
	 * @param array $messageProps message properties
2134
	 * @param array $recipients   recipients list of message
2135
	 * @param bool  $isException  true if we are processing recipient of exception
2136
	 */
2137
	public function addOrganizer(array $messageProps, array &$recipients, bool $isException = false): void {
2138
		$hasOrganizer = false;
2139
		// Check if meeting already has an organizer.
2140
		foreach ($recipients as $key => $recipient) {
2141
			if (isset($recipient[PR_RECIPIENT_FLAGS]) && $recipient[PR_RECIPIENT_FLAGS] == (recipSendable | recipOrganizer)) {
2142
				$hasOrganizer = true;
2143
			}
2144
			elseif ($isException && !isset($recipient[PR_RECIPIENT_FLAGS])) {
2145
				// Recipients for an occurrence
2146
				$recipients[$key][PR_RECIPIENT_FLAGS] = recipSendable | recipExceptionalResponse;
2147
			}
2148
		}
2149
2150
		if (!$hasOrganizer) {
2151
			// Create organizer.
2152
			$organizer = [];
2153
			$organizer[PR_ENTRYID] = $organizer[PR_RECIPIENT_ENTRYID] = $messageProps[PR_SENT_REPRESENTING_ENTRYID];
2154
			$organizer[PR_DISPLAY_NAME] = $messageProps[PR_SENT_REPRESENTING_NAME];
2155
			$organizer[PR_EMAIL_ADDRESS] = $messageProps[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
2156
			$organizer[PR_RECIPIENT_TYPE] = MAPI_TO;
2157
			$organizer[PR_RECIPIENT_DISPLAY_NAME] = $messageProps[PR_SENT_REPRESENTING_NAME];
2158
			$organizer[PR_ADDRTYPE] = empty($messageProps[PR_SENT_REPRESENTING_ADDRTYPE]) ? 'SMTP' : $messageProps[PR_SENT_REPRESENTING_ADDRTYPE];
2159
			$organizer[PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusNone;
2160
			$organizer[PR_RECIPIENT_FLAGS] = recipSendable | recipOrganizer;
2161
			$organizer[PR_SEARCH_KEY] = $messageProps[PR_SENT_REPRESENTING_SEARCH_KEY];
2162
			$organizer[PR_SMTP_ADDRESS] = $messageProps[PR_SENT_REPRESENTING_SMTP_ADDRESS] ?? $messageProps[PR_SENT_REPRESENTING_EMAIL_ADDRESS];
2163
2164
			// Add organizer to recipients list.
2165
			array_unshift($recipients, $organizer);
2166
		}
2167
	}
2168
2169
	/**
2170
	 * Function which removes an exception/occurrence from recurrencing meeting
2171
	 * when a meeting cancellation of an occurrence is processed.
2172
	 *
2173
	 * @param mixed    $basedate basedate of an occurrence
2174
	 * @param mixed    $message  recurring item from which occurrence has to be deleted
2175
	 * @param resource $store    MAPI_MSG_Store which contains the item
2176
	 */
2177
	public function doRemoveExceptionFromCalendar(mixed $basedate, mixed $message, mixed $store): void {
2178
		$recurr = new Recurrence($store, $message);
2179
		$recurr->createException([], $basedate, true);
2180
		mapi_savechanges($message);
2181
	}
2182
2183
	/**
2184
	 * Function which returns basedate of an changed occurrence from globalID of meeting request.
2185
	 *
2186
	 * @param string $goid globalID
2187
	 *
2188
	 * @return false|int true if basedate is found else false it not found
2189
	 */
2190
	public function getBasedateFromGlobalID(string $goid): false|int {
2191
		$hexguid = bin2hex($goid);
2192
		$hexbase = substr($hexguid, 32, 8);
2193
		$day = (int) hexdec(substr($hexbase, 6, 2));
2194
		$month = (int) hexdec(substr($hexbase, 4, 2));
2195
		$year = (int) hexdec(substr($hexbase, 0, 4));
2196
2197
		return ($day && $month && $year) ? gmmktime(0, 0, 0, $month, $day, $year) : false;
2198
	}
2199
2200
	/**
2201
	 * Function which sets basedate in globalID of changed occurrence which is to be sent.
2202
	 *
2203
	 * @param string              $goid       globalID
2204
	 * @param mixed               $basedate   of changed occurrence (UTC when $recurrence is provided)
2205
	 * @param null|BaseRecurrence $recurrence recurrence helper for timezone conversion
2206
	 *
2207
	 * @return false|string globalID with basedate in it
2208
	 */
2209
	public function setBasedateInGlobalID(string $goid, false|int $basedate = false, ?BaseRecurrence $recurrence = null): false|string {
2210
		$hexguid = bin2hex($goid);
2211
		$timestamp = $basedate;
2212
2213
		if ($basedate !== false && $recurrence instanceof BaseRecurrence && isset($recurrence->tz)) {
2214
			$timestamp = $recurrence->fromGMT($recurrence->tz, $basedate);
2215
		}
2216
2217
		$year = $timestamp !== false ? sprintf('%04s', dechex((int) gmdate('Y', $timestamp))) : '0000';
2218
		$month = $timestamp !== false ? sprintf('%02s', dechex((int) gmdate('m', $timestamp))) : '00';
2219
		$day = $timestamp !== false ? sprintf('%02s', dechex((int) gmdate('d', $timestamp))) : '00';
2220
2221
		return hex2bin(strtoupper(substr($hexguid, 0, 32) . $year . $month . $day . substr($hexguid, 40)));
2222
	}
2223
2224
	/**
2225
	 * Function which replaces attachments with copy_from in copy_to.
2226
	 *
2227
	 * @param mixed $copyFrom       MAPI_message from which attachments are to be copied
2228
	 * @param mixed $copyTo         MAPI_message to which attachment are to be copied
2229
	 * @param bool  $copyExceptions if true then all exceptions should also be sent as attachments
2230
	 */
2231
	public function replaceAttachments(mixed $copyFrom, mixed $copyTo, bool $copyExceptions = true): void {
2232
		/* remove all old attachments */
2233
		$attachmentTableTo = mapi_message_getattachmenttable($copyTo);
2234
		if ($attachmentTableTo) {
0 ignored issues
show
introduced by
$attachmentTableTo is of type resource, thus it always evaluated to true.
Loading history...
2235
			$attachments = mapi_table_queryallrows($attachmentTableTo, [PR_ATTACH_NUM, PR_ATTACH_METHOD, PR_EXCEPTION_STARTTIME]);
2236
2237
			foreach ($attachments as $attachProps) {
2238
				/* remove exceptions too? */
2239
				if (!$copyExceptions && $attachProps[PR_ATTACH_METHOD] == ATTACH_EMBEDDED_MSG && isset($attachProps[PR_EXCEPTION_STARTTIME])) {
2240
					continue;
2241
				}
2242
				mapi_message_deleteattach($copyTo, $attachProps[PR_ATTACH_NUM]);
2243
			}
2244
		}
2245
2246
		/* copy new attachments */
2247
		$attachmentTableFrom = mapi_message_getattachmenttable($copyFrom);
2248
		if ($attachmentTableFrom) {
0 ignored issues
show
introduced by
$attachmentTableFrom is of type resource, thus it always evaluated to true.
Loading history...
2249
			$attachments = mapi_table_queryallrows($attachmentTableFrom, [PR_ATTACH_NUM, PR_ATTACH_METHOD, PR_EXCEPTION_STARTTIME]);
2250
2251
			foreach ($attachments as $attachProps) {
2252
				if (!$copyExceptions && $attachProps[PR_ATTACH_METHOD] == ATTACH_EMBEDDED_MSG && isset($attachProps[PR_EXCEPTION_STARTTIME])) {
2253
					continue;
2254
				}
2255
2256
				$attachOld = mapi_message_openattach($copyFrom, (int) $attachProps[PR_ATTACH_NUM]);
2257
				$attachNewResourceMsg = mapi_message_createattach($copyTo);
2258
				mapi_copyto($attachOld, [], [], $attachNewResourceMsg, 0);
2259
				mapi_savechanges($attachNewResourceMsg);
2260
			}
2261
		}
2262
	}
2263
2264
	/**
2265
	 * Function which replaces recipients in copyTo with recipients from copyFrom.
2266
	 *
2267
	 * @param mixed $copyFrom   MAPI_message from which recipients are to be copied
2268
	 * @param mixed $copyTo     MAPI_message to which recipients are to be copied
2269
	 * @param bool  $isDelegate indicates whether delegate is processing
2270
	 *                          so don't copy delegate information to recipient table
2271
	 */
2272
	public function replaceRecipients(mixed $copyFrom, mixed $copyTo, bool $isDelegate = false): void {
2273
		// If delegate, then do not add the delegate in recipients
2274
		if ($isDelegate) {
2275
			$delegate = mapi_getprops($copyFrom, [PR_RECEIVED_BY_EMAIL_ADDRESS]);
2276
			$res = [
2277
				RES_PROPERTY,
2278
				[
2279
					RELOP => RELOP_NE,
2280
					ULPROPTAG => PR_EMAIL_ADDRESS,
2281
					VALUE => [PR_EMAIL_ADDRESS => $delegate[PR_RECEIVED_BY_EMAIL_ADDRESS]],
2282
				],
2283
			];
2284
			$recipients = $this->getMessageRecipients($copyFrom, $res);
2285
		}
2286
		else {
2287
			$recipients = $this->getMessageRecipients($copyFrom);
2288
		}
2289
2290
		$copyToRecipientTable = mapi_message_getrecipienttable($copyTo);
2291
		$copyToRecipientRows = mapi_table_queryallrows($copyToRecipientTable, [PR_ROWID]);
2292
2293
		mapi_message_modifyrecipients($copyTo, MODRECIP_REMOVE, $copyToRecipientRows);
2294
		mapi_message_modifyrecipients($copyTo, MODRECIP_ADD, $recipients);
2295
	}
2296
2297
	/**
2298
	 * Function creates meeting item in resource's calendar.
2299
	 *
2300
	 * @param resource $message  MAPI_message which is to create in resource's calendar
2301
	 * @param bool     $cancel   cancel meeting
2302
	 * @param mixed    $prefix   prefix for subject of meeting
2303
	 * @param mixed    $basedate
2304
	 *
2305
	 * @return (mixed|resource)[][]
2306
	 *
2307
	 * @psalm-return list<array{store: resource, folder: mixed, msg: mixed}>
2308
	 */
2309
	public function bookResources($message, $cancel, $prefix, $basedate = false): array {
2310
		if (!$this->enableDirectBooking) {
2311
			return [];
2312
		}
2313
2314
		// Get the properties of the message
2315
		$messageprops = mapi_getprops($message);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $any of mapi_getprops(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2315
		$messageprops = mapi_getprops(/** @scrutinizer ignore-type */ $message);
Loading history...
2316
2317
		$calFolder = '';
2318
2319
		if ($basedate) {
2320
			$recurrItemProps = mapi_getprops($this->message, [$this->proptags['goid'], $this->proptags['goid2'], $this->proptags['timezone_data'], $this->proptags['timezone'], PR_OWNER_APPT_ID]);
2321
2322
			$recurrenceHelper = new Recurrence($this->openDefaultStore(), $this->message);
2323
			$basedateUtc = $basedate;
2324
			if ($recurrenceHelper instanceof BaseRecurrence && isset($recurrenceHelper->tz)) {
2325
				$basedateUtc = $recurrenceHelper->toGMT($recurrenceHelper->tz, $basedate);
2326
			}
2327
2328
			$messageprops[$this->proptags['goid']] = $this->setBasedateInGlobalID($recurrItemProps[$this->proptags['goid']], $basedateUtc, $recurrenceHelper);
2329
			$messageprops[$this->proptags['goid2']] = $recurrItemProps[$this->proptags['goid2']];
2330
2331
			// Delete properties which are not needed.
2332
			$deleteProps = [$this->proptags['basedate'], PR_DISPLAY_NAME, PR_ATTACHMENT_FLAGS, PR_ATTACHMENT_HIDDEN, PR_ATTACHMENT_LINKID, PR_ATTACH_FLAGS, PR_ATTACH_METHOD];
2333
			foreach ($deleteProps as $propID) {
2334
				if (isset($messageprops[$propID])) {
2335
					unset($messageprops[$propID]);
2336
				}
2337
			}
2338
2339
			if (isset($messageprops[$this->proptags['recurring']])) {
2340
				$messageprops[$this->proptags['recurring']] = false;
2341
			}
2342
2343
			// Set Outlook properties
2344
			$messageprops[$this->proptags['clipstart']] = $messageprops[$this->proptags['startdate']];
2345
			$messageprops[$this->proptags['clipend']] = $messageprops[$this->proptags['duedate']];
2346
			$messageprops[$this->proptags['timezone_data']] = $recurrItemProps[$this->proptags['timezone_data']];
2347
			$messageprops[$this->proptags['timezone']] = $recurrItemProps[$this->proptags['timezone']];
2348
			$messageprops[$this->proptags['attendee_critical_change']] = time();
2349
			$messageprops[$this->proptags['owner_critical_change']] = time();
2350
		}
2351
2352
		// Get resource recipients
2353
		$getResourcesRestriction = [
2354
			RES_PROPERTY,
2355
			[
2356
				RELOP => RELOP_EQ,	// Equals recipient type 3: Resource
2357
				ULPROPTAG => PR_RECIPIENT_TYPE,
2358
				VALUE => [PR_RECIPIENT_TYPE => MAPI_BCC],
2359
			],
2360
		];
2361
		$resourceRecipients = $this->getMessageRecipients($message, $getResourcesRestriction);
2362
2363
		$this->errorSetResource = false;
2364
		$resourceRecipData = [];
2365
2366
		// Put appointment into store resource users
2367
		$i = 0;
2368
		$len = count($resourceRecipients);
2369
		while (!$this->errorSetResource && $i < $len) {
2370
			$userStore = $this->openCustomUserStore($resourceRecipients[$i][PR_ENTRYID]);
2371
2372
			// Open root folder
2373
			$userRoot = mapi_msgstore_openentry($userStore);
2374
2375
			// Get calendar entryID
2376
			$userRootProps = mapi_getprops($userRoot, [PR_STORE_ENTRYID, PR_IPM_APPOINTMENT_ENTRYID, PR_FREEBUSY_ENTRYIDS]);
2377
2378
			// Open Calendar folder
2379
			$accessToFolder = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $accessToFolder is dead and can be removed.
Loading history...
2380
2381
			try {
2382
				// @FIXME this checks delegate has access to resource's calendar folder
2383
				// but it should use boss' credentials
2384
2385
				$accessToFolder = $this->checkCalendarWriteAccess($this->store);
2386
				if ($accessToFolder) {
2387
					$calFolder = mapi_msgstore_openentry($userStore, $userRootProps[PR_IPM_APPOINTMENT_ENTRYID]);
2388
				}
2389
			}
2390
			catch (MAPIException $e) {
2391
				$e->setHandled();
2392
				$this->errorSetResource = 1; // No access
2393
			}
2394
2395
			if ($accessToFolder) {
2396
				/**
2397
				 * Get the LocalFreebusy message that contains the properties that
2398
				 * are set to accept or decline resource meeting requests.
2399
				 */
2400
				$localFreebusyMsg = FreeBusy::getLocalFreeBusyMessage($userStore);
2401
				if ($localFreebusyMsg) {
2402
					$props = mapi_getprops($localFreebusyMsg, [PR_SCHDINFO_AUTO_ACCEPT_APPTS, PR_SCHDINFO_DISALLOW_RECURRING_APPTS, PR_SCHDINFO_DISALLOW_OVERLAPPING_APPTS]);
2403
2404
					$acceptMeetingRequests = $props[PR_SCHDINFO_AUTO_ACCEPT_APPTS] ?? false;
2405
					$declineRecurringMeetingRequests = $props[PR_SCHDINFO_DISALLOW_RECURRING_APPTS] ?? false;
2406
					$declineConflictingMeetingRequests = $props[PR_SCHDINFO_DISALLOW_OVERLAPPING_APPTS] ?? false;
2407
2408
					if (!$acceptMeetingRequests) {
2409
						/*
2410
						 * When a resource has not been set to automatically accept meeting requests,
2411
						 * the meeting request has to be sent to him rather than being put directly into
2412
						 * his calendar. No error should be returned.
2413
						 */
2414
						// $errorSetResource = 2;
2415
						$this->nonAcceptingResources[] = $resourceRecipients[$i];
2416
					}
2417
					else {
2418
						if ($declineRecurringMeetingRequests && !$cancel) {
2419
							// Check if appointment is recurring
2420
							if ($messageprops[$this->proptags['recurring']]) {
2421
								$this->errorSetResource = 3;
2422
							}
2423
						}
2424
						if ($declineConflictingMeetingRequests && !$cancel) {
2425
							// Check for conflicting items
2426
							if ($calFolder && $this->isMeetingConflicting($message, $userStore, $calFolder)) {
2427
								$this->errorSetResource = 4; // Conflict
2428
							}
2429
						}
2430
					}
2431
				}
2432
			}
2433
2434
			if (!$this->errorSetResource && $accessToFolder) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->errorSetResource of type false|integer is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
2435
				/**
2436
				 * First search on GlobalID(0x3)
2437
				 * 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.
2438
				 * If (normal meeting) then GlobalID(0x3) and CleanGlobalID(0x23) are same, so doesn't matter if search is based on GlobalID.
2439
				 */
2440
				$rows = $this->findCalendarItems($messageprops[$this->proptags['goid']], $calFolder);
2441
2442
				/*
2443
				 * If no entry is found then
2444
				 * 1) Resource doesn't have meeting in Calendar. Seriously!!
2445
				 * OR
2446
				 * 2) We were looking for occurrence item but Resource has whole series
2447
				 */
2448
				if (empty($rows)) {
2449
					/**
2450
					 * Now search on CleanGlobalID(0x23) WHY???
2451
					 * Because we are looking recurring item.
2452
					 *
2453
					 * Possible results of this search
2454
					 * 1) If Resource was booked for more than one occurrences then this search will return all those occurrence because search is perform on CleanGlobalID
2455
					 * 2) If Resource was booked for whole series then it should return series.
2456
					 */
2457
					$rows = $this->findCalendarItems($messageprops[$this->proptags['goid2']], $calFolder, true);
2458
2459
					$newResourceMsg = false;
2460
					if (!empty($rows)) {
2461
						// Since we are looking for recurring item, open every result and check for 'recurring' property.
2462
						foreach ($rows as $row) {
2463
							$ResourceMsg = mapi_msgstore_openentry($userStore, $row);
2464
							$ResourceMsgProps = mapi_getprops($ResourceMsg, [$this->proptags['recurring']]);
2465
2466
							if (!empty($ResourceMsgProps[$this->proptags['recurring']])) {
2467
								$newResourceMsg = $ResourceMsg;
2468
								break;
2469
							}
2470
						}
2471
					}
2472
2473
					// Still no results found. I giveup, create new message.
2474
					if (!$newResourceMsg) {
2475
						$newResourceMsg = mapi_folder_createmessage($calFolder);
0 ignored issues
show
Bug introduced by
It seems like $calFolder can also be of type string; however, parameter $fld of mapi_folder_createmessage() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2475
						$newResourceMsg = mapi_folder_createmessage(/** @scrutinizer ignore-type */ $calFolder);
Loading history...
2476
					}
2477
				}
2478
				else {
2479
					$newResourceMsg = mapi_msgstore_openentry($userStore, $rows[0]);
2480
				}
2481
2482
				// Prefix the subject if needed
2483
				if ($prefix && isset($messageprops[PR_SUBJECT])) {
2484
					$messageprops[PR_SUBJECT] = $prefix . $messageprops[PR_SUBJECT];
2485
				}
2486
2487
				// Set status to cancelled if needed
2488
				$messageprops[$this->proptags['busystatus']] = fbBusy; // The default status (Busy)
2489
				if ($cancel) {
2490
					$messageprops[$this->proptags['meetingstatus']] = olMeetingCanceled; // The meeting has been canceled
2491
					$messageprops[$this->proptags['busystatus']] = fbFree; // Free
2492
				}
2493
				else {
2494
					$messageprops[$this->proptags['meetingstatus']] = olMeetingReceived; // The recipient is receiving the request
2495
				}
2496
				$messageprops[$this->proptags['responsestatus']] = olResponseAccepted; // The resource automatically accepts the appointment
2497
2498
				$messageprops[PR_MESSAGE_CLASS] = 'IPM.Appointment';
2499
2500
				// Remove the PR_ICON_INDEX as it is not needed in the sent message.
2501
				$messageprops[PR_ICON_INDEX] = null;
2502
				$messageprops[PR_RESPONSE_REQUESTED] = true;
2503
2504
				// get the store of organizer, in case of delegates it will be delegate store
2505
				$defaultStore = $this->openDefaultStore();
2506
2507
				$storeProps = mapi_getprops($this->store, [PR_ENTRYID]);
2508
				$defaultStoreProps = mapi_getprops($defaultStore, [PR_ENTRYID]);
2509
2510
				if (!compareEntryIds($storeProps[PR_ENTRYID], $defaultStoreProps[PR_ENTRYID])) {
2511
					// get delegate information
2512
					$addrInfo = $this->getOwnerAddress($defaultStore, false);
2513
					$this->setAddressProperties($messageprops, $addrInfo, 'SENDER');
0 ignored issues
show
Bug introduced by
It seems like $addrInfo can also be of type false; however, parameter $addrInfo of Meetingrequest::setAddressProperties() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2513
					$this->setAddressProperties($messageprops, /** @scrutinizer ignore-type */ $addrInfo, 'SENDER');
Loading history...
2514
2515
					// get delegator information
2516
					$addrInfo = $this->getOwnerAddress($this->store, false);
2517
					$this->setAddressProperties($messageprops, $addrInfo, 'SENT_REPRESENTING');
2518
				}
2519
				else {
2520
					// get organizer information
2521
					$addrInfo = $this->getOwnerAddress($this->store);
2522
					$this->setAddressProperties($messageprops, $addrInfo, 'SENDER');
2523
					$this->setAddressProperties($messageprops, $addrInfo, 'SENT_REPRESENTING');
2524
				}
2525
2526
				$messageprops[$this->proptags['replytime']] = time();
2527
2528
				if ($basedate && isset($ResourceMsgProps[$this->proptags['recurring']]) && $ResourceMsgProps[$this->proptags['recurring']]) {
2529
					$recurr = new Recurrence($userStore, $newResourceMsg);
2530
2531
					// Copy recipients list
2532
					$reciptable = mapi_message_getrecipienttable($message);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $msg of mapi_message_getrecipienttable(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2532
					$reciptable = mapi_message_getrecipienttable(/** @scrutinizer ignore-type */ $message);
Loading history...
2533
					$recips = mapi_table_queryallrows($reciptable, $this->recipprops);
2534
2535
					// add owner to recipient table
2536
					$this->addOrganizer($messageprops, $recips, true);
2537
2538
					// Update occurrence
2539
					if ($recurr->isException($basedate)) {
2540
						$recurr->modifyException($messageprops, $basedate, $recips);
2541
					}
2542
					else {
2543
						$recurr->createException($messageprops, $basedate, false, $recips);
2544
					}
2545
				}
2546
				else {
2547
					mapi_setprops($newResourceMsg, $messageprops);
2548
2549
					// Copy attachments
2550
					$this->replaceAttachments($message, $newResourceMsg);
2551
2552
					// Copy all recipients too
2553
					$this->replaceRecipients($message, $newResourceMsg);
2554
2555
					// Now add organizer also to recipient table
2556
					$recips = [];
2557
					$this->addOrganizer($messageprops, $recips);
2558
2559
					mapi_message_modifyrecipients($newResourceMsg, MODRECIP_ADD, $recips);
2560
				}
2561
2562
				mapi_savechanges($newResourceMsg);
2563
2564
				$resourceRecipData[] = [
2565
					'store' => $userStore,
2566
					'folder' => $calFolder,
2567
					'msg' => $newResourceMsg,
2568
				];
2569
				$this->includesResources = true;
2570
			}
2571
			else {
2572
				/*
2573
				 * If no other errors occurred and you have no access to the
2574
				 * folder of the resource, throw an error=1.
2575
				 */
2576
				if (!$this->errorSetResource) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->errorSetResource of type false|integer is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
2577
					$this->errorSetResource = 1;
2578
				}
2579
2580
				for ($j = 0, $len = count($resourceRecipData); $j < $len; ++$j) {
2581
					// Get the EntryID
2582
					$props = mapi_message_getprops($resourceRecipData[$j]['msg']);
0 ignored issues
show
Bug introduced by
The function mapi_message_getprops was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

2582
					$props = /** @scrutinizer ignore-call */ mapi_message_getprops($resourceRecipData[$j]['msg']);
Loading history...
2583
2584
					mapi_folder_deletemessages($resourceRecipData[$j]['folder'], [$props[PR_ENTRYID]], DELETE_HARD_DELETE);
2585
				}
2586
				$this->recipientDisplayname = $resourceRecipients[$i][PR_DISPLAY_NAME];
2587
			}
2588
			++$i;
2589
		}
2590
2591
		$resourceRecipients = $this->getMessageRecipients($message);
2592
		if (!empty($resourceRecipients)) {
2593
			// Set Tracking status of resource recipients to olResponseAccepted (3)
2594
			for ($i = 0, $len = count($resourceRecipients); $i < $len; ++$i) {
2595
				if (isset($resourceRecipients[$i][PR_RECIPIENT_TYPE]) && $resourceRecipients[$i][PR_RECIPIENT_TYPE] == MAPI_BCC) {
2596
					$resourceRecipients[$i][PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusAccepted;
2597
					$resourceRecipients[$i][PR_RECIPIENT_TRACKSTATUS_TIME] = time();
2598
				}
2599
			}
2600
			mapi_message_modifyrecipients($message, MODRECIP_MODIFY, $resourceRecipients);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $msg of mapi_message_modifyrecipients(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2600
			mapi_message_modifyrecipients(/** @scrutinizer ignore-type */ $message, MODRECIP_MODIFY, $resourceRecipients);
Loading history...
2601
		}
2602
2603
		return $resourceRecipData;
2604
	}
2605
2606
	/**
2607
	 * Function which save an exception into recurring item.
2608
	 *
2609
	 * @param resource $recurringItem  reference to MAPI_message of recurring item
2610
	 * @param resource $occurrenceItem reference to MAPI_message of occurrence
2611
	 * @param string   $basedate       basedate of occurrence
2612
	 * @param bool     $move           if true then occurrence item is deleted
2613
	 * @param bool     $tentative      true if user has tentatively accepted it or false if user has accepted it
2614
	 * @param bool     $userAction     true if user has manually responded to meeting request
2615
	 * @param resource $store          user store
2616
	 * @param bool     $isDelegate     true if delegate is processing this meeting request
2617
	 */
2618
	public function acceptException(mixed &$recurringItem, mixed &$occurrenceItem, mixed $basedate, bool $move, bool $tentative, bool $userAction, mixed $store, bool $isDelegate = false): void {
2619
		$recurr = new Recurrence($store, $recurringItem);
2620
2621
		// Copy properties from meeting request
2622
		$exception_props = mapi_getprops($occurrenceItem);
2623
2624
		// Copy recipients list
2625
		// If delegate, then do not add the delegate in recipients
2626
		if ($isDelegate) {
2627
			$delegate = mapi_getprops($this->message, [PR_RECEIVED_BY_EMAIL_ADDRESS]);
2628
			$res = [
2629
				RES_PROPERTY,
2630
				[
2631
					RELOP => RELOP_NE,
2632
					ULPROPTAG => PR_EMAIL_ADDRESS,
2633
					VALUE => [PR_EMAIL_ADDRESS => $delegate[PR_RECEIVED_BY_EMAIL_ADDRESS]],
2634
				],
2635
			];
2636
			$recips = $this->getMessageRecipients($occurrenceItem, $res);
2637
		}
2638
		else {
2639
			$recips = $this->getMessageRecipients($occurrenceItem);
2640
		}
2641
2642
		// add owner to recipient table
2643
		$this->addOrganizer($exception_props, $recips, true);
2644
2645
		// add delegator to meetings
2646
		if ($isDelegate) {
2647
			$this->addDelegator($exception_props, $recips);
2648
		}
2649
2650
		$exception_props[$this->proptags['meetingstatus']] = olMeetingReceived;
2651
		$exception_props[$this->proptags['responsestatus']] = $this->determineResponseStatus($userAction, $tentative);
2652
2653
		if (isset($exception_props[$this->proptags['intendedbusystatus']])) {
2654
			if ($tentative && $exception_props[$this->proptags['intendedbusystatus']] !== fbFree) {
2655
				$exception_props[$this->proptags['busystatus']] = fbTentative;
2656
			}
2657
			else {
2658
				$exception_props[$this->proptags['busystatus']] = $exception_props[$this->proptags['intendedbusystatus']];
2659
			}
2660
			// we already have intendedbusystatus value in $exception_props so no need to copy it
2661
		}
2662
		else {
2663
			$exception_props[$this->proptags['busystatus']] = $tentative ? fbTentative : fbBusy;
2664
		}
2665
2666
		if ($userAction) {
2667
			$this->setReplyTimeAndName($exception_props);
2668
		}
2669
2670
		// In some cases the exception subject is not in the property list,
2671
		// so it's necessary to fetch it.
2672
		if (!isset($exception_props[PR_SUBJECT])) {
2673
			$exSubject = mapi_getprops($occurrenceItem, [PR_SUBJECT]);
2674
			if (!empty($exSubject[PR_SUBJECT])) {
2675
				$exception_props[PR_SUBJECT] = $exSubject[PR_SUBJECT];
2676
			}
2677
		}
2678
		if ($recurr->isException($basedate)) {
2679
			$recurr->modifyException($exception_props, $basedate, $recips, $occurrenceItem);
2680
		}
2681
		else {
2682
			$recurr->createException($exception_props, $basedate, false, $recips, $occurrenceItem);
2683
		}
2684
2685
		// Move the occurrenceItem to the waste basket
2686
		if ($move) {
2687
			$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
2688
			$sourcefolder = mapi_msgstore_openentry($store, $exception_props[PR_PARENT_ENTRYID]);
2689
			mapi_folder_copymessages($sourcefolder, [$exception_props[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
2690
		}
2691
2692
		mapi_savechanges($recurringItem);
2693
	}
2694
2695
	/**
2696
	 * Function which merges an exception mapi message to recurring message.
2697
	 * This will be used when we receive recurring meeting request and we already have an exception message
2698
	 * of same meeting in calendar and we need to remove that exception message and add it to attachment table
2699
	 * of recurring meeting.
2700
	 *
2701
	 * @param resource $recurringItem  reference to MAPI_message of recurring item
2702
	 * @param resource $occurrenceItem reference to MAPI_message of occurrence
2703
	 * @param mixed    $basedate       basedate of occurrence
2704
	 * @param resource $store          user store
2705
	 */
2706
	public function mergeException(mixed &$recurringItem, mixed &$occurrenceItem, mixed $basedate, mixed $store): void {
2707
		$recurr = new Recurrence($store, $recurringItem);
2708
2709
		// Copy properties from meeting request
2710
		$exception_props = mapi_getprops($occurrenceItem);
2711
2712
		// Get recipient list from message and add it to exception attachment
2713
		$recips = $this->getMessageRecipients($occurrenceItem);
2714
2715
		if ($recurr->isException($basedate)) {
2716
			$recurr->modifyException($exception_props, $basedate, $recips, $occurrenceItem);
2717
		}
2718
		else {
2719
			$recurr->createException($exception_props, $basedate, false, $recips, $occurrenceItem);
2720
		}
2721
2722
		// Move the occurrenceItem to the waste basket
2723
		$wastebasket = $this->openDefaultWastebasket($this->openDefaultStore());
2724
		$sourcefolder = mapi_msgstore_openentry($store, $exception_props[PR_PARENT_ENTRYID]);
2725
		mapi_folder_copymessages($sourcefolder, [$exception_props[PR_ENTRYID]], $wastebasket, MESSAGE_MOVE);
2726
2727
		mapi_savechanges($recurringItem);
2728
	}
2729
2730
	/**
2731
	 * Function which submits meeting request based on arguments passed to it.
2732
	 *
2733
	 * @param resource $message        MAPI_message whose meeting request is to be sent
2734
	 * @param bool     $cancel         if true send request, else send cancellation
2735
	 * @param mixed    $prefix         subject prefix
2736
	 * @param mixed    $basedate       basedate for an occurrence
2737
	 * @param mixed    $recurObject    recurrence object of mr
2738
	 * @param bool     $copyExceptions When sending update mail for recurring item then we don't send exceptions in attachments
2739
	 * @param mixed    $modifiedRecips
2740
	 * @param mixed    $deletedRecips
2741
	 */
2742
	public function submitMeetingRequest($message, $cancel, $prefix, $basedate = false, $recurObject = false, $copyExceptions = true, $modifiedRecips = false, $deletedRecips = false): void {
2743
		$newmessageprops = $messageprops = mapi_getprops($this->message);
2744
		$new = $this->createOutgoingMessage();
2745
2746
		// Copy the entire message into the new meeting request message
2747
		if ($basedate) {
2748
			// messageprops contains properties of whole recurring series
2749
			// and newmessageprops contains properties of exception item
2750
			$newmessageprops = mapi_getprops($message);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $any of mapi_getprops(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2750
			$newmessageprops = mapi_getprops(/** @scrutinizer ignore-type */ $message);
Loading history...
2751
2752
			$basedateUtc = $basedate;
2753
			if ($recurObject instanceof BaseRecurrence && isset($recurObject->tz)) {
2754
				$basedateUtc = $recurObject->toGMT($recurObject->tz, $basedate);
2755
			}
2756
2757
			// Ensure that the correct basedate is set in the new message
2758
			$newmessageprops[$this->proptags['basedate']] = $basedateUtc;
2759
2760
			// Set isRecurring to false, because this is an exception
2761
			$newmessageprops[$this->proptags['recurring']] = false;
2762
2763
			// PidLidIsRecurring indicates a message associated with a recurring series object.
2764
			// It's true both for the series and an exception.
2765
			$newmessageprops[$this->proptags['meetingrecurring']] = true;
2766
2767
			// Recurrence data is not necessary for an exception
2768
			unset($newmessageprops[$this->proptags['recurrence_data']]);
2769
2770
			// set LID_IS_EXCEPTION to true
2771
			$newmessageprops[$this->proptags['is_exception']] = true;
2772
2773
			// Set to high importance
2774
			if ($cancel) {
2775
				$newmessageprops[PR_IMPORTANCE] = IMPORTANCE_HIGH;
2776
			}
2777
2778
			// Set startdate and enddate of exception
2779
			if ($cancel && $recurObject) {
2780
				$newmessageprops[$this->proptags['startdate']] = $recurObject->getOccurrenceStart($basedate);
0 ignored issues
show
Bug introduced by
The method getOccurrenceStart() does not exist on BaseRecurrence. It seems like you code against a sub-type of BaseRecurrence such as Recurrence. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

2780
				/** @scrutinizer ignore-call */ 
2781
    $newmessageprops[$this->proptags['startdate']] = $recurObject->getOccurrenceStart($basedate);
Loading history...
2781
				$newmessageprops[$this->proptags['duedate']] = $recurObject->getOccurrenceEnd($basedate);
0 ignored issues
show
Bug introduced by
The method getOccurrenceEnd() does not exist on BaseRecurrence. It seems like you code against a sub-type of BaseRecurrence such as Recurrence. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

2781
				/** @scrutinizer ignore-call */ 
2782
    $newmessageprops[$this->proptags['duedate']] = $recurObject->getOccurrenceEnd($basedate);
Loading history...
2782
			}
2783
2784
			// Set basedate in guid (0x3)
2785
			$newmessageprops[$this->proptags['goid']] = $this->setBasedateInGlobalID($messageprops[$this->proptags['goid2']], $basedateUtc, $recurObject instanceof BaseRecurrence ? $recurObject : null);
2786
			$newmessageprops[$this->proptags['goid2']] = $messageprops[$this->proptags['goid2']];
2787
			$newmessageprops[PR_OWNER_APPT_ID] = $messageprops[PR_OWNER_APPT_ID];
2788
2789
			// Get deleted recipiets from exception msg
2790
			$restriction = [
2791
				RES_AND,
2792
				[
2793
					[
2794
						RES_BITMASK,
2795
						[
2796
							ULTYPE => BMR_NEZ,
2797
							ULPROPTAG => PR_RECIPIENT_FLAGS,
2798
							ULMASK => recipExceptionalDeleted,
2799
						],
2800
					],
2801
					[
2802
						RES_BITMASK,
2803
						[
2804
							ULTYPE => BMR_EQZ,
2805
							ULPROPTAG => PR_RECIPIENT_FLAGS,
2806
							ULMASK => recipOrganizer,
2807
						],
2808
					],
2809
				],
2810
			];
2811
2812
			// In direct-booking mode, we don't need to send cancellations to resources
2813
			if ($this->enableDirectBooking) {
2814
				$restriction[1][] = [
2815
					RES_PROPERTY,
2816
					[
2817
						RELOP => RELOP_NE,	// Does not equal recipient type: MAPI_BCC (Resource)
2818
						ULPROPTAG => PR_RECIPIENT_TYPE,
2819
						VALUE => [PR_RECIPIENT_TYPE => MAPI_BCC],
2820
					],
2821
				];
2822
			}
2823
2824
			$recipients = $this->getMessageRecipients($message, $restriction);
2825
2826
			$deletedRecips = array_merge($deletedRecips ?: [], $recipients);
2827
		}
2828
2829
		// Remove the PR_ICON_INDEX as it is not needed in the sent message.
2830
		$newmessageprops[PR_ICON_INDEX] = null;
2831
		$newmessageprops[PR_RESPONSE_REQUESTED] = true;
2832
2833
		// PR_START_DATE and PR_END_DATE will be used by outlook to show the position in the calendar
2834
		$newmessageprops[PR_START_DATE] = $newmessageprops[$this->proptags['startdate']];
2835
		$newmessageprops[PR_END_DATE] = $newmessageprops[$this->proptags['duedate']];
2836
2837
		// Set updatecounter/AppointmentSequenceNumber
2838
		// get the value of latest updatecounter for the whole series and use it
2839
		$newmessageprops[$this->proptags['updatecounter']] = $messageprops[$this->proptags['last_updatecounter']];
2840
2841
		$meetingTimeInfo = $this->getMeetingTimeInfo();
2842
2843
		if ($meetingTimeInfo) {
2844
			// Needs to unset PR_HTML and PR_RTF_COMPRESSED props
2845
			// because while canceling meeting requests with edit text
2846
			// will override the PR_BODY because body value is not consistent with
2847
			// PR_HTML and PR_RTF_COMPRESSED value so in this case PR_RTF_COMPRESSED will
2848
			// get priority which override the PR_BODY value.
2849
			unset($newmessageprops[PR_HTML], $newmessageprops[PR_RTF_COMPRESSED]);
2850
2851
			$newmessageprops[PR_BODY] = $meetingTimeInfo;
2852
		}
2853
2854
		// Send all recurrence info in mail, if this is a recurrence meeting.
2855
		if (isset($newmessageprops[$this->proptags['recurring']]) && $newmessageprops[$this->proptags['recurring']]) {
2856
			if (!empty($messageprops[$this->proptags['recurring_pattern']])) {
2857
				$newmessageprops[$this->proptags['recurring_pattern']] = $messageprops[$this->proptags['recurring_pattern']];
2858
			}
2859
			$newmessageprops[$this->proptags['recurrence_data']] = $messageprops[$this->proptags['recurrence_data']];
2860
			$newmessageprops[$this->proptags['timezone_data']] = $messageprops[$this->proptags['timezone_data']];
2861
			$newmessageprops[$this->proptags['timezone']] = $messageprops[$this->proptags['timezone']];
2862
2863
			if ($recurObject) {
2864
				$this->generateRecurDates($recurObject, $messageprops, $newmessageprops);
2865
			}
2866
		}
2867
2868
		if (isset($newmessageprops[$this->proptags['counter_proposal']])) {
2869
			unset($newmessageprops[$this->proptags['counter_proposal']]);
2870
		}
2871
2872
		// Prefix the subject if needed
2873
		if ($prefix && isset($newmessageprops[PR_SUBJECT])) {
2874
			$newmessageprops[PR_SUBJECT] = $prefix . $newmessageprops[PR_SUBJECT];
2875
		}
2876
2877
		if (isset($newmessageprops[$this->proptags['categories']]) &&
2878
			!empty($newmessageprops[$this->proptags['categories']])) {
2879
			unset($newmessageprops[$this->proptags['categories']]);
2880
		}
2881
		mapi_setprops($new, $newmessageprops);
2882
2883
		// Copy attachments
2884
		$this->replaceAttachments($message, $new, $copyExceptions);
2885
2886
		// Retrieve only those recipient who should receive this meeting request.
2887
		$stripResourcesRestriction = [
2888
			RES_AND,
2889
			[
2890
				[
2891
					RES_BITMASK,
2892
					[
2893
						ULTYPE => BMR_EQZ,
2894
						ULPROPTAG => PR_RECIPIENT_FLAGS,
2895
						ULMASK => recipExceptionalDeleted,
2896
					],
2897
				],
2898
				[
2899
					RES_BITMASK,
2900
					[
2901
						ULTYPE => BMR_EQZ,
2902
						ULPROPTAG => PR_RECIPIENT_FLAGS,
2903
						ULMASK => recipOrganizer,
2904
					],
2905
				],
2906
			],
2907
		];
2908
2909
		// In direct-booking mode, resources do not receive a meeting request
2910
		if ($this->enableDirectBooking) {
2911
			$stripResourcesRestriction[1][] = [
2912
				RES_PROPERTY,
2913
				[
2914
					RELOP => RELOP_NE,	// Does not equal recipient type: MAPI_BCC (Resource)
2915
					ULPROPTAG => PR_RECIPIENT_TYPE,
2916
					VALUE => [PR_RECIPIENT_TYPE => MAPI_BCC],
2917
				],
2918
			];
2919
		}
2920
2921
		// If no recipients were explicitly provided, we will send the update to all
2922
		// recipients from the meeting.
2923
		if ($modifiedRecips === false) {
2924
			$modifiedRecips = $this->getMessageRecipients($message, $stripResourcesRestriction);
2925
2926
			if ($basedate && empty($modifiedRecips)) {
2927
				// Retrieve full list
2928
				$modifiedRecips = $this->getMessageRecipients($this->message);
2929
2930
				// Save recipients in exceptions
2931
				mapi_message_modifyrecipients($message, MODRECIP_ADD, $modifiedRecips);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $msg of mapi_message_modifyrecipients(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2931
				mapi_message_modifyrecipients(/** @scrutinizer ignore-type */ $message, MODRECIP_ADD, $modifiedRecips);
Loading history...
2932
2933
				// Now retrieve only those recipient who should receive this meeting request.
2934
				$modifiedRecips = $this->getMessageRecipients($this->message, $stripResourcesRestriction);
2935
			}
2936
		}
2937
2938
		// @TODO: handle nonAcceptingResources
2939
		/*
2940
		 * Add resource recipients that did not automatically accept the meeting request.
2941
		 * (note: meaning that they did not decline the meeting request)
2942
		 */ /*
2943
		for($i=0;$i<count($this->nonAcceptingResources);$i++){
2944
			$recipients[] = $this->nonAcceptingResources[$i];
2945
		}*/
2946
2947
		if (!empty($modifiedRecips)) {
2948
			// Strip out the sender/'owner' recipient
2949
			mapi_message_modifyrecipients($new, MODRECIP_ADD, $modifiedRecips);
2950
2951
			// Set some properties that are different in the sent request than
2952
			// in the item in our calendar
2953
2954
			// we should store busystatus value to intendedbusystatus property, because busystatus for outgoing meeting request
2955
			// should always be fbTentative
2956
			$newmessageprops[$this->proptags['intendedbusystatus']] = $newmessageprops[$this->proptags['busystatus']] ?? $messageprops[$this->proptags['busystatus']];
2957
			$newmessageprops[$this->proptags['busystatus']] = fbTentative; // The default status when not accepted
2958
			$newmessageprops[$this->proptags['responsestatus']] = olResponseNotResponded; // The recipient has not responded yet
2959
			$newmessageprops[$this->proptags['attendee_critical_change']] = time();
2960
			$newmessageprops[$this->proptags['owner_critical_change']] = time();
2961
			$newmessageprops[$this->proptags['meetingtype']] = mtgRequest;
2962
2963
			if ($cancel) {
2964
				$newmessageprops[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Canceled';
2965
				$newmessageprops[$this->proptags['meetingstatus']] = olMeetingCanceled; // It's a cancel request
2966
				$newmessageprops[$this->proptags['busystatus']] = fbFree; // set the busy status as free
2967
			}
2968
			else {
2969
				$newmessageprops[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Request';
2970
				$newmessageprops[$this->proptags['meetingstatus']] = olMeetingReceived; // The recipient is receiving the request
2971
			}
2972
2973
			mapi_setprops($new, $newmessageprops);
2974
			mapi_savechanges($new);
2975
2976
			// Submit message to non-resource recipients
2977
			mapi_message_submitmessage($new);
2978
		}
2979
2980
		// Search through the deleted recipients, and see if any of them is also
2981
		// listed as a recipient to whom we have sent an update. As we don't
2982
		// want to send a cancellation message to recipients who will also receive
2983
		// an meeting update, we have to filter those recipients out.
2984
		if ($deletedRecips) {
2985
			$tmp = [];
2986
2987
			foreach ($deletedRecips as $delRecip) {
2988
				$found = false;
2989
2990
				// Search if the deleted recipient can be found inside
2991
				// the updated recipients as well.
2992
				foreach ($modifiedRecips as $recip) {
2993
					if ($this->compareABEntryIDs($recip[PR_ENTRYID], $delRecip[PR_ENTRYID])) {
2994
						$found = true;
2995
						break;
2996
					}
2997
				}
2998
2999
				// If the recipient was not found, it truly is deleted,
3000
				// and we can safely send a cancellation message
3001
				if (!$found) {
3002
					$tmp[] = $delRecip;
3003
				}
3004
			}
3005
3006
			$deletedRecips = $tmp;
3007
		}
3008
3009
		// Send cancellation to deleted attendees
3010
		if ($deletedRecips) {
3011
			$new = $this->createOutgoingMessage();
3012
3013
			mapi_message_modifyrecipients($new, MODRECIP_ADD, $deletedRecips);
3014
3015
			$newmessageprops[PR_MESSAGE_CLASS] = 'IPM.Schedule.Meeting.Canceled';
3016
			$newmessageprops[$this->proptags['meetingstatus']] = olMeetingCanceled; // It's a cancel request
3017
			$newmessageprops[$this->proptags['busystatus']] = fbFree; // set the busy status as free
3018
			$newmessageprops[PR_IMPORTANCE] = IMPORTANCE_HIGH;	// HIGH Importance
3019
			if (isset($newmessageprops[PR_SUBJECT])) {
3020
				$newmessageprops[PR_SUBJECT] = _('Canceled') . ': ' . $newmessageprops[PR_SUBJECT];
3021
			}
3022
3023
			mapi_setprops($new, $newmessageprops);
3024
			mapi_savechanges($new);
3025
3026
			// Submit message to non-resource recipients
3027
			mapi_message_submitmessage($new);
3028
		}
3029
3030
		// Set properties on meeting object in calendar
3031
		// Set requestsent to 'true' (turns on 'tracking', etc)
3032
		$props = [];
3033
		$props[$this->proptags['meetingstatus']] = olMeeting;
3034
		$props[$this->proptags['responsestatus']] = olResponseOrganized;
3035
		// Only set the 'requestsent' property if it wasn't set previously yet,
3036
		// this ensures we will not accidentally set it from true to false.
3037
		if (!isset($messageprops[$this->proptags['requestsent']]) || $messageprops[$this->proptags['requestsent']] !== true) {
3038
			$props[$this->proptags['requestsent']] = !empty($modifiedRecips) || ($this->includesResources && !$this->errorSetResource);
3039
		}
3040
		$props[$this->proptags['attendee_critical_change']] = time();
3041
		$props[$this->proptags['owner_critical_change']] = time();
3042
		$props[$this->proptags['meetingtype']] = mtgRequest;
3043
		// save the new updatecounter to exception/recurring series/normal meeting
3044
		$props[$this->proptags['updatecounter']] = $newmessageprops[$this->proptags['updatecounter']];
3045
3046
		// PR_START_DATE and PR_END_DATE will be used by outlook to show the position in the calendar
3047
		$props[PR_START_DATE] = $messageprops[$this->proptags['startdate']];
3048
		$props[PR_END_DATE] = $messageprops[$this->proptags['duedate']];
3049
3050
		mapi_setprops($message, $props);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $any of mapi_setprops(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

3050
		mapi_setprops(/** @scrutinizer ignore-type */ $message, $props);
Loading history...
3051
3052
		// saving of these properties on calendar item should be handled by caller function
3053
		// based on sending meeting request was successful or not
3054
	}
3055
3056
	/**
3057
	 * OL2007 uses these 4 properties to specify occurrence that should be updated.
3058
	 * ical generates RECURRENCE-ID property based on exception's basedate (PidLidExceptionReplaceTime),
3059
	 * but OL07 doesn't send this property, so ical will generate RECURRENCE-ID property based on date
3060
	 * from GlobalObjId and time from StartRecurTime property, so we are sending basedate property and
3061
	 * also additionally we are sending these properties.
3062
	 * Ref: MS-OXCICAL 2.2.1.20.20 Property: RECURRENCE-ID.
3063
	 *
3064
	 * @param object $recurObject     instance of recurrence class for this message
3065
	 * @param array  $messageprops    properties of meeting object that is going to be sent
3066
	 * @param array  $newmessageprops properties of meeting request/response that is going to be sent
3067
	 */
3068
	public function generateRecurDates(object $recurObject, array $messageprops, array &$newmessageprops): void {
3069
		if ($messageprops[$this->proptags['startdate']] && $messageprops[$this->proptags['duedate']]) {
3070
			$startDate = date('Y:n:j:G:i:s', $recurObject->fromGMT($recurObject->tz, $messageprops[$this->proptags['startdate']]));
3071
			$endDate = date('Y:n:j:G:i:s', $recurObject->fromGMT($recurObject->tz, $messageprops[$this->proptags['duedate']]));
3072
3073
			$startDate = explode(':', $startDate);
3074
			$endDate = explode(':', $endDate);
3075
3076
			// [0] => year, [1] => month, [2] => day, [3] => hour, [4] => minutes, [5] => seconds
3077
			// RecurStartDate = year * 512 + month_number * 32 + day_number
3078
			$newmessageprops[$this->proptags['start_recur_date']] = (((int) $startDate[0]) * 512) + (((int) $startDate[1]) * 32) + ((int) $startDate[2]);
3079
			// RecurStartTime = hour * 4096 + minutes * 64 + seconds
3080
			$newmessageprops[$this->proptags['start_recur_time']] = (((int) $startDate[3]) * 4096) + (((int) $startDate[4]) * 64) + ((int) $startDate[5]);
3081
3082
			$newmessageprops[$this->proptags['end_recur_date']] = (((int) $endDate[0]) * 512) + (((int) $endDate[1]) * 32) + ((int) $endDate[2]);
3083
			$newmessageprops[$this->proptags['end_recur_time']] = (((int) $endDate[3]) * 4096) + (((int) $endDate[4]) * 64) + ((int) $endDate[5]);
3084
		}
3085
	}
3086
3087
	/**
3088
	 * Function will create a new outgoing message that will be used to send meeting mail.
3089
	 *
3090
	 * @param mixed $store (optional) store that is used when creating response, if delegate is creating outgoing mail
3091
	 *                     then this would point to delegate store
3092
	 *
3093
	 * @return resource outgoing mail that is created and can be used for sending it
3094
	 */
3095
	public function createOutgoingMessage(mixed $store = false): mixed {
3096
		// get logged in user's store that will be used to send mail, for delegate this will be
3097
		// delegate store
3098
		$userStore = $this->openDefaultStore();
3099
3100
		$sentprops = [];
3101
		$outbox = $this->openDefaultOutbox($userStore);
3102
3103
		$outgoing = mapi_folder_createmessage($outbox);
3104
3105
		// check if $store is set and it is not equal to $defaultStore (means its the delegation case)
3106
		if ($store !== false) {
3107
			$storeProps = mapi_getprops($store, [PR_ENTRYID]);
3108
			$userStoreProps = mapi_getprops($userStore, [PR_ENTRYID]);
3109
3110
			if (!compareEntryIds($storeProps[PR_ENTRYID], $userStoreProps[PR_ENTRYID])) {
3111
				// get the delegator properties and set it into outgoing mail
3112
				$delegatorDetails = $this->getOwnerAddress($store, false);
3113
				$this->setAddressProperties($sentprops, $delegatorDetails, 'SENT_REPRESENTING');
0 ignored issues
show
Bug introduced by
It seems like $delegatorDetails can also be of type false; however, parameter $addrInfo of Meetingrequest::setAddressProperties() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

3113
				$this->setAddressProperties($sentprops, /** @scrutinizer ignore-type */ $delegatorDetails, 'SENT_REPRESENTING');
Loading history...
3114
3115
				// get the delegate properties and set it into outgoing mail
3116
				$delegateDetails = $this->getOwnerAddress($userStore, false);
3117
				$this->setAddressProperties($sentprops, $delegateDetails, 'SENDER');
3118
			}
3119
		}
3120
		else {
3121
			// normal user is sending mail, so both set of properties will be same
3122
			$userDetails = $this->getOwnerAddress($userStore);
3123
			$this->setAddressProperties($sentprops, $userDetails, 'SENT_REPRESENTING');
3124
			$this->setAddressProperties($sentprops, $userDetails, 'SENDER');
3125
		}
3126
3127
		$sentprops[PR_SENTMAIL_ENTRYID] = $this->getDefaultSentmailEntryID($userStore);
3128
3129
		mapi_setprops($outgoing, $sentprops);
3130
3131
		return $outgoing;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $outgoing returns the type resource which is incompatible with the documented return type resource.
Loading history...
3132
	}
3133
3134
	/**
3135
	 * Function which checks that meeting in attendee's calendar is already updated
3136
	 * and we are checking an old meeting request. This function also will update property
3137
	 * meetingtype to indicate that its out of date meeting request.
3138
	 *
3139
	 * @return bool true if meeting request is outofdate else false if it is new
3140
	 */
3141
	public function isMeetingOutOfDate(): bool {
3142
		$result = false;
3143
3144
		$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']]);
3145
3146
		if (!$this->isMeetingRequest($props[PR_MESSAGE_CLASS])) {
3147
			return $result;
3148
		}
3149
3150
		if (isset($props[$this->proptags['meetingtype']]) && ($props[$this->proptags['meetingtype']] & mtgOutOfDate) == mtgOutOfDate) {
3151
			return true;
3152
		}
3153
3154
		// get the basedate to check for exception
3155
		$basedate = $this->getBasedateFromGlobalID($props[$this->proptags['goid']]);
3156
3157
		$calendarItem = $this->getCorrespondentCalendarItem(true);
3158
3159
		// if basedate is provided and we could not find the item then it could be that we are checking
3160
		// an exception so get the exception and check it
3161
		if ($basedate !== false && $calendarItem !== false) {
3162
			$exception = $this->getExceptionItem($calendarItem, $basedate);
3163
3164
			if ($exception !== false) {
3165
				// we are able to find the exception compare with it
3166
				$calendarItem = $exception;
3167
			}
3168
			// we are not able to find exception, could mean that a significant change has occurred on series
3169
			// and it deleted all exceptions, so compare with series
3170
			// $calendarItem already contains reference to series
3171
		}
3172
3173
		if ($calendarItem !== false) {
3174
			$calendarItemProps = mapi_getprops($calendarItem, [
3175
				$this->proptags['owner_critical_change'],
3176
				$this->proptags['updatecounter'],
3177
			]);
3178
3179
			$updateCounter = (isset($calendarItemProps[$this->proptags['updatecounter']]) && $props[$this->proptags['updatecounter']] < $calendarItemProps[$this->proptags['updatecounter']]);
3180
3181
			$criticalChange = (isset($calendarItemProps[$this->proptags['owner_critical_change']]) && $props[$this->proptags['owner_critical_change']] < $calendarItemProps[$this->proptags['owner_critical_change']]);
3182
3183
			if ($updateCounter || $criticalChange) {
3184
				// meeting request is out of date, set properties to indicate this
3185
				mapi_setprops($this->message, [$this->proptags['meetingtype'] => mtgOutOfDate, PR_ICON_INDEX => 1033]);
3186
				mapi_savechanges($this->message);
3187
3188
				$result = true;
3189
			}
3190
		}
3191
3192
		return $result;
3193
	}
3194
3195
	/**
3196
	 * Function which checks that if we have received a meeting response for an updated meeting in organizer's calendar.
3197
	 *
3198
	 * @param mixed $basedate basedate of the exception if we want to compare with exception
3199
	 *
3200
	 * @return bool true if meeting request is updated later
3201
	 */
3202
	public function isMeetingUpdated(false|int $basedate = false): bool {
3203
		$result = false;
3204
3205
		$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS, $this->proptags['updatecounter']]);
3206
3207
		if (!$this->isMeetingRequestResponse($props[PR_MESSAGE_CLASS])) {
3208
			return $result;
3209
		}
3210
3211
		$calendarItem = $this->getCorrespondentCalendarItem(true);
3212
3213
		if ($calendarItem !== false) {
3214
			// basedate is provided so open exception
3215
			if ($basedate !== false) {
3216
				$exception = $this->getExceptionItem($calendarItem, $basedate);
3217
3218
				if ($exception !== false) {
3219
					// we are able to find the exception compare with it
3220
					$calendarItem = $exception;
3221
				}
3222
				// we are not able to find exception, could mean that a significant change has occurred on series
3223
				// and it deleted all exceptions, so compare with series
3224
				// $calendarItem already contains reference to series
3225
			}
3226
3227
			if ($calendarItem !== false) {
3228
				$calendarItemProps = mapi_getprops($calendarItem, [$this->proptags['updatecounter']]);
3229
3230
				/*
3231
				 * if(message_counter < appointment_counter) meeting object is newer then meeting response (meeting is updated)
3232
				 * if(message_counter >= appointment_counter) meeting is not updated, do normal processing
3233
				 */
3234
				if (isset($calendarItemProps[$this->proptags['updatecounter']], $props[$this->proptags['updatecounter']])) {
3235
					$result = $props[$this->proptags['updatecounter']] < $calendarItemProps[$this->proptags['updatecounter']];
3236
				}
3237
			}
3238
		}
3239
3240
		return $result;
3241
	}
3242
3243
	/**
3244
	 * Checks if there has been any significant changes on appointment/meeting item.
3245
	 * Significant changes be:
3246
	 * 1) startdate has been changed
3247
	 * 2) duedate has been changed OR
3248
	 * 3) recurrence pattern has been created, modified or removed.
3249
	 *
3250
	 * @param mixed $oldProps
3251
	 * @param mixed $isRecurrenceChanged for change in recurrence pattern.
3252
	 *                                   true means Recurrence pattern has been changed,
3253
	 *                                   so clear all attendees response
3254
	 */
3255
	public function checkSignificantChanges(array $oldProps, mixed $basedate, bool $isRecurrenceChanged = false): void {
3256
		$message = null;
3257
		$attach = null;
3258
3259
		// If basedate is specified then we need to open exception message to clear recipient responses
3260
		if ($basedate) {
3261
			$recurrence = new Recurrence($this->store, $this->message);
3262
			if ($recurrence->isException($basedate)) {
3263
				$attach = $recurrence->getExceptionAttachment($basedate);
3264
				if ($attach) {
3265
					$message = mapi_attach_openobj($attach, MAPI_MODIFY);
3266
				}
3267
			}
3268
		}
3269
		else {
3270
			// use normal message or recurring series message
3271
			$message = $this->message;
3272
		}
3273
3274
		if (!$message) {
3275
			return;
3276
		}
3277
3278
		$newProps = mapi_getprops($message, [$this->proptags['startdate'], $this->proptags['duedate'], $this->proptags['updatecounter']]);
3279
3280
		// Check whether message is updated or not.
3281
		if (isset($newProps[$this->proptags['updatecounter']]) && $newProps[$this->proptags['updatecounter']] == 0) {
3282
			return;
3283
		}
3284
3285
		if (($newProps[$this->proptags['startdate']] != $oldProps[$this->proptags['startdate']]) ||
3286
				($newProps[$this->proptags['duedate']] != $oldProps[$this->proptags['duedate']]) ||
3287
				$isRecurrenceChanged) {
3288
			$this->clearRecipientResponse($message);
0 ignored issues
show
Bug introduced by
It seems like $message can also be of type resource; however, parameter $message of Meetingrequest::clearRecipientResponse() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

3288
			$this->clearRecipientResponse(/** @scrutinizer ignore-type */ $message);
Loading history...
3289
3290
			mapi_setprops($message, [$this->proptags['owner_critical_change'] => time()]);
3291
3292
			mapi_savechanges($message);
3293
			if ($attach) { // Also save attachment Object.
3294
				mapi_savechanges($attach);
3295
			}
3296
		}
3297
	}
3298
3299
	/**
3300
	 * Clear responses of all attendees who have replied in past.
3301
	 *
3302
	 * @param resource $message on which responses should be cleared
3303
	 */
3304
	public function clearRecipientResponse($message): void {
3305
		$recipsRows = $this->getMessageRecipients($message);
3306
		for ($i = 0, $recipsCnt = count($recipsRows); $i < $recipsCnt; ++$i) {
3307
			// Clear track status for everyone in the recipients table
3308
			$recipsRows[$i][PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusNone;
3309
		}
3310
		mapi_message_modifyrecipients($message, MODRECIP_MODIFY, $recipsRows);
0 ignored issues
show
Bug introduced by
$message of type resource is incompatible with the type resource expected by parameter $msg of mapi_message_modifyrecipients(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

3310
		mapi_message_modifyrecipients(/** @scrutinizer ignore-type */ $message, MODRECIP_MODIFY, $recipsRows);
Loading history...
3311
	}
3312
3313
	/**
3314
	 * Function returns correspondent calendar item attached with the meeting request/response/cancellation.
3315
	 * This will only check for actual MAPIMessages in calendar folder, so if a meeting request is
3316
	 * for exception then this function will return recurring series for that meeting request
3317
	 * after that you need to use getExceptionItem function to get exception item that will be
3318
	 * fetched from the attachment table of recurring series MAPIMessage.
3319
	 *
3320
	 * @param bool $open boolean to indicate the function should return entryid or MAPIMessage. Defaults to true.
3321
	 *
3322
	 * @return bool|resource resource of calendar item
3323
	 */
3324
	public function getCorrespondentCalendarItem(bool $open = true): mixed {
3325
		$props = mapi_getprops($this->message, [PR_MESSAGE_CLASS, $this->proptags['goid'], $this->proptags['goid2'], PR_RCVD_REPRESENTING_ENTRYID]);
3326
3327
		if (!$this->isMeetingRequest($props[PR_MESSAGE_CLASS]) && !$this->isMeetingRequestResponse($props[PR_MESSAGE_CLASS]) && !$this->isMeetingCancellation($props[PR_MESSAGE_CLASS])) {
3328
			// can work only with meeting requests/responses/cancellations
3329
			return false;
3330
		}
3331
3332
		// there is no goid - no items can be found - aborting
3333
		if (empty($props[$this->proptags['goid']])) {
3334
			return false;
3335
		}
3336
		$globalId = $props[$this->proptags['goid']];
3337
3338
		['store' => $store, 'calFolder' => $calFolder] = $this->resolveDelegateStoreAndCalendar($props);
3339
3340
		$basedate = $this->getBasedateFromGlobalID($globalId);
3341
3342
		/**
3343
		 * First search for any appointments which correspond to the $globalId,
3344
		 * this can be the entire series (if the Meeting Request refers to the
3345
		 * entire series), or an particular Occurrence (if the meeting Request
3346
		 * contains a basedate).
3347
		 *
3348
		 * If we cannot find a corresponding item, and the $globalId contains
3349
		 * a $basedate, it might imply that a new exception will have to be
3350
		 * created for a series which is present in the calendar, we can look
3351
		 * that one up by searching for the $cleanGlobalId.
3352
		 */
3353
		$entryids = $this->findCalendarItems($globalId, $calFolder);
3354
		if ($basedate !== false && empty($entryids)) {
3355
			// only search if a goid2 is available
3356
			if (!empty($props[$this->proptags['goid2']])) {
3357
				$cleanGlobalId = $props[$this->proptags['goid2']];
3358
				$entryids = $this->findCalendarItems($cleanGlobalId, $calFolder, true);
3359
			}
3360
		}
3361
3362
		// there should be only one item returned
3363
		if (!empty($entryids) && count($entryids) === 1) {
3364
			// return only entryid
3365
			if ($open === false) {
3366
				return $entryids[0];
3367
			}
3368
3369
			// open calendar item and return it
3370
			if ($store) {
3371
				return mapi_msgstore_openentry($store, $entryids[0]);
0 ignored issues
show
Bug Best Practice introduced by
The expression return mapi_msgstore_ope...y($store, $entryids[0]) returns the type resource which is incompatible with the documented return type boolean|resource.
Loading history...
3372
			}
3373
		}
3374
3375
		// no items found in calendar
3376
		return false;
3377
	}
3378
3379
	/**
3380
	 * Function returns exception item based on the basedate passed.
3381
	 *
3382
	 * @param mixed $recurringMessage Resource of Recurring meeting from calendar
3383
	 * @param mixed $basedate         basedate of exception that needs to be returned
3384
	 * @param mixed $store            store that contains the recurring calendar item
3385
	 *
3386
	 * @return entryid or MAPIMessage resource of exception item
0 ignored issues
show
Bug introduced by
The type entryid was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
3387
	 */
3388
	public function getExceptionItem(mixed $recurringMessage, mixed $basedate, mixed $store = false): mixed {
3389
		$occurItem = false;
3390
3391
		$props = mapi_getprops($this->message, [PR_RCVD_REPRESENTING_ENTRYID, $this->proptags['recurring']]);
3392
3393
		// check if the passed item is recurring series
3394
		if (isset($props[$this->proptags['recurring']]) && $props[$this->proptags['recurring']] !== false) {
3395
			return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type entryid.
Loading history...
3396
		}
3397
3398
		if ($store === false) {
3399
			$store = $this->store;
3400
			// If Delegate is processing Meeting Request/Response for Delegator then retrieve Delegator's store and calendar.
3401
			if (isset($props[PR_RCVD_REPRESENTING_ENTRYID])) {
3402
				$delegatorStore = $this->getDelegatorStore($props[PR_RCVD_REPRESENTING_ENTRYID]);
3403
				if (!empty($delegatorStore['store'])) {
3404
					$store = $delegatorStore['store'];
3405
				}
3406
			}
3407
		}
3408
3409
		$recurr = new Recurrence($store, $recurringMessage);
3410
		$attach = $recurr->getExceptionAttachment($basedate);
3411
		if ($attach) {
3412
			$occurItem = mapi_attach_openobj($attach);
3413
		}
3414
3415
		return $occurItem;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $occurItem returns the type false|resource which is incompatible with the documented return type entryid.
Loading history...
3416
	}
3417
3418
	/**
3419
	 * Function which checks whether received meeting request is either conflicting with other appointments or not.
3420
	 *
3421
	 * @param false|resource $message
3422
	 * @param false|resource $userStore
3423
	 * @param mixed          $calFolder calendar folder for conflict checking
3424
	 *
3425
	 * @psalm-return bool|int<1, max>
3426
	 */
3427
	public function isMeetingConflicting(mixed $message = false, mixed $userStore = false, mixed $calFolder = false): bool|int {
3428
		$returnValue = false;
3429
		$noOfInstances = 0;
3430
3431
		if ($message === false) {
3432
			$message = $this->message;
3433
		}
3434
3435
		$messageProps = mapi_getprops(
3436
			$message,
3437
			[
3438
				PR_MESSAGE_CLASS,
3439
				$this->proptags['goid'],
3440
				$this->proptags['goid2'],
3441
				$this->proptags['startdate'],
3442
				$this->proptags['duedate'],
3443
				$this->proptags['recurring'],
3444
				$this->proptags['clipstart'],
3445
				$this->proptags['clipend'],
3446
				PR_RCVD_REPRESENTING_ENTRYID,
3447
				$this->proptags['basedate'],
3448
				PR_RCVD_REPRESENTING_NAME,
3449
			]
3450
		);
3451
3452
		if ($userStore === false) {
3453
			$userStore = $this->store;
3454
3455
			// check if delegate is processing the response
3456
			if (isset($messageProps[PR_RCVD_REPRESENTING_ENTRYID])) {
3457
				$delegatorStore = $this->getDelegatorStore($messageProps[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
3458
3459
				if (!empty($delegatorStore['store'])) {
3460
					$userStore = $delegatorStore['store'];
3461
				}
3462
				if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
3463
					$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
3464
				}
3465
			}
3466
		}
3467
3468
		if ($calFolder === false) {
3469
			$calFolder = $this->openDefaultCalendar($userStore);
3470
		}
3471
3472
		if ($calFolder) {
3473
			// Meeting request is recurring, so get all occurrence and check for each occurrence whether it conflicts with other appointments in Calendar.
3474
			if (isset($messageProps[$this->proptags['recurring']]) && $messageProps[$this->proptags['recurring']] === true) {
3475
				// Apply recurrence class and retrieve all occurrences(max: 30 occurrence because recurrence can also be set as 'no end date')
3476
				$recurr = new Recurrence($userStore, $message);
3477
				$items = $recurr->getItems($messageProps[$this->proptags['clipstart']], $messageProps[$this->proptags['clipend']] * (24 * 24 * 60), 30);
3478
3479
				foreach ($items as $item) {
3480
					// Get all items in the timeframe that we want to book, and get the goid and busystatus for each item
3481
					$calendarItems = $recurr->getCalendarItems($userStore, $calFolder, $item[$this->proptags['startdate']], $item[$this->proptags['duedate']], [$this->proptags['goid'], $this->proptags['busystatus']]);
3482
3483
					foreach ($calendarItems as $calendarItem) {
3484
						if ($calendarItem[$this->proptags['busystatus']] !== fbFree) {
3485
							/*
3486
							 * Only meeting requests have globalID, normal appointments do not have globalID
3487
							 * so if any normal appointment if found then it is assumed to be conflict.
3488
							 */
3489
							if (isset($calendarItem[$this->proptags['goid']])) {
3490
								if ($calendarItem[$this->proptags['goid']] !== $messageProps[$this->proptags['goid']]) {
3491
									++$noOfInstances;
3492
									break;
3493
								}
3494
							}
3495
							else {
3496
								++$noOfInstances;
3497
								break;
3498
							}
3499
						}
3500
					}
3501
				}
3502
3503
				if ($noOfInstances > 0) {
3504
					$returnValue = $noOfInstances;
3505
				}
3506
			}
3507
			else {
3508
				// Get all items in the timeframe that we want to book, and get the goid and busystatus for each item
3509
				$items = getCalendarItems($userStore, $calFolder, $messageProps[$this->proptags['startdate']], $messageProps[$this->proptags['duedate']], [$this->proptags['goid'], $this->proptags['busystatus']]);
3510
3511
				if (isset($messageProps[$this->proptags['basedate']]) && !empty($messageProps[$this->proptags['basedate']])) {
3512
					$basedate = $messageProps[$this->proptags['basedate']];
3513
					// Get the goid2 from recurring MR which further used to
3514
					// check the resource conflicts item.
3515
					$recurrItemProps = mapi_getprops($this->message, [$this->proptags['goid2']]);
3516
					$recurrenceHelper = new Recurrence($this->openDefaultStore(), $this->message);
3517
					$messageProps[$this->proptags['goid']] = $this->setBasedateInGlobalID($recurrItemProps[$this->proptags['goid2']], $basedate, $recurrenceHelper);
3518
					$messageProps[$this->proptags['goid2']] = $recurrItemProps[$this->proptags['goid2']];
3519
				}
3520
3521
				foreach ($items as $item) {
3522
					if ($item[$this->proptags['busystatus']] !== fbFree) {
3523
						if (isset($item[$this->proptags['goid']])) {
3524
							if (($item[$this->proptags['goid']] !== $messageProps[$this->proptags['goid']]) &&
3525
								($item[$this->proptags['goid']] !== $messageProps[$this->proptags['goid2']])) {
3526
								$returnValue = true;
3527
								break;
3528
							}
3529
						}
3530
						else {
3531
							$returnValue = true;
3532
							break;
3533
						}
3534
					}
3535
				}
3536
			}
3537
		}
3538
3539
		return $returnValue;
3540
	}
3541
3542
	/**
3543
	 * Function which adds organizer to recipient list which is passed.
3544
	 * This function also checks if it has organizer.
3545
	 *
3546
	 * @param array $messageProps message properties
3547
	 * @param array $recipients   recipients list of message
3548
	 */
3549
	public function addDelegator(array $messageProps, array &$recipients): void {
3550
		$hasDelegator = false;
3551
		// Check if meeting already has an organizer.
3552
		foreach ($recipients as $key => $recipient) {
3553
			if (isset($messageProps[PR_RCVD_REPRESENTING_EMAIL_ADDRESS]) && $recipient[PR_EMAIL_ADDRESS] == $messageProps[PR_RCVD_REPRESENTING_EMAIL_ADDRESS]) {
3554
				$hasDelegator = true;
3555
			}
3556
		}
3557
3558
		if (!$hasDelegator) {
3559
			// Create delegator.
3560
			$delegator = [];
3561
			$delegator[PR_ENTRYID] = $messageProps[PR_RCVD_REPRESENTING_ENTRYID];
3562
			$delegator[PR_DISPLAY_NAME] = $messageProps[PR_RCVD_REPRESENTING_NAME];
3563
			$delegator[PR_EMAIL_ADDRESS] = $messageProps[PR_RCVD_REPRESENTING_EMAIL_ADDRESS];
3564
			$delegator[PR_RECIPIENT_TYPE] = MAPI_TO;
3565
			$delegator[PR_RECIPIENT_DISPLAY_NAME] = $messageProps[PR_RCVD_REPRESENTING_NAME];
3566
			$delegator[PR_ADDRTYPE] = empty($messageProps[PR_RCVD_REPRESENTING_ADDRTYPE]) ? 'SMTP' : $messageProps[PR_RCVD_REPRESENTING_ADDRTYPE];
3567
			$delegator[PR_RECIPIENT_TRACKSTATUS] = olRecipientTrackStatusNone;
3568
			$delegator[PR_RECIPIENT_FLAGS] = recipSendable;
3569
			$delegator[PR_SEARCH_KEY] = $messageProps[PR_RCVD_REPRESENTING_SEARCH_KEY];
3570
3571
			// Add organizer to recipients list.
3572
			array_unshift($recipients, $delegator);
3573
		}
3574
	}
3575
3576
	/**
3577
	 * Function will return delegator's store and calendar folder for processing meetings.
3578
	 *
3579
	 * @param string $receivedRepresentingEntryId entryid of the delegator user
3580
	 * @param array  $foldersToOpen               contains list of folder types that should be returned in result
3581
	 *
3582
	 * @return resource[] contains store of the delegator and resource of folders if $foldersToOpen is not empty
3583
	 *
3584
	 * @psalm-return array<resource>
3585
	 */
3586
	public function getDelegatorStore($receivedRepresentingEntryId, $foldersToOpen = []): array {
3587
		$returnData = [];
3588
3589
		$delegatorStore = $this->openCustomUserStore($receivedRepresentingEntryId);
3590
		$returnData['store'] = $delegatorStore;
3591
3592
		if (!empty($foldersToOpen)) {
3593
			for ($index = 0, $len = count($foldersToOpen); $index < $len; ++$index) {
3594
				$folderType = $foldersToOpen[$index];
3595
3596
				// first try with default folders
3597
				$folder = $this->openDefaultFolder($folderType, $delegatorStore);
3598
3599
				// if folder not found then try with base folders
3600
				if ($folder === false) {
3601
					$folder = $this->openBaseFolder($folderType, $delegatorStore);
3602
				}
3603
3604
				if ($folder === false) {
3605
					// we are still not able to get the folder so give up
3606
					continue;
3607
				}
3608
3609
				$returnData[$folderType] = $folder;
3610
			}
3611
		}
3612
3613
		return $returnData;
3614
	}
3615
3616
	/**
3617
	 * Function returns extra info about meeting timing along with message body
3618
	 * which will be included in body while sending meeting request/response.
3619
	 *
3620
	 * @return false|string $meetingTimeInfo info about meeting timing along with message body
3621
	 */
3622
	public function getMeetingTimeInfo(): false|string {
3623
		return $this->meetingTimeInfo;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->meetingTimeInfo could return the type boolean which is incompatible with the type-hinted return false|string. Consider adding an additional type-check to rule them out.
Loading history...
3624
	}
3625
3626
	/**
3627
	 * Function sets extra info about meeting timing along with message body
3628
	 * which will be included in body while sending meeting request/response.
3629
	 *
3630
	 * @param string $meetingTimeInfo info about meeting timing along with message body
3631
	 */
3632
	public function setMeetingTimeInfo(false|string $meetingTimeInfo): void {
3633
		$this->meetingTimeInfo = $meetingTimeInfo;
3634
	}
3635
3636
	/**
3637
	 * Helper function which is use to get local categories of all occurrence.
3638
	 *
3639
	 * @param mixed $calendarItem meeting request item
3640
	 * @param mixed $store        store containing calendar folder
3641
	 * @param mixed $calFolder    calendar folder
3642
	 *
3643
	 * @return array $localCategories which contain array of basedate along with categories
3644
	 */
3645
	public function getLocalCategories(mixed $calendarItem, mixed $store, mixed $calFolder): array {
3646
		$calendarItemProps = mapi_getprops($calendarItem);
3647
		$recurrence = new Recurrence($store, $calendarItem);
3648
3649
		// Retrieve all occurrences(max: 30 occurrence because recurrence can also be set as 'no end date')
3650
		$items = $recurrence->getItems($calendarItemProps[$this->proptags['clipstart']], $calendarItemProps[$this->proptags['clipend']] * (24 * 24 * 60), 30);
3651
		$localCategories = [];
3652
3653
		foreach ($items as $item) {
3654
			$recurrenceItems = $recurrence->getCalendarItems($store, $calFolder, $item[$this->proptags['startdate']], $item[$this->proptags['duedate']], [$this->proptags['goid'], $this->proptags['busystatus'], $this->proptags['categories']]);
3655
			foreach ($recurrenceItems as $recurrenceItem) {
3656
				// Check if occurrence is exception then get the local categories of that occurrence.
3657
				if (isset($recurrenceItem[$this->proptags['goid']]) && $recurrenceItem[$this->proptags['goid']] == $calendarItemProps[$this->proptags['goid']]) {
3658
					$exceptionAttach = $recurrence->getExceptionAttachment($recurrenceItem['basedate']);
3659
3660
					if ($exceptionAttach) {
3661
						$exception = mapi_attach_openobj($exceptionAttach, 0);
3662
						$exceptionProps = mapi_getprops($exception, [$this->proptags['categories']]);
3663
						if (isset($exceptionProps[$this->proptags['categories']])) {
3664
							$localCategories[$recurrenceItem['basedate']] = $exceptionProps[$this->proptags['categories']];
3665
						}
3666
					}
3667
				}
3668
			}
3669
		}
3670
3671
		return $localCategories;
3672
	}
3673
3674
	/**
3675
	 * Helper function which is use to apply local categories on respective occurrences.
3676
	 *
3677
	 * @param mixed $calendarItem    meeting request item
3678
	 * @param mixed $store           store containing calendar folder
3679
	 * @param array $localCategories array contains basedate and array of categories
3680
	 */
3681
	public function applyLocalCategories(mixed $calendarItem, mixed $store, array $localCategories): void {
3682
		$calendarItemProps = mapi_getprops($calendarItem, [PR_PARENT_ENTRYID, PR_ENTRYID]);
3683
		$message = mapi_msgstore_openentry($store, $calendarItemProps[PR_ENTRYID]);
3684
		$recurrence = new Recurrence($store, $message);
3685
3686
		// Check for all occurrence if it is exception then modify the exception by setting up categories,
3687
		// Otherwise create new exception with categories.
3688
		foreach ($localCategories as $key => $value) {
3689
			if ($recurrence->isException($key)) {
3690
				$recurrence->modifyException([$this->proptags['categories'] => $value], $key);
3691
			}
3692
			else {
3693
				$recurrence->createException([$this->proptags['categories'] => $value], $key, false);
3694
			}
3695
			mapi_savechanges($message);
3696
		}
3697
	}
3698
3699
	/**
3700
	 * Check if a message is from a delegate (received representing someone else).
3701
	 *
3702
	 * @param array $messageprops Message properties
3703
	 *
3704
	 * @return bool True if message is from delegate
3705
	 */
3706
	private function isMessageFromDelegate(array $messageprops): bool {
3707
		return isset($messageprops[PR_RCVD_REPRESENTING_NAME]);
3708
	}
3709
3710
	/**
3711
	 * Calculate the busy status for an accepted meeting based on tentative flag and intended status.
3712
	 *
3713
	 * @param bool  $tentative Whether acceptance is tentative
3714
	 * @param array $props     Message properties containing intended busy status
3715
	 *
3716
	 * @return int The calculated busy status
3717
	 */
3718
	private function calculateBusyStatus(bool $tentative, array $props): int {
3719
		if (isset($props[$this->proptags['intendedbusystatus']])) {
3720
			if ($tentative && $props[$this->proptags['intendedbusystatus']] !== fbFree) {
3721
				return fbTentative;
3722
			}
3723
3724
			return $props[$this->proptags['intendedbusystatus']];
3725
		}
3726
3727
		return $tentative ? fbTentative : fbBusy;
3728
	}
3729
3730
	/**
3731
	 * Set reply time and name properties when user responds to a meeting.
3732
	 *
3733
	 * @param array $props Properties array to update (passed by reference)
3734
	 */
3735
	private function setReplyTimeAndName(array &$props): void {
3736
		$addrInfo = $this->getOwnerAddress($this->store);
3737
3738
		// if user has responded then set replytime and name
3739
		$props[$this->proptags['replytime']] = time();
3740
		if (!empty($addrInfo)) {
3741
			// @FIXME conditionally set this property only for delegation case
3742
			$props[$this->proptags['apptreplyname']] = $addrInfo[0];
3743
		}
3744
	}
3745
3746
	/**
3747
	 * Correct the flagdueby (reminder time) property.
3748
	 * Some clients (mainly OL) can generate wrong flagdueby time, so regenerate it.
3749
	 *
3750
	 * @param array $props Properties array to update (passed by reference)
3751
	 */
3752
	private function correctReminderTime(array &$props): void {
3753
		if (isset($props[$this->proptags['reminderminutes']])) {
3754
			$props[$this->proptags['flagdueby']] = $props[$this->proptags['startdate']] - ($props[$this->proptags['reminderminutes']] * 60);
3755
		}
3756
	}
3757
3758
	/**
3759
	 * Determine the appropriate response status based on user action and tentative flag.
3760
	 *
3761
	 * @param bool $userAction Whether this is a user-initiated action
3762
	 * @param bool $tentative  Whether the response is tentative
3763
	 *
3764
	 * @return int The response status constant
3765
	 */
3766
	private function determineResponseStatus(bool $userAction, bool $tentative): int {
3767
		if (!$userAction) {
3768
			return olResponseNotResponded;
3769
		}
3770
3771
		return $tentative ? olResponseTentative : olResponseAccepted;
3772
	}
3773
3774
	/**
3775
	 * Sets sender or representing address properties in message props.
3776
	 *
3777
	 * @param array  $messageprops reference to message properties array
3778
	 * @param array  $addrInfo     address info array from getOwnerAddress
3779
	 * @param string $prefix       property prefix ('SENDER' or 'SENT_REPRESENTING')
3780
	 */
3781
	private function setAddressProperties(array &$messageprops, array $addrInfo, string $prefix): void {
3782
		if (empty($addrInfo)) {
3783
			return;
3784
		}
3785
3786
		[$ownername, $owneremailaddr, $owneraddrtype, $ownerentryid, $ownersearchkey] = $addrInfo;
3787
3788
		$messageprops[constant("PR_{$prefix}_EMAIL_ADDRESS")] = $owneremailaddr;
3789
		$messageprops[constant("PR_{$prefix}_NAME")] = $ownername;
3790
		$messageprops[constant("PR_{$prefix}_ADDRTYPE")] = $owneraddrtype;
3791
		$messageprops[constant("PR_{$prefix}_ENTRYID")] = $ownerentryid;
3792
		$messageprops[constant("PR_{$prefix}_SEARCH_KEY")] = $ownersearchkey;
3793
	}
3794
3795
	/**
3796
	 * Resolves the appropriate store and calendar folder for delegate scenarios.
3797
	 *
3798
	 * When a meeting request is received by a delegate, this method opens the
3799
	 * delegator's store and calendar folder instead of the delegate's own.
3800
	 *
3801
	 * @param array $messageprops message properties containing delegate information
3802
	 *
3803
	 * @return array{store: mixed, calFolder: mixed} array with 'store' and 'calFolder' keys
3804
	 */
3805
	private function resolveDelegateStoreAndCalendar(array $messageprops): array {
3806
		$store = $this->store;
3807
		$calFolder = $this->openDefaultCalendar();
3808
3809
		// If this meeting request is received by a delegate then open delegator's store
3810
		if (isset($messageprops[PR_RCVD_REPRESENTING_ENTRYID])) {
3811
			$delegatorStore = $this->getDelegatorStore($messageprops[PR_RCVD_REPRESENTING_ENTRYID], [PR_IPM_APPOINTMENT_ENTRYID]);
3812
			if (!empty($delegatorStore['store'])) {
3813
				$store = $delegatorStore['store'];
3814
			}
3815
			if (!empty($delegatorStore[PR_IPM_APPOINTMENT_ENTRYID])) {
3816
				$calFolder = $delegatorStore[PR_IPM_APPOINTMENT_ENTRYID];
3817
			}
3818
		}
3819
3820
		return ['store' => $store, 'calFolder' => $calFolder];
3821
	}
3822
3823
	/**
3824
	 * Gets all recipients from a message.
3825
	 *
3826
	 * @param resource $message     the message to get recipients from
3827
	 * @param array    $restriction optional restriction to filter recipients
3828
	 *
3829
	 * @return array array of recipient rows
3830
	 */
3831
	private function getMessageRecipients(mixed $message, ?array $restriction = null): array {
3832
		$recipientTable = mapi_message_getrecipienttable($message);
3833
3834
		return empty($restriction) ?
3835
			mapi_table_queryallrows($recipientTable, $this->recipprops) :
3836
			mapi_table_queryallrows($recipientTable, $this->recipprops, $restriction);
3837
	}
3838
3839
	/**
3840
	 * Ensures calendar write access and throws exception if denied.
3841
	 *
3842
	 * @param mixed $store the store to check calendar write access for
3843
	 *
3844
	 * @throws MAPIException with MAPI_E_NO_ACCESS if write access is denied
3845
	 */
3846
	private function ensureCalendarWriteAccess(mixed $store): void {
3847
		if ($this->checkCalendarWriteAccess($store) !== true) {
3848
			throw new MAPIException(_("Insufficient permissions"), MAPI_E_NO_ACCESS);
3849
		}
3850
	}
3851
}
3852