Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/Db/TagMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,18 @@ public function createDefaultTags(MailAccount $account): void {
}
}

/**
* @throws \OCP\DB\Exception
*/
public function deleteAll(string $userId): void {
$qb = $this->db->getQueryBuilder();
$delete = $qb->delete($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId))
);
$delete->executeStatement();
}

public function deleteDuplicates(): void {
$qb = $this->db->getQueryBuilder();
$qb->select('mt2.id')
Expand Down
6 changes: 6 additions & 0 deletions lib/UserMigration/MailAccountMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\UserMigration\Service\AccountMigrationService;
use OCA\Mail\UserMigration\Service\AppConfigMigrationService;
use OCA\Mail\UserMigration\Service\TagsMigrationService;
use OCA\Mail\UserMigration\Service\TextBlocksMigrationService;
use OCA\Mail\UserMigration\Service\TrustedSendersMigrationService;
use OCP\AppFramework\Db\DoesNotExistException;
Expand All @@ -37,6 +38,7 @@ public function __construct(
private readonly AppConfigMigrationService $appConfigMigrationService,
private readonly TrustedSendersMigrationService $trustedSendersMigrationService,
private readonly TextBlocksMigrationService $textBlocksMigrationService,
private readonly TagsMigrationService $tagsMigrationService,
) {
}

Expand All @@ -50,16 +52,19 @@ public function export(IUser $user,
$this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output);
$this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output);
$this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output);
$this->tagsMigrationService->exportTags($user, $exportDestination, $output);
}

#[\Override]
public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
$output->writeln($this->l10n->t("Importing mail accounts for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE);

$this->deleteExistingData($user, $output);

$this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output);
$this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output);
$this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output);
$newTagIds = $this->tagsMigrationService->importTags($user, $importSource, $output);
Comment thread
DerDreschner marked this conversation as resolved.

$this->accountMigrationService->scheduleBackgroundJobs($user, $output);
}
Expand All @@ -80,6 +85,7 @@ private function deleteExistingData(IUser $user, OutputInterface $output): void
$this->appConfigMigrationService->deleteAppConfiguration($user, $output);
$this->trustedSendersMigrationService->removeAllTrustedSenders($user, $output);
$this->textBlocksMigrationService->deleteAllTextBlocks($user, $output);
$this->tagsMigrationService->deleteAllTags($user, $output);
$this->accountMigrationService->deleteAllAccounts($user, $output);
}

Expand Down
156 changes: 156 additions & 0 deletions lib/UserMigration/Service/TagsMigrationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\UserMigration\Service;

use JsonException;
use OCA\Mail\Db\Tag;
use OCA\Mail\Db\TagMapper;
use OCA\Mail\UserMigration\MailAccountMigrator;
use OCP\IL10N;
use OCP\IUser;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\UserMigrationException;
use Symfony\Component\Console\Output\OutputInterface;

