Passed
Push — master ( 6d2c79...4d8b4e )
by Roeland
23:36 queued 11:49
created

EmailProvider::getTitleFromVEvent()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 2
dl 0
loc 6
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright Copyright (c) 2019, Thomas Citharel
7
 * @copyright Copyright (c) 2019, Georg Ehrke
8
 *
9
 * @author Christoph Wurst <[email protected]>
10
 * @author Georg Ehrke <[email protected]>
11
 * @author Roeland Jago Douma <[email protected]>
12
 * @author Thomas Citharel <[email protected]>
13
 *
14
 * @license GNU AGPL version 3 or any later version
15
 *
16
 * This program is free software: you can redistribute it and/or modify
17
 * it under the terms of the GNU Affero General Public License as
18
 * published by the Free Software Foundation, either version 3 of the
19
 * License, or (at your option) any later version.
20
 *
21
 * This program is distributed in the hope that it will be useful,
22
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
23
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
24
 * GNU Affero General Public License for more details.
25
 *
26
 * You should have received a copy of the GNU Affero General Public License
27
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
28
 *
29
 */
30
31
namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
32
33
use DateTime;
34
use OCP\IConfig;
35
use OCP\IL10N;
36
use OCP\ILogger;
37
use OCP\IURLGenerator;
38
use OCP\L10N\IFactory as L10NFactory;
39
use OCP\Mail\IEMailTemplate;
40
use OCP\Mail\IMailer;
41
use Sabre\VObject;
42
use Sabre\VObject\Component\VEvent;
43
use Sabre\VObject\Parameter;
44
use Sabre\VObject\Property;
45
46
/**
47
 * Class EmailProvider
48
 *
49
 * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
50
 */
51
class EmailProvider extends AbstractProvider {
52
53
	/** @var string */
54
	public const NOTIFICATION_TYPE = 'EMAIL';
55
56
	/** @var IMailer */
57
	private $mailer;
58
59
	/**
60
	 * @param IConfig $config
61
	 * @param IMailer $mailer
62
	 * @param ILogger $logger
63
	 * @param L10NFactory $l10nFactory
64
	 * @param IUrlGenerator $urlGenerator
65
	 */
66
	public function __construct(IConfig $config,
67
								IMailer $mailer,
68
								ILogger $logger,
69
								L10NFactory $l10nFactory,
70
								IURLGenerator $urlGenerator) {
71
		parent::__construct($logger, $l10nFactory, $urlGenerator, $config);
72
		$this->mailer = $mailer;
73
	}
74
75
	/**
76
	 * Send out notification via email
77
	 *
78
	 * @param VEvent $vevent
79
	 * @param string $calendarDisplayName
80
	 * @param array $users
81
	 * @throws \Exception
82
	 */
83
	public function send(VEvent $vevent,
84
						 string $calendarDisplayName,
85
						 array $users=[]):void {
86
		$fallbackLanguage = $this->getFallbackLanguage();
87
88
		$emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users);
89
		$emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent);
90
91
		// Quote from php.net:
92
		// If the input arrays have the same string keys, then the later value for that key will overwrite the previous one.
93
		// => if there are duplicate email addresses, it will always take the system value
94
		$emailAddresses = array_merge(
95
			$emailAddressesOfAttendees,
96
			$emailAddressesOfSharees
97
		);
98
99
		$sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage);
100
		$organizer = $this->getOrganizerEMailAndNameFromEvent($vevent);
101
102
		foreach ($sortedByLanguage as $lang => $emailAddresses) {
103
			if (!$this->hasL10NForLang($lang)) {
104
				$lang = $fallbackLanguage;
105
			}
106
			$l10n = $this->getL10NForLang($lang);
107
			$fromEMail = \OCP\Util::getDefaultEmailAddress('reminders-noreply');
108
109
			$template = $this->mailer->createEMailTemplate('dav.calendarReminder');
110
			$template->addHeader();
111
			$this->addSubjectAndHeading($template, $l10n, $vevent);
112
			$this->addBulletList($template, $l10n, $calendarDisplayName, $vevent);
113
			$template->addFooter();
114
115
			foreach ($emailAddresses as $emailAddress) {
116
				$message = $this->mailer->createMessage();
117
				$message->setFrom([$fromEMail]);
118
				if ($organizer) {
119
					$message->setReplyTo($organizer);
120
				}
121
				$message->setTo([$emailAddress]);
122
				$message->useTemplate($template);
123
124
				try {
125
					$failed = $this->mailer->send($message);
126
					if ($failed) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $failed of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
127
						$this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
128
					}
129
				} catch (\Exception $ex) {
130
					$this->logger->logException($ex, ['app' => 'dav']);
131
				}
132
			}
