Passed
Push — master ( c162bd...c6df3d )
by Roeland
25:18 queued 11:39
created

EmailProvider::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 5
dl 0
loc 7
rs 10
c 0
b 0
f 0
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 Joas Schilling <[email protected]>
12
 * @author Richard Steinmetz <[email protected]>
13
 * @author Roeland Jago Douma <[email protected]>
14
 * @author Thomas Citharel <[email protected]>
15
 *
16
 * @license GNU AGPL version 3 or any later version
17
 *
18
 * This program is free software: you can redistribute it and/or modify
19
 * it under the terms of the GNU Affero General Public License as
20
 * published by the Free Software Foundation, either version 3 of the
21
 * License, or (at your option) any later version.
22
 *
23
 * This program is distributed in the hope that it will be useful,
24
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
25
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
26
 * GNU Affero General Public License for more details.
27
 *
28
 * You should have received a copy of the GNU Affero General Public License
29
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
30
 *
31
 */
32
33
namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
34
35
use DateTime;
36
use OCP\IConfig;
37
use OCP\IL10N;
38
use OCP\ILogger;
39
use OCP\IURLGenerator;
40
use OCP\L10N\IFactory as L10NFactory;
41
use OCP\Mail\IEMailTemplate;
42
use OCP\Mail\IMailer;
43
use Sabre\VObject;
44
use Sabre\VObject\Component\VEvent;
45
use Sabre\VObject\Parameter;
46
use Sabre\VObject\Property;
47
48
/**
49
 * Class EmailProvider
50
 *
51
 * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
52
 */
53
class EmailProvider extends AbstractProvider {
54
55
	/** @var string */
56
	public const NOTIFICATION_TYPE = 'EMAIL';
57
58
	/** @var IMailer */
59
	private $mailer;
60
61
	/**
62
	 * @param IConfig $config
63
	 * @param IMailer $mailer
64
	 * @param ILogger $logger
65
	 * @param L10NFactory $l10nFactory
66
	 * @param IUrlGenerator $urlGenerator
67
	 */
68
	public function __construct(IConfig $config,
69
								IMailer $mailer,
70
								ILogger $logger,
71
								L10NFactory $l10nFactory,
72
								IURLGenerator $urlGenerator) {
73
		parent::__construct($logger, $l10nFactory, $urlGenerator, $config);
74
		$this->mailer = $mailer;
75
	}
76
77
	/**
78
	 * Send out notification via email
79
	 *
80
	 * @param VEvent $vevent
81
	 * @param string $calendarDisplayName
82
	 * @param array $users
83
	 * @throws \Exception
84
	 */
85
	public function send(VEvent $vevent,
86
						 string $calendarDisplayName,
87
						 array $users = []):void {
88
		$fallbackLanguage = $this->getFallbackLanguage();
89
90
		$emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users);
91
		$emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent);
92
93
		// Quote from php.net:
94
		// If the input arrays have the same string keys, then the later value for that key will overwrite the previous one.
95
		// => if there are duplicate email addresses, it will always take the system value
96
		$emailAddresses = array_merge(
97
			$emailAddressesOfAttendees,
98
			$emailAddressesOfSharees
99
		);
100
101
		$sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage);
102
		$organizer = $this->getOrganizerEMailAndNameFromEvent($vevent);
103
104
		foreach ($sortedByLanguage as $lang => $emailAddresses) {
105
			if (!$this->hasL10NForLang($lang)) {
106
				$lang = $fallbackLanguage;
107
			}
108
			$l10n = $this->getL10NForLang($lang);
109
			$fromEMail = \OCP\Util::getDefaultEmailAddress('reminders-noreply');
110
111
			$template = $this->mailer->createEMailTemplate('dav.calendarReminder');
112
			$template->addHeader();
113
			$this->addSubjectAndHeading($template, $l10n, $vevent);
114
			$this->addBulletList($template, $l10n, $calendarDisplayName, $vevent);
115
			$template->addFooter();
116
117
			foreach ($emailAddresses as $emailAddress) {
118
				if (!$this->mailer->validateMailAddress($emailAddress)) {
119
					$this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]);
120
					continue;
121
				}