class TagsMigrationService {
public const TAGS_FILE = MailAccountMigrator::EXPORT_ROOT . '/tags.json';

public function __construct(
private readonly TagMapper $tagMapper,
private readonly IL10N $l10n,
) {
}

/**
* Export all tags the user currently uses.
*
* @throws UserMigrationException
*/
public function exportTags(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
$output->writeln(
$this->l10n->t('Exporting tags for user %s', [$user->getUID()]),
OutputInterface::VERBOSITY_VERBOSE
);

$tags = $this->tagMapper->getAllTagsForUser($user->getUID());

try {
$exportDestination->addFileContents(self::TAGS_FILE, json_encode($tags, JSON_THROW_ON_ERROR));
} catch (JsonException|UserMigrationException $exception) {
throw new UserMigrationException(
"Failed to export tags for user {$user->getUID()}",
previous: $exception
);
}
}

/**
* Import all tags the user used on export.
*
* @throws \OCP\UserMigration\UserMigrationException
* @throws \OCP\DB\Exception
*/
public function importTags(IUser $user, IImportSource $importSource, OutputInterface $output): array {
$output->writeln(
$this->l10n->t('Importing tags for user %s', [$user->getUID()]),
OutputInterface::VERBOSITY_VERBOSE
);

try {
$tagsFileContent = $importSource->getFileContents(self::TAGS_FILE);
} catch (UserMigrationException) {
$output->writeln(
$this->l10n->t('Tags for user %s not found. Continue...', [$user->getUID()]),
OutputInterface::VERBOSITY_VERBOSE
);

return [];
}

$tags = json_decode($tagsFileContent, true);
Comment thread
DerDreschner marked this conversation as resolved.
$this->validateTags($tags);

$newTags = [];

foreach ($tags as $tag) {
$newTag = new Tag();

$newTag->setUserId($user->getUID());
$newTag->setDisplayName($tag['displayName']);
$newTag->setImapLabel($tag['imapLabel']);
$newTag->setColor($tag['color']);
$newTag->setIsDefaultTag($tag['isDefaultTag']);

$newTags[$tag['id']] = $this->tagMapper->insert($newTag)->getId();
}
Comment thread
DerDreschner marked this conversation as resolved.

return $newTags;
}

/**
* @throws \OCP\DB\Exception
*/
public function deleteAllTags(IUser $user, OutputInterface $output): void {
$output->writeln(
$this->l10n->t('Delete existing tags for user %s', [$user->getUID()]),
OutputInterface::VERBOSITY_VERBOSE
);

$this->tagMapper->deleteAll($user->getUID());
}

/**
* Validate the parsed tags to ensure they
* have the expected structure and types.
*
* @throws UserMigrationException
*/
private function validateTags(mixed $tags): void {
$tagsArrayIsValid = is_array($tags) && array_is_list($tags);
if (!$tagsArrayIsValid) {
throw new UserMigrationException('Invalid tags export structure');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We automatically import tags from imap once first seen, hence we should also be forgiving here. Something isn't right with the tags export, go ahead, dont abort the import.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, will change it as the comment in my other PR.

}

foreach ($tags as $tag) {
$tagArrayIsValid = is_array($tag);

$idIsValid = $tagArrayIsValid
&& array_key_exists('id', $tag)
&& is_int($tag['id']);

$displayNameIsValid = $tagArrayIsValid
&& array_key_exists('displayName', $tag)
&& is_string($tag['displayName']);

$imapLabelIsValid = $tagArrayIsValid
&& array_key_exists('imapLabel', $tag)
&& is_string($tag['imapLabel']);

$colorIsValid = $tagArrayIsValid
&& array_key_exists('color', $tag)
&& is_string($tag['color']);

$isDefaultTagIsValid = $tagArrayIsValid
&& array_key_exists('isDefaultTag', $tag)
&& is_bool($tag['isDefaultTag']);

if (
!$idIsValid
|| !$displayNameIsValid
|| !$imapLabelIsValid
|| !$colorIsValid
|| !$isDefaultTagIsValid
) {
throw new UserMigrationException('Invalid tag entry');
Comment on lines +119 to +152
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thrown errors ('Invalid tags export structure' / 'Invalid tag entry') are very generic and make it hard to diagnose which entry/field failed (especially for larger exports). Consider including the failing index and/or the missing/invalid field name in the exception message so operators can identify and fix malformed exports more easily.

Suggested change
throw new UserMigrationException('Invalid tags export structure');
}
foreach ($tags as $tag) {
$tagArrayIsValid = is_array($tag);
$displayNameIsValid = $tagArrayIsValid
&& array_key_exists('displayName', $tag)
&& is_string($tag['displayName']);
$imapLabelIsValid = $tagArrayIsValid
&& array_key_exists('imapLabel', $tag)
&& is_string($tag['imapLabel']);
$colorIsValid = $tagArrayIsValid
&& array_key_exists('color', $tag)
&& is_string($tag['color']);
$isDefaultTagIsValid = $tagArrayIsValid
&& array_key_exists('isDefaultTag', $tag)
&& is_bool($tag['isDefaultTag']);
if (!$displayNameIsValid || !$imapLabelIsValid || !$colorIsValid || !$isDefaultTagIsValid) {
throw new UserMigrationException('Invalid tag entry');
throw new UserMigrationException('Invalid tags export structure: expected a JSON list of tag entries');
}
foreach ($tags as $index => $tag) {
if (!is_array($tag)) {
throw new UserMigrationException(sprintf('Invalid tag entry at index %d: expected an object/associative array', $index));
}
if (!array_key_exists('id', $tag) || (!is_int($tag['id']) && !is_string($tag['id']))) {
throw new UserMigrationException(sprintf('Invalid tag entry at index %d: missing or invalid "id"', $index));
}
if (!array_key_exists('displayName', $tag) || !is_string($tag['displayName'])) {
throw new UserMigrationException(sprintf('Invalid tag entry at index %d: missing or invalid "displayName"', $index));
}
if (!array_key_exists('imapLabel', $tag) || !is_string($tag['imapLabel'])) {
throw new UserMigrationException(sprintf('Invalid tag entry at index %d: missing or invalid "imapLabel"', $index));
}
if (!array_key_exists('color', $tag) || !is_string($tag['color'])) {
throw new UserMigrationException(sprintf('Invalid tag entry at index %d: missing or invalid "color"', $index));
}
if (!array_key_exists('isDefaultTag', $tag) || !is_bool($tag['isDefaultTag'])) {
throw new UserMigrationException(sprintf('Invalid tag entry at index %d: missing or invalid "isDefaultTag"', $index));

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhm, that's a bit too much in my opinion

}
}
}
}
165 changes: 165 additions & 0 deletions tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace Unit\UserMigration\Service;

use ChristophWurst\Nextcloud\Testing\ServiceMockObject;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Db\Tag;
use OCA\Mail\UserMigration\Service\TagsMigrationService;
use OCP\IUser;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\UserMigrationException;
use Symfony\Component\Console\Output\OutputInterface;

class TagsMigrationServiceTest extends TestCase {
private const USER_ID = '123';
private OutputInterface $output;
private IUser $user;
private IExportDestination $exportDestination;
private IImportSource $importSource;
private ServiceMockObject $serviceMock;
private TagsMigrationService $migrationService;

protected function setUp(): void {
parent::setUp();

$this->output = $this->createMock(OutputInterface::class);
$this->exportDestination = $this->createMock(IExportDestination::class);
$this->importSource = $this->createMock(IImportSource::class);

$this->user = $this->createMock(IUser::class);
$this->user->method('getUID')->willReturn(self::USER_ID);

$this->serviceMock = $this->createServiceMock(TagsMigrationService::class);
$this->migrationService = $this->serviceMock->getService();
}

public function testExportsMultipleTags(): void {
$tagsList = [$this->getTestingTag(), $this->getSuccessfulTag()];
$this->exportDestination->expects(self::once())->method('addFileContents')->with(TagsMigrationService::TAGS_FILE, json_encode($tagsList));

$this->serviceMock->getParameter('tagMapper')->method('getAllTagsForUser')->with(self::USER_ID)->willReturn($tagsList);
$this->migrationService->exportTags($this->user, $this->exportDestination, $this->output);
}
Comment thread
DerDreschner marked this conversation as resolved.

public function testExportsNoTags(): void {
$tagsList = [];

$this->serviceMock->getParameter('tagMapper')->method('getAllTagsForUser')->with(self::USER_ID)->willReturn($tagsList);
$this->exportDestination->expects(self::once())->method('addFileContents')->with(TagsMigrationService::TAGS_FILE, json_encode($tagsList));

$this->migrationService->exportTags($this->user, $this->exportDestination, $this->output);
}

public function testImportMultipleTags(): void {
$testingTag = $this->getTestingTag();
$successfulTag = $this->getSuccessfulTag();
$tagsList = [$testingTag, $successfulTag];
$this->importSource->expects(self::once())->method('getFileContents')->with(TagsMigrationService::TAGS_FILE)->willReturn(json_encode($tagsList));

$this->serviceMock->getParameter('tagMapper')->expects(self::exactly(2))->method('insert')->with(self::callback(function (Tag $writtenTag) use ($testingTag, $successfulTag): bool {
if ($this->userIdMatches($writtenTag)
&& $this->displayNameMatches($writtenTag)
&& $this->imapLabelMatches($writtenTag)
&& $this->colorMatches($writtenTag)
&& $this->isDefaultTagMatches($writtenTag)
) {
return true;
} else {
return false;
}
}))->willReturnCallback(function ($test) {
$test->setId(random_int(10, 999));
return $test;
});

$mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output);

$this->assertCount(2, $mappedTags);
$this->assertArrayHasKey($testingTag->getId(), $mappedTags);
$this->assertIsInt($mappedTags[$testingTag->getId()]);
$this->assertArrayHasKey($successfulTag->getId(), $mappedTags);
$this->assertIsInt($mappedTags[$successfulTag->getId()]);
}

public function testImportNoTags(): void {
$tagsList = [];
$this->importSource->expects(self::once())->method('getFileContents')->with(TagsMigrationService::TAGS_FILE)->willReturn(json_encode($tagsList));
$this->serviceMock->getParameter('tagMapper')->expects(self::never())->method('insert');
$mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output);
$this->assertCount(0, $mappedTags);
}

public function testImportNoFileIsBeingIgnored(): void {
$this->importSource->expects(self::once())->method('getFileContents')->with(TagsMigrationService::TAGS_FILE)->willThrowException(new UserMigrationException());
$this->serviceMock->getParameter('tagMapper')->expects(self::never())->method('insert');
$mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output);
$this->assertCount(0, $mappedTags);
}

