Skip to content

Commit 9c5c865

Browse files
Merge pull request #25464 from nextcloud/enhancement/parse-calendar-object-for-attendees
Parse calendar object for attendees and emit interaction events
2 parents 9c88e32 + 5366ef3 commit 9c5c865

2 files changed

Lines changed: 267 additions & 5 deletions

File tree

apps/dav/lib/Listener/CalendarContactInteractionListener.php

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@
3535
use OCP\EventDispatcher\IEventListener;
3636
use OCP\IUser;
3737
use OCP\IUserSession;
38+
use OCP\Mail\IMailer;
3839
use Psr\Log\LoggerInterface;
40+
use Sabre\VObject\Component\VEvent;
41+
use Sabre\VObject\Parameter;
42+
use Sabre\VObject\Property;
43+
use Sabre\VObject\Reader;
44+
use Throwable;
3945
use function strlen;
4046
use function strpos;
4147
use function substr;
@@ -47,39 +53,59 @@ class CalendarContactInteractionListener implements IEventListener {
4753
private $dispatcher;
4854

4955
/** @var IUserSession */
50-
private $userManager;
56+
private $userSession;
5157

5258
/** @var Principal */
5359
private $principalConnector;
5460

61+
/** @var IMailer */
62+
private $mailer;
63+
5564
/** @var LoggerInterface */
5665
private $logger;
5766

5867
public function __construct(IEventDispatcher $dispatcher,
59-
IUserSession $userManager,
68+
IUserSession $userSession,
6069
Principal $principalConnector,
70+
IMailer $mailer,
6171
LoggerInterface $logger) {
6272
$this->dispatcher = $dispatcher;
63-
$this->userManager = $userManager;
73+
$this->userSession = $userSession;
6474
$this->principalConnector = $principalConnector;
75+
$this->mailer = $mailer;
6576
$this->logger = $logger;
6677
}
6778

6879
public function handle(Event $event): void {
69-
if (($user = $this->userManager->getUser()) === null) {
80+
if (($user = $this->userSession->getUser()) === null) {
7081
// Without user context we can't do anything
7182
return;
7283
}
7384

7485
if ($event instanceof CalendarObjectCreatedEvent || $event instanceof CalendarObjectUpdatedEvent) {
7586
// users: href => principal:principals/users/admin
76-
// TODO: parse (email) attendees from the VCARD
7787
foreach ($event->getShares() as $share) {
7888
if (!isset($share['href'])) {
7989
continue;
8090
}
8191
$this->emitFromUri($share['href'], $user);
8292
}
93+
94+
// emit interaction for email attendees as well
95+
if (isset($event->getObjectData()['calendardata'])) {
96+
try {
97+
$calendar = Reader::read($event->getObjectData()['calendardata']);
98+
if ($calendar->VEVENT) {
99+
foreach ($calendar->VEVENT as $calendarEvent) {
100+
$this->emitFromObject($calendarEvent, $user);
101+
}
102+
}
103+
} catch (Throwable $e) {
104+
$this->logger->warning('Could not read calendar data for interaction events: ' . $e->getMessage(), [
105+
'exception' => $e,
106+
]);
107+
}
108+
}
83109
}
84110

85111
if ($event instanceof CalendarShareUpdatedEvent && !empty($event->getAdded())) {
@@ -114,4 +140,38 @@ private function emitFromUri(string $uri, IUser $user): void {
114140
(new ContactInteractedWithEvent($user))->setUid($uid)
115141
);
116142
}
143+
144+
private function emitFromObject(VEvent $vevent, IUser $user): void {
145+
if (!$vevent->ATTENDEE) {
146+
// Nothing left to do
147+
return;
148+
}
149+
150+
foreach ($vevent->ATTENDEE as $attendee) {
151+
if (!($attendee instanceof Property)) {
152+
continue;
153+
}
154+
155+
$cuType = $attendee->offsetGet('CUTYPE');
156+
if ($cuType instanceof Parameter && $cuType->getValue() !== 'INDIVIDUAL') {
157+
// Don't care about those
158+
continue;
159+
}
160+
161+
$mailTo = $attendee->getValue();
162+
if (strpos($mailTo, 'mailto:') !== 0) {
163+
// Doesn't look like an email
164+
continue;
165+
}
166+
$email = substr($mailTo, strlen('mailto:'));
167+
if (!$this->mailer->validateMailAddress($email)) {
168+
// This really isn't a valid email
169+
continue;
170+
}
171+
172+
$this->dispatcher->dispatchTyped(
173+
(new ContactInteractedWithEvent($user))->setEmail($email)
174+
);
175+
}
176+
}
117177
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
7+
*
8+
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
9+
*
10+
* @license GNU AGPL version 3 or any later version
11+
*
12+
* This program is free software: you can redistribute it and/or modify
13+
* it under the terms of the GNU Affero General Public License as
14+
* published by the Free Software Foundation, either version 3 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This program is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
* GNU Affero General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Affero General Public License
23+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
24+
*/
25+
26+
namespace OCA\DAV\Tests\Unit\Listener;
27+
28+
use OCA\DAV\Connector\Sabre\Principal;
29+
use OCA\DAV\Events\CalendarObjectCreatedEvent;
30+
use OCA\DAV\Events\CalendarShareUpdatedEvent;
31+
use OCA\DAV\Listener\CalendarContactInteractionListener;
32+
use OCP\Contacts\Events\ContactInteractedWithEvent;
33+
use OCP\EventDispatcher\Event;
34+
use OCP\EventDispatcher\IEventDispatcher;
35+
use OCP\IUser;
36+
use OCP\IUserSession;
37+
use OCP\Mail\IMailer;
38+
use PHPUnit\Framework\MockObject\MockObject;
39+
use Psr\Log\LoggerInterface;
40+
use Test\TestCase;
41+
42+
class CalendarContactInteractionListenerTest extends TestCase {
43+
44+
/** @var IEventDispatcher|MockObject */
45+
private $eventDispatcher;
46+
47+
/** @var IUserSession|MockObject */
48+
private $userSession;
49+
50+
/** @var Principal|MockObject */
51+
private $principalConnector;
52+
53+
/** @var LoggerInterface|MockObject */
54+
private $logger;
55+
56+
/** @var IMailer|MockObject */
57+
private $mailer;
58+
59+
/** @var CalendarContactInteractionListener */
60+
private $listener;
61+
62+
protected function setUp(): void {
63+
parent::setUp();
64+
65+
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
66+
$this->userSession = $this->createMock(IUserSession::class);
67+
$this->principalConnector = $this->createMock(Principal::class);
68+
$this->mailer = $this->createMock(IMailer::class);
69+
$this->logger = $this->createMock(LoggerInterface::class);
70+
71+
$this->listener = new CalendarContactInteractionListener(
72+
$this->eventDispatcher,
73+
$this->userSession,
74+
$this->principalConnector,
75+
$this->mailer,
76+
$this->logger
77+
);
78+
}
79+
80+
public function testParseUnrelated(): void {
81+
$event = new Event();
82+
$this->eventDispatcher->expects(self::never())->method('dispatchTyped');
83+
84+
$this->listener->handle($event);
85+
}
86+
87+
public function testHandleWithoutAnythingInteresting(): void {
88+
$event = new CalendarShareUpdatedEvent(123, [], [], [], []);
89+
$user = $this->createMock(IUser::class);
90+
$this->userSession->expects(self::once())->method('getUser')->willReturn($user);
91+
$this->eventDispatcher->expects(self::never())->method('dispatchTyped');
92+
93+
$this->listener->handle($event);
94+
}
95+
96+
public function testParseInvalidData(): void {
97+
$event = new CalendarObjectCreatedEvent(123, [], [], ['calendardata' => 'BEGIN:FOO']);
98+
$user = $this->createMock(IUser::class);
99+
$this->userSession->expects(self::once())->method('getUser')->willReturn($user);
100+
$this->eventDispatcher->expects(self::never())->method('dispatchTyped');
101+
$this->logger->expects(self::once())->method('warning');
102+
103+
$this->listener->handle($event);
104+
}
105+
106+
public function testParseCalendarEventWithInvalidEmail(): void {
107+
$event = new CalendarObjectCreatedEvent(123, [], [], ['calendardata' => <<<EVENT
108+
BEGIN:VCALENDAR
109+
VERSION:2.0
110+
CALSCALE:GREGORIAN
111+
PRODID:-//IDN nextcloud.com//Calendar app 2.1.3//EN
112+
BEGIN:VTIMEZONE
113+
TZID:Europe/Vienna
114+
BEGIN:DAYLIGHT
115+
TZOFFSETFROM:+0100
116+
TZOFFSETTO:+0200
117+
TZNAME:CEST
118+
DTSTART:19700329T020000
119+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
120+
END:DAYLIGHT
121+
BEGIN:STANDARD
122+
TZOFFSETFROM:+0200
123+
TZOFFSETTO:+0100
124+
TZNAME:CET
125+
DTSTART:19701025T030000
126+
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
127+
END:STANDARD
128+
END:VTIMEZONE
129+
BEGIN:VEVENT
130+
CREATED:20210202T091151Z
131+
DTSTAMP:20210203T130231Z
132+
LAST-MODIFIED:20210203T130231Z
133+
SEQUENCE:9
134+
UID:b74a0c8e-93b0-447f-aed5-b679b19e874a
135+
DTSTART;TZID=Europe/Vienna:20210202T103000
136+
DTEND;TZID=Europe/Vienna:20210202T133000
137+
SUMMARY:tes
138+
ORGANIZER;CN=admin:mailto:christoph.wurst@nextcloud.com
139+
ATTENDEE;CN=somethingbutnotanemail;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;
140+
ROLE=REQ-PARTICIPANT;RSVP=FALSE:mailto:somethingbutnotanemail
141+
DESCRIPTION:test
142+
END:VEVENT
143+
END:VCALENDAR
144+
EVENT]);
145+
$user = $this->createMock(IUser::class);
146+
$this->userSession->expects(self::once())->method('getUser')->willReturn($user);
147+
$this->eventDispatcher->expects(self::never())->method('dispatchTyped');
148+
$this->logger->expects(self::never())->method('warning');
149+
150+
$this->listener->handle($event);
151+
}
152+
153+
public function testParseCalendarEvent(): void {
154+
$event = new CalendarObjectCreatedEvent(123, [], [], ['calendardata' => <<<EVENT
155+
BEGIN:VCALENDAR
156+
VERSION:2.0
157+
CALSCALE:GREGORIAN
158+
PRODID:-//IDN nextcloud.com//Calendar app 2.1.3//EN
159+
BEGIN:VTIMEZONE
160+
TZID:Europe/Vienna
161+
BEGIN:DAYLIGHT
162+
TZOFFSETFROM:+0100
163+
TZOFFSETTO:+0200
164+
TZNAME:CEST
165+
DTSTART:19700329T020000
166+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
167+
END:DAYLIGHT
168+
BEGIN:STANDARD
169+
TZOFFSETFROM:+0200
170+
TZOFFSETTO:+0100
171+
TZNAME:CET
172+
DTSTART:19701025T030000
173+
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
174+
END:STANDARD
175+
END:VTIMEZONE
176+
BEGIN:VEVENT
177+
CREATED:20210202T091151Z
178+
DTSTAMP:20210203T130231Z
179+
LAST-MODIFIED:20210203T130231Z
180+
SEQUENCE:9
181+
UID:b74a0c8e-93b0-447f-aed5-b679b19e874a
182+
DTSTART;TZID=Europe/Vienna:20210202T103000
183+
DTEND;TZID=Europe/Vienna:20210202T133000
184+
SUMMARY:tes
185+
ORGANIZER;CN=admin:mailto:christoph.wurst@nextcloud.com
186+
ATTENDEE;CN=user@domain.tld;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;
187+
ROLE=REQ-PARTICIPANT;RSVP=FALSE:mailto:user@domain.tld
188+
DESCRIPTION:test
189+
END:VEVENT
190+
END:VCALENDAR
191+
EVENT]);
192+
$user = $this->createMock(IUser::class);
193+
$this->userSession->expects(self::once())->method('getUser')->willReturn($user);
194+
$this->mailer->expects(self::once())->method('validateMailAddress')->willReturn(true);
195+
$this->eventDispatcher->expects(self::once())
196+
->method('dispatchTyped')
197+
->with(self::equalTo((new ContactInteractedWithEvent($user))->setEmail('user@domain.tld')));
198+
$this->logger->expects(self::never())->method('warning');
199+
200+
$this->listener->handle($event);
201+
}
202+
}

0 commit comments

Comments
 (0)