122
123
				$message = $this->mailer->createMessage();
124
				$message->setFrom([$fromEMail]);
125
				if ($organizer) {
126
					$message->setReplyTo($organizer);
127
				}
128
				$message->setTo([$emailAddress]);
129
				$message->useTemplate($template);
130
131
				try {
132
					$failed = $this->mailer->send($message);
133
					if ($failed) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $failed of type string[] 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...
134
						$this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
135
					}
136
				} catch (\Exception $ex) {
137
					$this->logger->logException($ex, ['app' => 'dav']);
138
				}
139
			}
140
		}
141
	}
142
143
	/**
144
	 * @param IEMailTemplate $template
145
	 * @param IL10N $l10n
146
	 * @param VEvent $vevent
147
	 */
148
	private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void {
149
		$template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n));
150
		$template->addHeading($this->getTitleFromVEvent($vevent, $l10n));
151
	}
152
153
	/**
154
	 * @param IEMailTemplate $template
155
	 * @param IL10N $l10n
156
	 * @param string $calendarDisplayName
157
	 * @param array $eventData
158
	 */
159
	private function addBulletList(IEMailTemplate $template,
160
								   IL10N $l10n,
161
								   string $calendarDisplayName,
162
								   VEvent $vevent):void {
163
		$template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'),
164
			$this->getAbsoluteImagePath('actions/info.png'));
165
166
		$template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'),
167
			$this->getAbsoluteImagePath('places/calendar.png'));
168
169
		if (isset($vevent->LOCATION)) {
170
			$template->addBodyListItem((string) $vevent->LOCATION, $l10n->t('Where:'),
171
				$this->getAbsoluteImagePath('actions/address.png'));
172
		}
173
		if (isset($vevent->DESCRIPTION)) {
174
			$template->addBodyListItem((string) $vevent->DESCRIPTION, $l10n->t('Description:'),
175
				$this->getAbsoluteImagePath('actions/more.png'));
176
		}
177
	}
178
179
	/**
180
	 * @param string $path
181
	 * @return string
182
	 */
183
	private function getAbsoluteImagePath(string $path):string {
184
		return $this->urlGenerator->getAbsoluteURL(
185
			$this->urlGenerator->imagePath('core', $path)
186
		);
187
	}
188
189
	/**
190
	 * @param VEvent $vevent
191
	 * @return array|null
192
	 */
193
	private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array {
194
		if (!$vevent->ORGANIZER) {
195
			return null;
196
		}
197
198
		$organizer = $vevent->ORGANIZER;
199
		if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) {
200
			return null;
201
		}
202
203
		$organizerEMail = substr($organizer->getValue(), 7);
204
205
		if (!$this->mailer->validateMailAddress($organizerEMail)) {
206
			return null;
207
		}
208
209
		$name = $organizer->offsetGet('CN');
210
		if ($name instanceof Parameter) {
211
			return [$organizerEMail => $name];
212
		}
213
214
		return [$organizerEMail];
215
	}
216
217
	/**
218
	 * @param array $emails
219
	 * @param string $defaultLanguage
220
	 * @return array
221
	 */
222
	private function sortEMailAddressesByLanguage(array $emails,
223
												  string $defaultLanguage):array {
224
		$sortedByLanguage = [];
225
226
		foreach ($emails as $emailAddress => $parameters) {
227
			if (isset($parameters['LANG'])) {
228
				$lang = $parameters['LANG'];
229
			} else {
230
				$lang = $defaultLanguage;
231
			}
232
233
			if (!isset($sortedByLanguage[$lang])) {
234
				$sortedByLanguage[$lang] = [];
235
			}
236
237
			$sortedByLanguage[$lang][] = $emailAddress;
238
		}
239
240
		return $sortedByLanguage;
241
	}
242
243
	/**
244
	 * @param VEvent $vevent
245
	 * @return array
246
	 */