private function getTestingTag(): Tag {
$testingTag = new Tag();

$testingTag->setId(1);
$testingTag->setUserId(self::USER_ID);
$testingTag->setImapLabel('testing');
$testingTag->setDisplayName('Testing');
$testingTag->setColor('#fff');
$testingTag->setIsDefaultTag(false);

return $testingTag;
}

private function getSuccessfulTag(): Tag {
$successfulTag = new Tag();

$successfulTag->setId(2);
$successfulTag->setUserId(self::USER_ID);
$successfulTag->setImapLabel('successful');
$successfulTag->setDisplayName('Successful');
$successfulTag->setColor('#fff');
$successfulTag->setIsDefaultTag(false);

return $successfulTag;
}

private function userIdMatches(Tag $tag): bool {
return $tag->getUserId() === self::USER_ID;
}

private function displayNameMatches(Tag $tag): bool {
$testing = $this->getTestingTag();
$successful = $this->getSuccessfulTag();

return $tag->getDisplayName() === $testing->getDisplayName() || $tag->getDisplayName() === $successful->getDisplayName();
}

private function imapLabelMatches(Tag $tag): bool {
$testing = $this->getTestingTag();
$successful = $this->getSuccessfulTag();

return $tag->getImapLabel() === $testing->getImapLabel() || $tag->getImapLabel() === $successful->getImapLabel();
}

private function colorMatches(Tag $tag): bool {
$testing = $this->getTestingTag();
$successful = $this->getSuccessfulTag();

return $tag->getColor() === $testing->getColor() || $tag->getColor() === $successful->getColor();
}

private function isDefaultTagMatches(Tag $tag): bool {
$testing = $this->getTestingTag();
$successful = $this->getSuccessfulTag();

return $tag->getIsDefaultTag() === $testing->getIsDefaultTag() || $tag->getIsDefaultTag() === $successful->getIsDefaultTag();
}
}
Loading