Skip to content

Commit

Permalink
feat(invitations): Allow importing CSV email lists
Browse files Browse the repository at this point in the history
Signed-off-by: Joas Schilling <[email protected]>
  • Loading branch information
nickvergessen committed Nov 27, 2024
1 parent cc1f931 commit ee1aaa3
Show file tree
Hide file tree
Showing 11 changed files with 894 additions and 2 deletions.
2 changes: 2 additions & 0 deletions appinfo/routes/routesRoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,7 @@
['name' => 'Room#archiveConversation', 'url' => '/api/{apiVersion}/room/{token}/archive', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
/** @see \OCA\Talk\Controller\RoomController::unarchiveConversation() */
['name' => 'Room#unarchiveConversation', 'url' => '/api/{apiVersion}/room/{token}/archive', 'verb' => 'DELETE', 'requirements' => $requirementsWithToken],
/** @see \OCA\Talk\Controller\RoomController::importEmailsAsParticipants() */
['name' => 'Room#importEmailsAsParticipants', 'url' => '/api/{apiVersion}/room/{token}/import-emails', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
],
];
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
* `talk-polls-drafts` - Whether moderators can store and retrieve poll drafts
* `download-call-participants` - Whether the endpoints for moderators to download the call participants is available
* `chat-summary-api` (local) - Whether the endpoint to get summarized chat messages in a conversation is available
* `email-csv-import` - Whether the endpoint to import a CSV email list as participants exists
* `config => chat => summary-threshold` (local) - Number of unread messages that should exist to show a "Generate summary" option
* `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation
* `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran.
Expand Down
1 change: 1 addition & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class Capabilities implements IPublicCapability {
'archived-conversations-v2',
'talk-polls-drafts',
'download-call-participants',
'email-csv-import',
];