133
		}
134
	}
135
136
	/**
137
	 * @param IEMailTemplate $template
138
	 * @param IL10N $l10n
139
	 * @param VEvent $vevent
140
	 */
141
	private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void {
142
		$template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n));
143
		$template->addHeading($this->getTitleFromVEvent($vevent, $l10n));
144
	}
145
146
	/**
147
	 * @param IEMailTemplate $template
148
	 * @param IL10N $l10n
149
	 * @param string $calendarDisplayName
150
	 * @param array $eventData
151
	 */
152
	private function addBulletList(IEMailTemplate $template,
153
								   IL10N $l10n,
154
								   string $calendarDisplayName,
155
								   VEvent $vevent):void {
156
		$template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'),
157
			$this->getAbsoluteImagePath('actions/info.svg'));
158
159
		$template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'),
160
			$this->getAbsoluteImagePath('places/calendar.svg'));
161
162
		if (isset($vevent->LOCATION)) {
163
			$template->addBodyListItem((string) $vevent->LOCATION, $l10n->t('Where:'),
164
				$this->getAbsoluteImagePath('actions/address.svg'));
165
		}
166
		if (isset($vevent->DESCRIPTION)) {
167
			$template->addBodyListItem((string) $vevent->DESCRIPTION, $l10n->t('Description:'),
168
				$this->getAbsoluteImagePath('actions/more.svg'));
169
		}
170
	}
171
172
	/**
173
	 * @param string $path
174
	 * @return string
175
	 */
176
	private function getAbsoluteImagePath(string $path):string {
177
		return $this->urlGenerator->getAbsoluteURL(
178
			$this->urlGenerator->imagePath('core', $path)
179
		);
180
	}
181
182
	/**
183
	 * @param VEvent $vevent
184
	 * @return array|null
185
	 */
186
	private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array {
187
		if (!$vevent->ORGANIZER) {
188
			return null;
189
		}
190
191
		$organizer = $vevent->ORGANIZER;
192
		if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) {
193
			return null;
194
		}
195
196
		$organizerEMail = substr($organizer->getValue(), 7);
197
198
		$name = $organizer->offsetGet('CN');
199
		if ($name instanceof Parameter) {
200
			return [$organizerEMail => $name];
201
		}
202
203
		return [$organizerEMail];
204
	}
205
206
	/**
207
	 * @param array $emails
208
	 * @param string $defaultLanguage
209
	 * @return array
210
	 */
211
	private function sortEMailAddressesByLanguage(array $emails,
212
												  string $defaultLanguage):array {
213
		$sortedByLanguage = [];
214
215
		foreach ($emails as $emailAddress => $parameters) {
216
			if (isset($parameters['LANG'])) {
217
				$lang = $parameters['LANG'];
218
			} else {
219
				$lang = $defaultLanguage;
220
			}
221
222
			if (!isset($sortedByLanguage[$lang])) {
223
				$sortedByLanguage[$lang] = [];
224
			}
225
226
			$sortedByLanguage[$lang][] = $emailAddress;
227
		}
228
229
		return $sortedByLanguage;
230
	}
231
232
	/**
233
	 * @param VEvent $vevent
234
	 * @return array
235
	 */
236
	private function getAllEMailAddressesFromEvent(VEvent $vevent):array {
237
		$emailAddresses = [];
238
239
		if (isset($vevent->ATTENDEE)) {
240
			foreach ($vevent->ATTENDEE as $attendee) {
241
				if (!($attendee instanceof VObject\Property)) {
242
					continue;
243
				}
244
245
				$cuType = $this->getCUTypeOfAttendee($attendee);
246
				if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) {
247
					// Don't send emails to things
248
					continue;
249
				}
250
251
				$partstat = $this->getPartstatOfAttendee($attendee);
252
				if ($partstat === 'DECLINED') {
253
					// Don't send out emails to people who declined
254
					continue;
255
				}
256
				if ($partstat === 'DELEGATED') {
257
					$delegates = $attendee->offsetGet('DELEGATED-TO');
258
					if (!($delegates instanceof VObject\Parameter)) {
259
						continue;
260
					}
261
262
					$emailAddressesOfDelegates = $delegates->getParts();
263
					foreach ($emailAddressesOfDelegates as $addressesOfDelegate) {
264
						if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) {
265
							$emailAddresses[substr($addressesOfDelegate, 7)] = [];
266
						}
267
					}
268
269
					continue;
270
				}
271
272
				$emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee);