247
	private function getAllEMailAddressesFromEvent(VEvent $vevent):array {
248
		$emailAddresses = [];
249
250
		if (isset($vevent->ATTENDEE)) {
251
			foreach ($vevent->ATTENDEE as $attendee) {
252
				if (!($attendee instanceof VObject\Property)) {
253
					continue;
254
				}
255
256
				$cuType = $this->getCUTypeOfAttendee($attendee);
257
				if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) {
258
					// Don't send emails to things
259
					continue;
260
				}
261
262
				$partstat = $this->getPartstatOfAttendee($attendee);
263
				if ($partstat === 'DECLINED') {
264
					// Don't send out emails to people who declined
265
					continue;
266
				}
267
				if ($partstat === 'DELEGATED') {
268
					$delegates = $attendee->offsetGet('DELEGATED-TO');
269
					if (!($delegates instanceof VObject\Parameter)) {
270
						continue;
271
					}
272
273
					$emailAddressesOfDelegates = $delegates->getParts();
274
					foreach ($emailAddressesOfDelegates as $addressesOfDelegate) {
275
						if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) {
276
							$emailAddresses[substr($addressesOfDelegate, 7)] = [];
277
						}
278
					}
279
280
					continue;
281
				}
282
283
				$emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee);
284
				if ($emailAddressOfAttendee !== null) {
285
					$properties = [];
286
287
					$langProp = $attendee->offsetGet('LANG');
288
					if ($langProp instanceof VObject\Parameter) {
289
						$properties['LANG'] = $langProp->getValue();
290
					}
291
292
					$emailAddresses[$emailAddressOfAttendee] = $properties;
293
				}
294
			}
295
		}
296
297
		if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) {
298
			$emailAddresses[$this->getEMailAddressOfAttendee($vevent->ORGANIZER)] = [];
299
		}
300
301
		return $emailAddresses;
302
	}
303
304
305
306
	/**
307
	 * @param VObject\Property $attendee
308
	 * @return string
309
	 */
310
	private function getCUTypeOfAttendee(VObject\Property $attendee):string {
311
		$cuType = $attendee->offsetGet('CUTYPE');
312
		if ($cuType instanceof VObject\Parameter) {
313
			return strtoupper($cuType->getValue());
314
		}
315
316
		return 'INDIVIDUAL';
317
	}
318
319
	/**
320
	 * @param VObject\Property $attendee
321
	 * @return string
322
	 */
323
	private function getPartstatOfAttendee(VObject\Property $attendee):string {
324
		$partstat = $attendee->offsetGet('PARTSTAT');
325
		if ($partstat instanceof VObject\Parameter) {
326
			return strtoupper($partstat->getValue());
327
		}
328
329
		return 'NEEDS-ACTION';
330
	}
331
332
	/**
333
	 * @param VObject\Property $attendee
334
	 * @return bool
335
	 */
336
	private function hasAttendeeMailURI(VObject\Property $attendee):bool {
337
		return stripos($attendee->getValue(), 'mailto:') === 0;
338
	}
339
340
	/**
341
	 * @param VObject\Property $attendee
342
	 * @return string|null
343
	 */
344
	private function getEMailAddressOfAttendee(VObject\Property $attendee):?string {
345
		if (!$this->hasAttendeeMailURI($attendee)) {
346
			return null;
347
		}
348
349
		return substr($attendee->getValue(), 7);
350
	}
351
352
	/**
353
	 * @param array $users
354
	 * @return array
355
	 */
356
	private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array {
357
		$emailAddresses = [];
358
359
		foreach ($users as $user) {
360
			$emailAddress = $user->getEMailAddress();
361
			if ($emailAddress) {
362
				$lang = $this->l10nFactory->getUserLanguage($user);
363
				if ($lang) {
364
					$emailAddresses[$emailAddress] = [
365
						'LANG' => $lang,
366
					];
367
				} else {
368
					$emailAddresses[$emailAddress] = [];
369
				}
370
			}
371
		}
372
373
		return $emailAddresses;
374
	}
375
376
	/**
377
	 * @param IL10N $l10n
378
	 * @param VEvent $vevent
379
	 * @return string
380
	 * @throws \Exception
381
	 */