public const CONDITIONAL_FEATURES = [
Expand Down
47 changes: 46 additions & 1 deletion lib/Controller/RoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Talk\Events\BeforeRoomsFetchEvent;
use OCA\Talk\Exceptions\CannotReachRemoteException;
use OCA\Talk\Exceptions\ForbiddenException;
use OCA\Talk\Exceptions\GuestImportException;
use OCA\Talk\Exceptions\InvalidPasswordException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
Expand Down Expand Up @@ -73,6 +74,7 @@
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IPhoneNumberUtil;
use OCP\IRequest;
use OCP\IUser;
Expand Down Expand Up @@ -121,6 +123,7 @@ public function __construct(
protected Capabilities $capabilities,
protected FederationManager $federationManager,
protected BanService $banService,
protected IL10N $l,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -1204,7 +1207,7 @@ public function addParticipantToRoom(string $newParticipant, string $source = 'u
} catch (TypeException) {
}

$email = $newParticipant;
$email = strtolower($newParticipant);
$actorId = hash('sha256', $email);
try {
$this->participantService->getParticipantByActor($this->room, Attendee::ACTOR_EMAILS, $actorId);
Expand Down Expand Up @@ -2424,6 +2427,48 @@ public function setMessageExpiration(int $seconds): DataResponse {
return new DataResponse();
}

/**
* Import a list of email attendees
*
* Content format is comma separated values:
* - Header line is required and must match `"email","name"` or `"email"`
* - one entry per line
*
* Required capability: `email-csv-import`
*
* @param bool $testRun When set to true, the file is validated and no email is actually sent nor any participant added to the conversation
* @return DataResponse<Http::STATUS_OK, array{invites: non-negative-int, duplicates: non-negative-int, invalid?: non-negative-int, invalidLines?: list<non-negative-int>, type?: int<-1, 6>}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'room'|'file'|'header-email'|'header-name'|'rows', message?: string, invites?: non-negative-int, duplicates?: non-negative-int, invalid?: non-negative-int, invalidLines?: list<non-negative-int>, type?: int<-1, 6>}, array{}>
*
* 200: All entries imported successfully
* 400: Import was not successful. When message is provided the string is in user language and should be displayed as an error.
*/
#[NoAdminRequired]
#[RequireModeratorParticipant]
public function importEmailsAsParticipants(bool $testRun = false): DataResponse {
$file = $this->request->getUploadedFile('file');
if ($file === null) {
return new DataResponse([
'error' => 'file',
'message' => $this->l->t('Uploading the file failed'),
], Http::STATUS_BAD_REQUEST);
}
if ($file['error'] !== 0) {
$this->logger->error('Uploading email CSV file failed with error: ' . $file['error']);
return new DataResponse([
'error' => 'file',
'message' => $this->l->t('Uploading the file failed'),
], Http::STATUS_BAD_REQUEST);
}

try {
$data = $this->guestManager->importEmails($this->room, $file, $testRun);
return new DataResponse($data);
} catch (GuestImportException $e) {
return new DataResponse($e->getData(), Http::STATUS_BAD_REQUEST);
}

}

/**
* Get capabilities for a room
*
Expand Down
91 changes: 91 additions & 0 deletions lib/Exceptions/GuestImportException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/


namespace OCA\Talk\Exceptions;

class GuestImportException extends \Exception {
public const REASON_ROOM = 'room';
public const REASON_ROWS = 'rows';
public const REASON_HEADER_EMAIL = 'header-email';
public const REASON_HEADER_NAME = 'header-name';

/**
* @param self::REASON_* $reason
* @param list<non-negative-int>|null $invalidLines
* @param non-negative-int|null $invites
* @param non-negative-int|null $duplicates
*/
public function __construct(
protected readonly string $reason,
protected readonly ?string $errorMessage = null,
protected readonly ?array $invalidLines = null,
protected readonly ?int $invites = null,
protected readonly ?int $duplicates = null,
) {
parent::__construct();
}

/**
* @return self::REASON_*
*/
public function getReason(): string {
return $this->reason;
}

public function getErrorMessage(): ?string {
return $this->errorMessage;
}

/**
* @return non-negative-int|null
*/
public function getInvites(): ?int {
return $this->invites;
}

/**
* @return non-negative-int|null
*/
public function getDuplicates(): ?int {
return $this->duplicates;
}

/**
* @return non-negative-int|null
*/
public function getInvalid(): ?int {
return $this->invalidLines === null ? null : count($this->invalidLines);
}

/**
* @return list<non-negative-int>|null
*/
public function getInvalidLines(): ?array {
return $this->invalidLines;
}

public function getData(): array {
$data = ['error' => $this->errorMessage];
if ($this->errorMessage !== null) {
$data['message'] = $this->errorMessage;
}
if ($this->invites !== null) {
$data['invites'] = $this->invites;
}
if ($this->duplicates !== null) {
$data['duplicates'] = $this->duplicates;
}
if ($this->invalidLines !== null) {
$data['invalid'] = count($this->invalidLines);
$data['invalidLines'] = $this->invalidLines;
}

return $data;
}
}
126 changes: 126 additions & 0 deletions lib/GuestManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@
use OCA\Talk\Events\BeforeParticipantModifiedEvent;
use OCA\Talk\Events\EmailInvitationSentEvent;
use OCA\Talk\Events\ParticipantModifiedEvent;
use OCA\Talk\Exceptions\GuestImportException;
use OCA\Talk\Exceptions\RoomProperty\TypeException;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\PollService;
use OCA\Talk\Service\RoomService;
use OCP\Defaults;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
Expand All @@ -24,6 +28,7 @@
use OCP\IUserSession;
use OCP\Mail\IMailer;
use OCP\Util;
use Psr\Log\LoggerInterface;

class GuestManager {
public function __construct(
Expand All @@ -33,9 +38,11 @@ public function __construct(
protected IUserSession $userSession,
protected ParticipantService $participantService,
protected PollService $pollService,
protected RoomService $roomService,
protected IURLGenerator $url,
protected IL10N $l,
protected IEventDispatcher $dispatcher,
protected LoggerInterface $logger,
) {
}

Expand Down Expand Up @@ -69,6 +76,125 @@ public function updateName(Room $room, Participant $participant, string $display
}
}

public function validateMailAddress(string $email): bool {
return $this->mailer->validateMailAddress($email);
}

/**
* @return array{invites: non-negative-int, duplicates: non-negative-int, invalid?: non-negative-int, invalidLines?: list<non-negative-int>, type?: int<-1, 6>}
* @throws GuestImportException
*/
public function importEmails(Room $room, $file, bool $testRun): array {
if ($room->getType() === Room::TYPE_ONE_TO_ONE
|| $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER
|| $room->getType() === Room::TYPE_NOTE_TO_SELF
|| $room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE
|| $room->getObjectType() === Room::OBJECT_TYPE_VIDEO_VERIFICATION) {
throw new GuestImportException(GuestImportException::REASON_ROOM);
}

$content = fopen($file['tmp_name'], 'rb');
$details = fgetcsv($content, escape: '');
if (!isset($details[0]) || strtolower($details[0]) !== 'email') {
throw new GuestImportException(
GuestImportException::REASON_HEADER_EMAIL,
$this->l->t('Missing email field in header line'),
);
}
if (isset($details[1]) && strtolower($details[1]) !== 'name') {
throw new GuestImportException(
GuestImportException::REASON_HEADER_NAME,
$this->l->t('Missing name field in header line'),
);
}

$participants = $this->participantService->getParticipantsByActorType($room, Attendee::ACTOR_EMAILS);
$alreadyInvitedEmails = array_flip(array_map(static fn (Participant $participant): string => $participant->getAttendee()->getInvitedCloudId(), $participants));

$line = $duplicates = 0;
$emailsToAdd = $invalidLines = [];
while ($details = fgetcsv($content, escape: '')) {
$line++;
if (isset($alreadyInvitedEmails[$details[0]])) {
$this->logger->debug('Skipping import of ' . $details[0] . ' (line: ' . $line . ') as they are already invited');
$duplicates++;
continue;
}

if (count($details) > 2) {
$this->logger->debug('Invalid entry with too many fields on line: ' . $line);
$invalidLines[] = $line;
continue;
}

if (count($details) === 2) {
[$email, $name] = $details;
} else {
$email = $details[0];
$name = null;
}

$email = strtolower($email);
if (!$this->validateMailAddress($email)) {
$this->logger->debug('Invalid email "' . $email . '" on line: ' . $line);
$invalidLines[] = $line;
continue;
}

if ($name !== null && strcasecmp($name, $email) === 0) {
$name = null;
}

$actorId = hash('sha256', $email);
$alreadyInvitedEmails[$email] = $actorId;
$emailsToAdd[] = [
'email' => $email,
'actorId' => $actorId,
'name' => $name,
];
}

if ($testRun) {
if (empty($invalidLines)) {
return [
'invites' => count($emailsToAdd),
'duplicates' => $duplicates,
];
}

throw new GuestImportException(
GuestImportException::REASON_ROWS,
$this->l->t('Following lines are invalid: %s', implode(', ', $invalidLines)),
$invalidLines,
count($emailsToAdd),
$duplicates,
);
}

$data = [
'invites' => count($emailsToAdd),
'duplicates' => $duplicates,
];

try {
$this->roomService->setType($room, Room::TYPE_PUBLIC);
$data['type'] = $room->getType();
} catch (TypeException) {
}

foreach ($emailsToAdd as $add) {
$participant = $this->participantService->inviteEmailAddress($room, $add['actorId'], $add['email'], $add['name']);
$this->sendEmailInvitation($room, $participant);
}

if (!empty($invalidLines)) {
$data['invalidLines'] = $invalidLines;
$data['invalid'] = count($invalidLines);
}

return $data;
}

public function sendEmailInvitation(Room $room, Participant $participant): void {
if ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_EMAILS) {
throw new \InvalidArgumentException('Cannot send email for non-email participant actor type');
Expand Down
6 changes: 5 additions & 1 deletion lib/Service/ParticipantService.php
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ public function addCircle(Room $room, Circle $circle, array $existingParticipant
$this->addUsers($room, $newParticipants, bansAlreadyChecked: true);
}

public function inviteEmailAddress(Room $room, string $actorId, string $email): Participant {
public function inviteEmailAddress(Room $room, string $actorId, string $email, ?string $name = null): Participant {
$lastMessage = 0;
if ($room->getLastMessage() instanceof IComment) {
$lastMessage = (int)$room->getLastMessage()->getId();
Expand All @@ -822,6 +822,10 @@ public function inviteEmailAddress(Room $room, string $actorId, string $email):
ISecureRandom::CHAR_HUMAN_READABLE
));

if ($name !== null) {
$attendee->setDisplayName($name);
}

if ($room->getSIPEnabled() !== Webinary::SIP_DISABLED
&& $this->talkConfig->isSIPConfigured()) {
$attendee->setPin($this->generatePin());
Expand Down
Loading

0 comments on commit ee1aaa3

Please sign in to comment.