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 26, 2024
1 parent 347edd4 commit 505cde5
Show file tree
Hide file tree
Showing 6 changed files with 260 additions and 3 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::importEmails() */
['name' => 'Room#importEmails', 'url' => '/api/{apiVersion}/room/{token}/import-emails', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
],
];
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
58 changes: 57 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,59 @@ public function setMessageExpiration(int $seconds): DataResponse {
return new DataResponse();
}

/**
* Import a list of email attendees (comma separated values, one per line, either `"email","name"` or only `"email"`)
*
* @return DataResponse<Http::STATUS_OK, array{invites: non-negative-int, duplicates: 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, type: int<-1, 6>}, array{}>

Check failure on line 2433 in lib/Controller/RoomController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MoreSpecificReturnType

lib/Controller/RoomController.php:2433:13: MoreSpecificReturnType: The declared return type 'OCP\AppFramework\Http\DataResponse<200|400, array{duplicates?: int<0, max>, error?: 'file'|'header-email'|'header-name'|'room'|'rows', invites?: int<0, max>, message?: string, type?: int<-1, 6>}, array<never, never>>' for OCA\Talk\Controller\RoomController::importEmails is more specific than the inferred return type 'OCP\AppFramework\Http\DataResponse<200|400, array{duplicates?: int<0, max>|null, error?: 'file'|'header-email'|'header-name'|'room'|'rows', invalid?: int|null, invalidLines?: array<array-key, mixed>|null, invites?: int<0, max>|null, message?: null|string, type?: int<-1, 6>}, array<never, never>>' (see https://psalm.dev/070)
*
* 200: All entries imported successfully
* 400: Import was not successful
*/
#[NoAdminRequired]
#[RequireModeratorParticipant]
public function importEmails(bool $testRun = true): 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);

Check failure on line 2458 in lib/Controller/RoomController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidReturnStatement

lib/Controller/RoomController.php:2458:11: InvalidReturnStatement: The inferred type 'OCP\AppFramework\Http\DataResponse<200, array{duplicates: int<0, max>, invalid?: int, invalidLines?: list<int>, invites: int<0, max>, type?: int<-1, 6>}, array<never, never>>' does not match the declared return type 'OCP\AppFramework\Http\DataResponse<200|400, array{duplicates?: int<0, max>, error?: 'file'|'header-email'|'header-name'|'room'|'rows', invites?: int<0, max>, message?: string, type?: int<-1, 6>}, array<never, never>>' for OCA\Talk\Controller\RoomController::importEmails (see https://psalm.dev/128)
} catch (GuestImportException $e) {
$data = [
'error' => $e->getReason(),
];

if ($e->getErrorMessage()) {
$data['message'] = $e->getErrorMessage();
}
if ($e->getInvites()) {
$data['invites'] = $e->getInvites();
}
if ($e->getDuplicates()) {
$data['duplicates'] = $e->getDuplicates();
}
if ($e->getInvalid()) {
$data['invalid'] = $e->getInvalid();
$data['invalidLines'] = $e->getInvalidLines();
}

return new DataResponse($data, Http::STATUS_BAD_REQUEST);

Check failure on line 2478 in lib/Controller/RoomController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

LessSpecificReturnStatement

lib/Controller/RoomController.php:2478:11: LessSpecificReturnStatement: The type 'OCP\AppFramework\Http\DataResponse<400, array{duplicates?: int<0, max>|null, error: 'header-email'|'header-name'|'room'|'rows', invalid?: int<0, max>|null, invalidLines?: array<array-key, mixed>|null, invites?: int<0, max>|null, message?: null|string}, array<never, never>>' is more general than the declared return type 'OCP\AppFramework\Http\DataResponse<200|400, array{duplicates?: int<0, max>, error?: 'file'|'header-email'|'header-name'|'room'|'rows', invites?: int<0, max>, message?: string, type?: int<-1, 6>}, array<never, never>>' for OCA\Talk\Controller\RoomController::importEmails (see https://psalm.dev/129)
}

}

/**
* Get capabilities for a room
*
Expand Down
68 changes: 68 additions & 0 deletions lib/Exceptions/GuestImportException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?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 non-negative-int|null $invites
* @param non-negative-int|null $duplicates
*/
public function __construct(
protected string $reason,
protected ?string $errorMessage = null,
protected ?array $invalidLines = null,
protected ?int $invites = null,
protected ?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);
}

public function getInvalidLines(): ?array {
return $this->invalidLines;
}
}
128 changes: 127 additions & 1 deletion lib/GuestManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
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 OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\Defaults;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
Expand All @@ -24,6 +29,7 @@
use OCP\IUserSession;
use OCP\Mail\IMailer;
use OCP\Util;
use Psr\Log\LoggerInterface;

class GuestManager {
public function __construct(
Expand All @@ -36,6 +42,7 @@ public function __construct(
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?: int, invalidLines?: list<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 address on line: ' . $line);
$invalidLines[] = $line;
continue;
}

if ($name !== null && strtolower($name) === $email) {
$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 where invalid: %s', implode(', ', $invalidLines)),
$invalidLines,
count($emailsToAdd),
$duplicates,
);
}

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

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

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

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

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 Expand Up @@ -142,7 +268,7 @@ public function sendEmailInvitation(Room $room, Participant $participant): void
$message->setTo([$email]);
$message->useTemplate($template);
try {
$this->mailer->send($message);
// FIXME MUST NOT COMMIT $this->mailer->send($message);

$event = new EmailInvitationSentEvent($room, $participant->getAttendee());
$this->dispatcher->dispatchTyped($event);
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

0 comments on commit 505cde5

Please sign in to comment.