382
	private function generateDateString(IL10N $l10n, VEvent $vevent):string {
383
		$isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date;
384
385
		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
386
		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
387
		/** @var \DateTimeImmutable $dtstartDt */
388
		$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

388
		/** @scrutinizer ignore-call */ 
389
  $dtstartDt = $vevent->DTSTART->getDateTime();
Loading history...
Bug introduced by
The method getDateTime() does not exist on null. ( Ignorable by Annotation )

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

388
		/** @scrutinizer ignore-call */ 
389
  $dtstartDt = $vevent->DTSTART->getDateTime();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
389
		/** @var \DateTimeImmutable $dtendDt */
390
		$dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime();
391
392
		$diff = $dtstartDt->diff($dtendDt);
393
394
		$dtstartDt = new \DateTime($dtstartDt->format(\DateTime::ATOM));
395
		$dtendDt = new \DateTime($dtendDt->format(\DateTime::ATOM));
396
397
		if ($isAllDay) {
398
			// One day event
399
			if ($diff->days === 1) {
400
				return $this->getDateString($l10n, $dtstartDt);
401
			}
402
403
			return implode(' - ', [
404
				$this->getDateString($l10n, $dtstartDt),
405
				$this->getDateString($l10n, $dtendDt),
406
			]);
407
		}
408
409
		$startTimezone = $endTimezone = null;
410
		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

410
		if (!$vevent->DTSTART->/** @scrutinizer ignore-call */ isFloating()) {
Loading history...
411
			$startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName();
412
			$endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName();
413
		}
414
415
		$localeStart = implode(', ', [
416
			$this->getWeekDayName($l10n, $dtstartDt),
417
			$this->getDateTimeString($l10n, $dtstartDt)
418
		]);
419
420
		// always show full date with timezone if timezones are different
421
		if ($startTimezone !== $endTimezone) {
422
			$localeEnd = implode(', ', [
423
				$this->getWeekDayName($l10n, $dtendDt),
424
				$this->getDateTimeString($l10n, $dtendDt)
425
			]);
426
427
			return $localeStart
428
				. ' (' . $startTimezone . ') '
429
				. ' - '
430
				. $localeEnd
431
				. ' (' . $endTimezone . ')';
432
		}
433
434
		// Show only the time if the day is the same
435
		$localeEnd = $this->isDayEqual($dtstartDt, $dtendDt)
436
			? $this->getTimeString($l10n, $dtendDt)
437
			: implode(', ', [
438
				$this->getWeekDayName($l10n, $dtendDt),
439
				$this->getDateTimeString($l10n, $dtendDt)
440
			]);
441
442
		return $localeStart
443
			. ' - '
444
			. $localeEnd
445
			. ' (' . $startTimezone . ')';
446
	}
447
448
	/**
449
	 * @param DateTime $dtStart
450
	 * @param DateTime $dtEnd
451
	 * @return bool
452
	 */
453
	private function isDayEqual(DateTime $dtStart,
454
								DateTime $dtEnd):bool {
455
		return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
456
	}
457
458
	/**
459
	 * @param IL10N $l10n
460
	 * @param DateTime $dt
461
	 * @return string
462
	 */
463
	private function getWeekDayName(IL10N $l10n, DateTime $dt):string {
464
		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...
465
	}
466
467
	/**
468
	 * @param IL10N $l10n
469
	 * @param DateTime $dt
470
	 * @return string
471
	 */
472
	private function getDateString(IL10N $l10n, DateTime $dt):string {
473
		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...
474
	}
475
476
	/**
477
	 * @param IL10N $l10n
478
	 * @param DateTime $dt
479
	 * @return string
480
	 */
481
	private function getDateTimeString(IL10N $l10n, DateTime $dt):string {
482
		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...
483
	}
484
485
	/**
486
	 * @param IL10N $l10n
487
	 * @param DateTime $dt
488
	 * @return string
489
	 */
490
	private function getTimeString(IL10N $l10n, DateTime $dt):string {
491
		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...
492
	}
493
494
	/**
495
	 * @param VEvent $vevent
496
	 * @param IL10N $l10n
497
	 * @return string
498
	 */
499
	private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string {
500
		if (isset($vevent->SUMMARY)) {
501
			return (string)$vevent->SUMMARY;
502
		}
503
504
		return $l10n->t('Untitled event');
505
	}
506
}
507