273
				if ($emailAddressOfAttendee !== null) {
274
					$properties = [];
275
276
					$langProp = $attendee->offsetGet('LANG');
277
					if ($langProp instanceof VObject\Parameter) {
278
						$properties['LANG'] = $langProp->getValue();
279
					}
280
281
					$emailAddresses[$emailAddressOfAttendee] = $properties;
282
				}
283
			}
284
		}
285
286
		if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) {
287
			$emailAddresses[$this->getEMailAddressOfAttendee($vevent->ORGANIZER)] = [];
288
		}
289
290
		return $emailAddresses;
291
	}
292
293
294
295
	/**
296
	 * @param VObject\Property $attendee
297
	 * @return string
298
	 */
299
	private function getCUTypeOfAttendee(VObject\Property $attendee):string {
300
		$cuType = $attendee->offsetGet('CUTYPE');
301
		if ($cuType instanceof VObject\Parameter) {
302
			return strtoupper($cuType->getValue());
303
		}
304
305
		return 'INDIVIDUAL';
306
	}
307
308
	/**
309
	 * @param VObject\Property $attendee
310
	 * @return string
311
	 */
312
	private function getPartstatOfAttendee(VObject\Property $attendee):string {
313
		$partstat = $attendee->offsetGet('PARTSTAT');
314
		if ($partstat instanceof VObject\Parameter) {
315
			return strtoupper($partstat->getValue());
316
		}
317
318
		return 'NEEDS-ACTION';
319
	}
320
321
	/**
322
	 * @param VObject\Property $attendee
323
	 * @return bool
324
	 */
325
	private function hasAttendeeMailURI(VObject\Property $attendee):bool {
326
		return stripos($attendee->getValue(), 'mailto:') === 0;
327
	}
328
329
	/**
330
	 * @param VObject\Property $attendee
331
	 * @return string|null
332
	 */
333
	private function getEMailAddressOfAttendee(VObject\Property $attendee):?string {
334
		if (!$this->hasAttendeeMailURI($attendee)) {
335
			return null;
336
		}
337
338
		return substr($attendee->getValue(), 7);
339
	}
340
341
	/**
342
	 * @param array $users
343
	 * @return array
344
	 */
345
	private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array {
346
		$emailAddresses = [];
347
348
		foreach ($users as $user) {
349
			$emailAddress = $user->getEMailAddress();
350
			if ($emailAddress) {
351
				$lang = $this->l10nFactory->getUserLanguage($user);
352
				if ($lang) {
353
					$emailAddresses[$emailAddress] = [
354
						'LANG' => $lang,
355
					];
356
				} else {
357
					$emailAddresses[$emailAddress] = [];
358
				}
359
			}
360
		}
361
362
		return $emailAddresses;
363
	}
364
365
	/**
366
	 * @param IL10N $l10n
367
	 * @param VEvent $vevent
368
	 * @return string
369
	 * @throws \Exception
370
	 */
371
	private function generateDateString(IL10N $l10n, VEvent $vevent):string {
372
		$isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date;
373
374
		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
375
		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
376
		/** @var \DateTimeImmutable $dtstartDt */
377
		$dtstartDt = $vevent->DTSTART->getDateTime();
0 ignored issues
show
Bug introduced by
The method getDateTime() does not exist on Sabre\VObject\Property. It seems like you code against a sub-type of Sabre\VObject\Property such as Sabre\VObject\Property\ICalendar\DateTime or Sabre\VObject\Property\VCard\DateAndOrTime. ( Ignorable by Annotation )

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

377
		/** @scrutinizer ignore-call */ 
378
  $dtstartDt = $vevent->DTSTART->getDateTime();
Loading history...
378
		/** @var \DateTimeImmutable $dtendDt */
379
		$dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime();
380
381
		$diff = $dtstartDt->diff($dtendDt);
382
383
		/** @phan-suppress-next-line PhanUndeclaredClassMethod */
384
		$dtstartDt = new \DateTime($dtstartDt->format(\DateTime::ATOM));
385
		/** @phan-suppress-next-line PhanUndeclaredClassMethod */
386
		$dtendDt = new \DateTime($dtendDt->format(\DateTime::ATOM));
387
388
		if ($isAllDay) {
389
			// One day event
390
			if ($diff->days === 1) {
391
				return $this->getDateString($l10n, $dtstartDt);
392
			}
393
394
			return implode(' - ', [
395
				$this->getDateString($l10n, $dtstartDt),
396
				$this->getDateString($l10n, $dtendDt),
397
			]);
398
		}
399
400
		$startTimezone = $endTimezone = null;
401
		if (!$vevent->DTSTART->isFloating()) {
0 ignored issues
show
Bug introduced by
The method isFloating() does not exist on Sabre\VObject\Property. It seems like you code against a sub-type of Sabre\VObject\Property such as Sabre\VObject\Property\ICalendar\DateTime. ( Ignorable by Annotation )

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

401
		if (!$vevent->DTSTART->/** @scrutinizer ignore-call */ isFloating()) {
Loading history...
402
			/** @phan-suppress-next-line PhanUndeclaredClassMethod */
403
			$startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName();
404
			/** @phan-suppress-next-line PhanUndeclaredClassMethod */
405
			$endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName();
406
		}
407
408
		$localeStart = implode(', ', [
409
			$this->getWeekDayName($l10n, $dtstartDt),
410
			$this->getDateTimeString($l10n, $dtstartDt)
411
		]);
412
413
		// always show full date with timezone if timezones are different
414
		if ($startTimezone !== $endTimezone) {
415
			$localeEnd = implode(', ', [
416
				$this->getWeekDayName($l10n, $dtendDt),
417
				$this->getDateTimeString($l10n, $dtendDt)
418
			]);
419
420
			return $localeStart
421
				. ' (' . $startTimezone . ') '
422
				. ' - '
423
				. $localeEnd
424
				. ' (' . $endTimezone . ')';
425
		}
426
427
		// Show only the time if the day is the same
428
		$localeEnd = $this->isDayEqual($dtstartDt, $dtendDt)
429
			? $this->getTimeString($l10n, $dtendDt)
430
			: implode(', ', [
431
				$this->getWeekDayName($l10n, $dtendDt),
432
				$this->getDateTimeString($l10n, $dtendDt)
433
			]);
434
435
		return $localeStart
436
			. ' - '
437
			. $localeEnd
438
			. ' (' . $startTimezone . ')';
439
	}
440
441
	/**
442
	 * @param DateTime $dtStart
443
	 * @param DateTime $dtEnd
444
	 * @return bool
445
	 */
446
	private function isDayEqual(DateTime $dtStart,
447
								DateTime $dtEnd):bool {
448
		return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
449
	}
450
451
	/**
452
	 * @param IL10N $l10n
453
	 * @param DateTime $dt
454
	 * @return string
455
	 */
456
	private function getWeekDayName(IL10N $l10n, DateTime $dt):string {
457
		return $l10n->l('weekdayName', $dt, ['width' => 'abbreviated']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $l10n->l('weekday...dth' => 'abbreviated')) could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
458
	}
459
460
	/**
461
	 * @param IL10N $l10n
462
	 * @param DateTime $dt
463
	 * @return string
464
	 */
465
	private function getDateString(IL10N $l10n, DateTime $dt):string {
466
		return $l10n->l('date', $dt, ['width' => 'medium']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $l10n->l('date', ...y('width' => 'medium')) could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
467
	}
468
469
	/**
470
	 * @param IL10N $l10n
471
	 * @param DateTime $dt
472
	 * @return string
473
	 */
474
	private function getDateTimeString(IL10N $l10n, DateTime $dt):string {
475
		return $l10n->l('datetime', $dt, ['width' => 'medium|short']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $l10n->l('datetim...th' => 'medium|short')) could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
476
	}
477
478
	/**
479
	 * @param IL10N $l10n
480
	 * @param DateTime $dt
481
	 * @return string
482
	 */
483
	private function getTimeString(IL10N $l10n, DateTime $dt):string {
484
		return $l10n->l('time', $dt, ['width' => 'short']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $l10n->l('time', ...ay('width' => 'short')) could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
485
	}
486
487
	/**
488
	 * @param VEvent $vevent
489
	 * @param IL10N $l10n
490
	 * @return string
491
	 */
492
	private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string {
493
		if (isset($vevent->SUMMARY)) {
494
			return (string)$vevent->SUMMARY;
495
		}
496
497
		return $l10n->t('Untitled event');
498
	}
499
}
500