diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 6eba519334..85b48c0c1f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -15,6 +15,7 @@ use OCA\Mail\Contracts\IAvatarService; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IDkimValidator; +use OCA\Mail\Contracts\IInternalAddressService; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; @@ -61,6 +62,7 @@ use OCA\Mail\Service\AvatarService; use OCA\Mail\Service\DkimService; use OCA\Mail\Service\DkimValidator; +use OCA\Mail\Service\InternalAddressService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\Search\MailSearch; @@ -121,6 +123,7 @@ public function register(IRegistrationContext $context): void { $context->registerServiceAlias(IMailManager::class, MailManager::class); $context->registerServiceAlias(IMailSearch::class, MailSearch::class); $context->registerServiceAlias(IMailTransmission::class, MailTransmission::class); + $context->registerServiceAlias(IInternalAddressService::class, InternalAddressService::class); $context->registerServiceAlias(ITrustedSenderService::class, TrustedSenderService::class); $context->registerServiceAlias(IUserPreferences::class, UserPreferenceService::class); $context->registerServiceAlias(IDkimService::class, DkimService::class); diff --git a/lib/Contracts/IInternalAddressService.php b/lib/Contracts/IInternalAddressService.php index 9dc69747cb..d1ffb7b33f 100644 --- a/lib/Contracts/IInternalAddressService.php +++ b/lib/Contracts/IInternalAddressService.php @@ -16,6 +16,8 @@ public function isInternal(string $uid, string $address): bool; public function add(string $uid, string $address, string $type, ?bool $trust = true); + public function removeInternalAddresses(string $uid): void; + /** * @param string $uid * @return InternalAddress[] diff --git a/lib/Contracts/ITrustedSenderService.php b/lib/Contracts/ITrustedSenderService.php index 06df620c4a..90bd96c2f6 100644 --- a/lib/Contracts/ITrustedSenderService.php +++ b/lib/Contracts/ITrustedSenderService.php @@ -16,6 +16,8 @@ public function isTrusted(string $uid, string $email): bool; public function trust(string $uid, string $email, string $type, ?bool $trust = true); + public function removeTrusted(string $uid): void; + /** * @param string $uid * @return TrustedSender[] diff --git a/lib/Db/ActionsMapper.php b/lib/Db/ActionsMapper.php index f04319fbe1..a83dec4768 100644 --- a/lib/Db/ActionsMapper.php +++ b/lib/Db/ActionsMapper.php @@ -53,5 +53,22 @@ public function findAll(string $owner) { return $this->findEntities($qb); } + public function deleteAll(string $owner) { + $qb = $this->db->getQueryBuilder(); + $actionIds = $this->db->getQueryBuilder() + ->select('actions.id') + ->from($this->getTableName(), 'actions') + ->join('actions', 'mail_accounts', 'accounts', $qb->expr()->eq('actions.account_id', 'accounts.id')) + ->where( + $qb->expr()->eq('accounts.user_id', $qb->createNamedParameter($owner, IQueryBuilder::PARAM_STR)) + ) + ->executeQuery()->fetchAllAssociative(); + + $delete = $qb->delete($this->getTableName()) + ->where( + $qb->expr()->in('id', $qb->createNamedParameter($actionIds, IQueryBuilder::PARAM_INT_ARRAY)) + ); + $delete->executeStatement(); + } } diff --git a/lib/Db/InternalAddressMapper.php b/lib/Db/InternalAddressMapper.php index eda65fc77c..b3bb3b60cc 100644 --- a/lib/Db/InternalAddressMapper.php +++ b/lib/Db/InternalAddressMapper.php @@ -11,6 +11,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; use OCP\IDBConnection; /** @@ -100,4 +101,14 @@ public function find(string $uid, string $address): ?InternalAddress { return null; } } + + /** + * @throws Exception + */ + public function removeAll(string $uid) : void { + $qb = $this->db->getQueryBuilder(); + $delete = $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($uid))); + $delete->executeStatement(); + } } diff --git a/lib/Db/TagMapper.php b/lib/Db/TagMapper.php index c3b1a169c1..3868aa4a1a 100644 --- a/lib/Db/TagMapper.php +++ b/lib/Db/TagMapper.php @@ -228,6 +228,15 @@ public function createDefaultTags(MailAccount $account): void { } } + 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') diff --git a/lib/Db/TextBlockMapper.php b/lib/Db/TextBlockMapper.php index 439aa4cac0..a16bb9a2a8 100644 --- a/lib/Db/TextBlockMapper.php +++ b/lib/Db/TextBlockMapper.php @@ -73,4 +73,13 @@ public function findSharedWithMe(string $userId, array $groups) { return $this->findEntities($qb); } + public function deleteAll(string $owner) { + $qb = $this->db->getQueryBuilder(); + $delete = $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('owner', $qb->createNamedParameter($owner, IQueryBuilder::PARAM_STR)) + ); + $delete->executeStatement(); + } + } diff --git a/lib/Db/TrustedSenderMapper.php b/lib/Db/TrustedSenderMapper.php index 791e81a3c9..7ea6fb92d6 100644 --- a/lib/Db/TrustedSenderMapper.php +++ b/lib/Db/TrustedSenderMapper.php @@ -83,4 +83,11 @@ public function findAll(string $uid): array { ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($uid))); return $this->findEntities($select); } + + public function deleteAll(string $uid) { + $qb = $this->db->getQueryBuilder(); + $delete = $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($uid))); + $delete->executeStatement(); + } } diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 77a03da560..09de75672f 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -167,13 +167,19 @@ public function deleteByAccountId(int $accountId): void { /** * @param MailAccount $newAccount + * @param bool $scheduleBackgroundJobs Optional parameter to save the mail + * account without scheduling the corresponding background jobs. This can + * be useful if further database modifications must be done before + * running any background jobs. Defaults to `true`. * @return MailAccount */ - public function save(MailAccount $newAccount): MailAccount { + public function save(MailAccount $newAccount, bool $scheduleBackgroundJobs = true): MailAccount { $newAccount = $this->mapper->save($newAccount); - // Insert background jobs for this account - $this->scheduleBackgroundJobs($newAccount->getId()); + if ($scheduleBackgroundJobs) { + // Insert background jobs for this account + $this->scheduleBackgroundJobs($newAccount->getId()); + } return $newAccount; } diff --git a/lib/Service/AliasesService.php b/lib/Service/AliasesService.php index 62f4538fd6..91cc460dce 100644 --- a/lib/Service/AliasesService.php +++ b/lib/Service/AliasesService.php @@ -15,6 +15,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ClientException; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\Exception; class AliasesService { /** @var AliasMapper */ @@ -133,4 +134,16 @@ public function updateSignature(string $userId, int $aliasId, ?string $signature $entity->setSignature($signature); return $this->aliasMapper->update($entity); } + + /** + * Update the S/MIME certificate for alias. + * + * @throws DoesNotExistException + * @throws Exception + */ + public function updateSmimeCertificateId(string $userId, int $aliasId, ?int $smimeCertificateId = null): Alias { + $entity = $this->find($aliasId, $userId); + $entity->setSmimeCertificateId($smimeCertificateId); + return $this->aliasMapper->update($entity); + } } diff --git a/lib/Service/InternalAddressService.php b/lib/Service/InternalAddressService.php index 34ace7a608..5289b3ecc0 100644 --- a/lib/Service/InternalAddressService.php +++ b/lib/Service/InternalAddressService.php @@ -52,6 +52,11 @@ public function add(string $uid, string $address, string $type, ?bool $trust = t return null; } + #[\Override] + public function removeInternalAddresses(string $uid): void { + $this->mapper->removeAll($uid); + } + #[\Override] public function getInternalAddresses(string $uid): array { return $this->mapper->findAll($uid); diff --git a/lib/Service/QuickActionsService.php b/lib/Service/QuickActionsService.php index af6ce3cfc3..08f648e555 100644 --- a/lib/Service/QuickActionsService.php +++ b/lib/Service/QuickActionsService.php @@ -85,6 +85,14 @@ public function delete(int $actionId, string $userId): void { $this->actionsMapper->delete($action); } + public function deleteAll(string $userId): void { + $allActions = $this->actionsMapper->findAll($userId); + + foreach ($allActions as $action) { + $this->actionsMapper->delete($action); + } + } + /** * @throws DoesNotExistException */ diff --git a/lib/Service/TextBlockService.php b/lib/Service/TextBlockService.php index 1f4a4316f6..8fcbf1a997 100644 --- a/lib/Service/TextBlockService.php +++ b/lib/Service/TextBlockService.php @@ -88,6 +88,10 @@ public function delete(int $textBlockId, string $userId): void { $this->textBlockShareMapper->deleteByTextBlockId($textBlockId); } + public function deleteAll(string $userId): void { + $this->textBlockMapper->deleteAll($userId); + } + /** * @throws UserNotFoundException diff --git a/lib/Service/TrustedSenderService.php b/lib/Service/TrustedSenderService.php index d9b355316e..18c5630415 100644 --- a/lib/Service/TrustedSenderService.php +++ b/lib/Service/TrustedSenderService.php @@ -72,4 +72,8 @@ public function trust(string $uid, string $email, string $type, ?bool $trust = t public function getTrusted(string $uid): array { return $this->mapper->findAll($uid); } + + public function removeTrusted(string $uid): void { + $this->mapper->deleteAll($uid); + } } diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index 27d81644d9..6b76db0d86 100644 --- a/lib/UserMigration/MailAccountMigrator.php +++ b/lib/UserMigration/MailAccountMigrator.php @@ -9,18 +9,31 @@ namespace OCA\Mail\UserMigration; -use Exception; use JsonException; -use OCA\Mail\Db\Alias; -use OCA\Mail\Db\MailAccount; -use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\AliasesService; +use OCA\Mail\AppInfo\Application; +use OCA\Mail\Contracts\IInternalAddressService; +use OCA\Mail\Contracts\ITrustedSenderService; +use OCA\Mail\Db\ActionStep; +use OCA\Mail\Db\ActionStepMapper; +use OCA\Mail\Db\Tag; +use OCA\Mail\Db\TagMapper; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\QuickActionsService; +use OCA\Mail\Service\SmimeService; +use OCA\Mail\Service\TextBlockService; +use OCA\Mail\UserMigration\Service\AccountMigrationService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\Exception; +use OCP\IConfig; use OCP\IL10N; use OCP\IUser; +use OCP\PreConditionNotMetException; use OCP\Security\ICrypto; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; +use OCP\UserMigration\TMigratorBasicVersionHandling; use OCP\UserMigration\UserMigrationException; use Symfony\Component\Console\Output\OutputInterface; use function array_map; @@ -28,176 +41,404 @@ use function json_encode; class MailAccountMigrator implements IMigrator { + use TMigratorBasicVersionHandling; + + public const EXPORT_ROOT = Application::APP_ID; + public const FILENAME_PLACEHOLDER = '{filename}'; + private const INTERNAL_ADDRESSES_FILE = self::EXPORT_ROOT . '/internal_addresses.json'; + private const TRUSTED_SENDERS_FILE = self::EXPORT_ROOT . '/trusted_senders.json'; + private const TEXT_BLOCKS_FILE = self::EXPORT_ROOT . '/text_blocks.json'; + private const QUICK_ACTIONS_FILE = self::EXPORT_ROOT . '/quick_actions.json'; + private const TAGS_FILE = self::EXPORT_ROOT . '/tags.json'; + private const APP_CONFIGURATION = self::EXPORT_ROOT . '/app_configuration.json'; + private const SMIME_CERTIFICATE_FILES = self::EXPORT_ROOT . '/certificates/' . self::FILENAME_PLACEHOLDER . '.json'; public function __construct( - private AccountService $accountService, - private AliasesService $aliasesService, - private IL10N $l10n, - private ICrypto $crypto, + private readonly AccountMigrationService $accountMigrationService, + private readonly IInternalAddressService $internalAddressService, + private readonly ITrustedSenderService $trustedSenderService, + private readonly TextBlockService $textBlockService, + private readonly QuickActionsService $quickActionsService, + private readonly ActionStepMapper $actionStepMapper, + private readonly TagMapper $tagMapper, + private readonly IConfig $config, + private readonly SmimeService $smimeService, + private readonly IL10N $l10n, + private readonly ICrypto $crypto, ) { } - public function export(IUser $user, - IExportDestination $exportDestination, - OutputInterface $output, - ): void { - $accounts = $this->accountService->findByUserId($user->getUID()); - $index = []; - foreach ($accounts as $account) { - if ($account->getMailAccount()->getProvisioningId() !== null) { - // These configuration of these accounts is owned by the admins - $output->writeln("Skipping provisioned account {$account->getId()}"); - continue; - } - - $accountFilePath = "mail/accounts/{$account->getId()}.json"; - $accountData = $account->jsonSerialize(); - - if ($account->getMailAccount()->getAuthMethod() === 'password') { - $encryptedInboundPassword = $account->getMailAccount()->getInboundPassword(); - $encryptedOutboundPassword = $account->getMailAccount()->getOutboundPassword(); - if ($encryptedInboundPassword !== null) { - try { - $accountData['inboundPassword'] = $this->crypto->decrypt($encryptedInboundPassword); - } catch (Exception $e) { - $output->writeln("Can not decrypt inbound password of account {$account->getId()}: " . $e->getMessage()); - } - } - if ($encryptedOutboundPassword !== null) { - try { - $accountData['outboundPassword'] = $this->crypto->decrypt($encryptedOutboundPassword); - } catch (Exception $e) { - $output->writeln("Can not decrypt outbound password of account {$account->getId()}: " . $e->getMessage()); - } - } - } elseif ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { - $encryptedRefreshToken = $account->getMailAccount()->getOauthRefreshToken(); - $encryptedAccessToken = $account->getMailAccount()->getOauthAccessToken(); - if ($encryptedRefreshToken !== null) { - try { - $accountData['oauthRefreshToken'] = $this->crypto->decrypt($encryptedRefreshToken); - } catch (Exception $e) { - $output->writeln("Can not decrypt oauth refresh token of account {$account->getId()}: " . $e->getMessage()); - } - } - if ($encryptedAccessToken !== null) { - try { - $accountData['oauthAccessToken'] = $this->crypto->decrypt($encryptedAccessToken); - } catch (Exception $e) { - $output->writeln("Can not decrypt oauth access token of account {$account->getId()}: " . $e->getMessage()); - } - } - $accountData['oauthTokenTtl'] = $account->getMailAccount()->getOauthTokenTtl(); - } - - unset( - $accountData['draftsMailboxId'], - $accountData['sentMailboxId'], - $accountData['trashMailboxId'], - $accountData['archiveMailboxId'], - $accountData['snoozeMailboxId'], - $accountData['junkMailboxId'], - ); - - $aliases = $this->aliasesService->findAll( - $account->getId(), - $account->getUserId(), // perf: this adds overhead - add dedicated method to fetch by account id only - ); - $accountData['aliases'] = array_map(function (Alias $alias) { - $data = $alias->jsonSerialize(); - return $data; - }, $aliases); - - $exportDestination->addFileContents($accountFilePath, json_encode($accountData)); - $index[$account->getId()] = $accountFilePath; + /** + * {@inheritDoc} + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @return void + * @throws ServiceException + * @throws UserMigrationException + */ + public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $this->accountMigrationService->exportAccounts($user, $exportDestination, $output); + $this->exportAppConfiguration($user, $exportDestination, $output); + $this->exportInternalAddresses($user, $exportDestination, $output); + $this->exportTrustedSenders($user, $exportDestination, $output); + $this->exportTextBlocks($user, $exportDestination, $output); + $this->exportQuickActions($user, $exportDestination, $output); + $this->exportTags($user, $exportDestination, $output); + $this->exportCertificates($user, $exportDestination, $output); + } + + /** + * Export the user configuration stored via IConfig. + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @return void + * @throws UserMigrationException + */ + private function exportAppConfiguration(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID); + $appConfigSettings = array_map(function (string $appConfigKey) use ($user) { + return [ + 'key' => $appConfigKey, + 'value' => $this->config->getUserValue($user->getUID(), Application::APP_ID, $appConfigKey) + ]; + }, $appConfigKeys); + $exportDestination->addFileContents(self::APP_CONFIGURATION, json_encode($appConfigSettings)); + } + + /** + * Export all addresses the user defined as internal ones + * on export. + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @throws UserMigrationException + */ + private function exportInternalAddresses(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $internalAddresses = $this->internalAddressService->getInternalAddresses($user->getUID()); + $exportDestination->addFileContents(self::INTERNAL_ADDRESSES_FILE, json_encode($internalAddresses)); + } + + /** + * Export all addresses the user defined as trustworthy. + * + * @throws UserMigrationException + */ + private function exportTrustedSenders(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $trustedSenders = $this->trustedSenderService->getTrusted($user->getUID()); + $exportDestination->addFileContents(self::TRUSTED_SENDERS_FILE, json_encode($trustedSenders)); + } + + /** + * Export all text blocks the user created itself. + * This does not include those shared with others. + * + * @throws UserMigrationException + */ + private function exportTextBlocks(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $textBlocks = $this->textBlockService->findAll($user->getUID()); + $exportDestination->addFileContents(self::TEXT_BLOCKS_FILE, json_encode($textBlocks)); + } + + /** + * Export all quick actions the user defined across + * their accounts. + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @throws UserMigrationException + */ + private function exportQuickActions(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $quickActions = $this->quickActionsService->findAll($user->getUID()); + $exportDestination->addFileContents(self::QUICK_ACTIONS_FILE, json_encode($quickActions)); + } + + /** + * Export all tags the user currently uses. + * + * @throws UserMigrationException + */ + private function exportTags(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $tags = $this->tagMapper->getAllTagsForUser($user->getUID()); + $exportDestination->addFileContents(self::TAGS_FILE, json_encode($tags)); + } + + /** + * Export all S/MIME certificates added by the user + * on export. + * + * @throws UserMigrationException + * @throws ServiceException + * @throws \Exception + */ + private function exportCertificates(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $certificates = $this->smimeService->findAllCertificates($user->getUID()); + $mimeCerts = []; + + foreach ($certificates as $mimeCert) { + $mimeCerts[$mimeCert->getId()] = [ + 'id' => $mimeCert->getId(), + 'certificate' => $this->crypto->decrypt($mimeCert->getCertificate()), + 'privateKey' => $mimeCert->getPrivateKey() !== null ? $this->crypto->decrypt($mimeCert->getPrivateKey()) : null, + ]; + + $exportDestination->addFileContents(str_replace(self::FILENAME_PLACEHOLDER, (string)$mimeCert->getId(), self::SMIME_CERTIFICATE_FILES), json_encode($mimeCerts[$mimeCert->getId()])); } - $exportDestination->addFileContents('mail/accounts/index.json', json_encode($index)); + $exportDestination->addFileContents(str_replace(self::FILENAME_PLACEHOLDER, 'index', self::SMIME_CERTIFICATE_FILES), json_encode($mimeCerts)); } + /** + * {@inheritDoc} + * + * @return string + */ + public function getId(): string { + return 'mail_account'; + } + + /** + * {@inheritDoc} + * + * @param IUser $user + * @param IImportSource $importSource + * @param OutputInterface $output + * @throws ClientException + * @throws DoesNotExistException + * @throws Exception + * @throws JsonException + * @throws PreConditionNotMetException + * @throws ServiceException + * @throws UserMigrationException + */ public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { - try { - $index = json_decode($importSource->getFileContents('mail/accounts/index.json'), true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new UserMigrationException("Invalid index content: {$e->getMessage()}", $e->getCode(), $e); + $this->deleteExistingData($user, $output); + + $this->importAppConfiguration($user, $importSource); + $this->importInternalAddresses($user, $importSource); + $this->importTrustedSenders($user, $importSource); + $this->importTextBlocks($user, $importSource); + $tagMapping = $this->importTags($user, $importSource); + $certificatesMapping = $this->importCertificates($user, $importSource); + $accountAndMailboxMappings = $this->accountMigrationService->importAccounts($user, $importSource, $certificatesMapping, $output); + $this->importQuickActions($importSource, $accountAndMailboxMappings['accounts'], $accountAndMailboxMappings['mailboxes'], $tagMapping); + + $this->accountMigrationService->scheduleBackgroundJobs($user, $output); + } + + /** + * Delete all existing user data of our app to ensure + * the result of the import is always the same. + * + * @param IUser $user + * @param OutputInterface $output + * @throws ClientException + * @throws DoesNotExistException + * @throws ServiceException + */ + private function deleteExistingData(IUser $user, OutputInterface $output): void { + $this->deleteAppConfiguration($user); + $this->internalAddressService->removeInternalAddresses($user->getUID()); + $this->trustedSenderService->removeTrusted($user->getUID()); + $this->textBlockService->deleteAll($user->getUID()); + $this->tagMapper->deleteAll($user->getUID()); + $this->deleteAllUserCertificates($user); + $this->accountMigrationService->deleteAllAccounts($user, $output); + $this->quickActionsService->deleteAll($user->getUID()); + } + + /** + * Delete the user configuration stored via IConfig. + * + * @param IUser $user + * @return void + */ + private function deleteAppConfiguration(IUser $user): void { + $appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID); + foreach ($appConfigKeys as $appConfigKey) { + $this->config->deleteUserValue($user->getUID(), Application::APP_ID, $appConfigKey); } - foreach ($index as $accountFilePath) { - try { - $accountData = json_decode($importSource->getFileContents($accountFilePath), true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new UserMigrationException("Invalid account content: {$e->getMessage()}", $e->getCode(), $e); - } - - // Wipe the old ID(s) to prevent overwrites - unset( - $accountData['id'], - $accountData['accountId'], - ); - - $newAccount = new MailAccount($accountData); - - // Change UID to new owner - $newAccount->setUserId($user->getUID()); - // Map the rest of the properties that are not mapped via the constructor - $newAccount->setName($accountData['name']); - $newAccount->setAuthMethod($accountData['authMethod']); - $newAccount->setEditorMode($accountData['editorMode'] ?? 'plain'); - $newAccount->setSearchBody($accountData['searchBody'] ?? false); - $newAccount->setClassificationEnabled($accountData['classificationEnabled'] ?? false); - $newAccount->setSignatureAboveQuote($accountData['signatureAboveQuote'] ?? false); - $newAccount->setPersonalNamespace($accountData['personalNamespace'] ?? null); - if (isset($accountData['inboundPassword'])) { - $newAccount->setInboundPassword($this->crypto->encrypt($accountData['inboundPassword'])); - } - if (isset($accountData['outboundPassword'])) { - $newAccount->setOutboundPassword($this->crypto->encrypt($accountData['outboundPassword'])); - } - if (isset($accountData['oauthRefreshToken'])) { - $newAccount->setOauthRefreshToken($this->crypto->encrypt($accountData['oauthRefreshToken'])); - } - if (isset($accountData['oauthAccessToken'])) { - $newAccount->setOauthAccessToken($this->crypto->encrypt($accountData['oauthAccessToken'])); - } - $newAccount->setOauthTokenTtl($accountData['oauthTokenTtl'] ?? null); - - $mailAccount = $this->accountService->save( - $newAccount - ); - - // Import aliases - foreach ($accountData['aliases'] as $alias) { - $this->aliasesService->create( - $user->getUID(), - $mailAccount->getId(), - $alias['alias'], - $alias['name'], - ); - } + } + + /** + * Delete all S/MIME certificates added by the user. + * + * @param IUser $user + * @return void + * @throws DoesNotExistException + * @throws \OCA\Mail\Exception\ServiceException + */ + private function deleteAllUserCertificates(IUser $user): void { + $allCertificates = $this->smimeService->findAllCertificates($user->getUID()); + foreach ($allCertificates as $cert) { + $this->smimeService->deleteCertificate($cert->getId(), $user->getUID()); } } - public function getId(): string { - return 'mail_account'; + /** + * Import the user configuration stored via IConfig + * on export. + * + * @throws UserMigrationException + * @throws PreConditionNotMetException + * @throws JsonException + */ + private function importAppConfiguration(IUser $user, IImportSource $importSource): void { + $appConfigs = json_decode($importSource->getFileContents(self::APP_CONFIGURATION), true, flags: JSON_THROW_ON_ERROR); + + foreach ($appConfigs as $appConfig) { + $this->config->setUserValue($user->getUID(), Application::APP_ID, $appConfig['key'], $appConfig['value']); + } } - public function getDisplayName(): string { - return $this->l10n->t('Mail'); + /** + * Import all addresses the user defined as internal ones. + * + * @throws UserMigrationException + * @throws JsonException + */ + private function importInternalAddresses(IUser $user, IImportSource $importSource): void { + $internalAddresses = json_decode($importSource->getFileContents(self::INTERNAL_ADDRESSES_FILE), true, flags: JSON_THROW_ON_ERROR); + + foreach ($internalAddresses as $internalAddress) { + $this->internalAddressService->add($user->getUID(), $internalAddress['address'], $internalAddress['type']); + } } - public function getDescription(): string { - return $this->l10n->t('Mail account parameters, aliases and preferences'); + /** + * Import all addresses the user defined as trustworthy + * on export. + * + * @throws UserMigrationException + * @throws JsonException + */ + private function importTrustedSenders(IUser $user, IImportSource $importSource): void { + $trustedSenders = json_decode($importSource->getFileContents(self::TRUSTED_SENDERS_FILE), true, flags: JSON_THROW_ON_ERROR); + + foreach ($trustedSenders as $trustedSender) { + $this->trustedSenderService->trust($user->getUID(), $trustedSender['email'], $trustedSender['type']); + } + } + + /** + * Import all text blocks the user created itself on export. + * This does not include those shared with others. + * + * @throws UserMigrationException + * @throws JsonException + */ + private function importTextBlocks(IUser $user, IImportSource $importSource): void { + $textBlocks = json_decode($importSource->getFileContents(self::TEXT_BLOCKS_FILE), true, flags: JSON_THROW_ON_ERROR); + + foreach ($textBlocks as $textBlock) { + $this->textBlockService->create($user->getUID(), $textBlock['title'], $textBlock['content']); + } + } + + /** + * Import all tags the user used on export. + * + * @param IUser $user + * @param IImportSource $importSource + * @return array + * @throws Exception + * @throws JsonException + * @throws UserMigrationException + */ + private function importTags(IUser $user, IImportSource $importSource): array { + $tags = json_decode($importSource->getFileContents(self::TAGS_FILE), true, flags: JSON_THROW_ON_ERROR); + $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(); + } + + return $newTags; } - public function getVersion(): int { - return 01_00_00; + /** + * Import all S/MIME certificates added by the user. + * + * @return array Contains the old certificate ID as array key and the new + * certificate ID as value. + * + * @throws UserMigrationException + * @throws JsonException + * @throws ServiceException + */ + private function importCertificates(IUser $user, IImportSource $importSource): array { + $certificates = json_decode($importSource->getFileContents(str_replace(self::FILENAME_PLACEHOLDER, 'index', self::SMIME_CERTIFICATE_FILES)), true, flags: JSON_THROW_ON_ERROR); + $certificatesMapping = []; + + foreach ($certificates as $certificateFilePath) { + $certificate = json_decode($importSource->getFileContents($certificateFilePath), true, flags: JSON_THROW_ON_ERROR); + $newCertificate = $this->smimeService->createCertificate($user->getUID(), $certificate['certificate'], $certificate['privateKey']); + + $oldCertificateId = $certificate['id']; + $certificatesMapping[$oldCertificateId] = $newCertificate->getId(); + } + + return $certificatesMapping; } - public function canImport(IImportSource $importSource): bool { - try { - return $importSource->getMigratorVersion($this->getId()) <= $this->getVersion(); - } catch (UserMigrationException) { - return false; + /** + * Import all quick actions the user defined across + * their accounts. + * + * @throws UserMigrationException + * @throws Exception + * @throws JsonException + */ + private function importQuickActions(IImportSource $importSource, array $accountMapping, array $mailboxMapping, array $tagMapping): void { + $quickActions = json_decode($importSource->getFileContents(self::QUICK_ACTIONS_FILE), true, flags: JSON_THROW_ON_ERROR); + + foreach ($quickActions as $quickAction) { + $action = $this->quickActionsService->create($quickAction['name'], $accountMapping[$quickAction['accountId']]); + $action->setIcon($quickAction['icon']); + $actionSteps = array_map(function ($step) use ($action, $mailboxMapping, $tagMapping) { + $actionStep = new ActionStep(); + + $actionStep->setName($step['name']); + $actionStep->setOrder($step['order']); + $actionStep->setActionId($action->getId()); + $actionStep->setMailboxId($mailboxMapping[$step['mailboxId']]); + $actionStep->setTagId($tagMapping[$step['tagId']]); + + return $this->actionStepMapper->insert($actionStep); + }, $quickAction['actionSteps']); + $action->setActionSteps($actionSteps); } } + /** + * {@inheritDoc} + * + * @return string + */ + public function getDisplayName(): string { + return $this->l10n->t('Mail'); + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function getDescription(): string { + return $this->l10n->t('Mail app settings and manually configured accounts'); + } + } diff --git a/lib/UserMigration/Service/AccountMigrationService.php b/lib/UserMigration/Service/AccountMigrationService.php new file mode 100644 index 0000000000..eec72bc9cc --- /dev/null +++ b/lib/UserMigration/Service/AccountMigrationService.php @@ -0,0 +1,472 @@ +accountService->findByUserId($user->getUID()); + + foreach ($allAccounts as $account) { + $this->accountService->deleteByAccountId($account->getId()); + } + } + + /** + * Exports all mail accounts for the given user. + * This includes the mailboxes (without messages), + * aliases and Sieve settings. + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @return void + * @throws UserMigrationException + * @throws \Exception + */ + public function exportAccounts(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $accounts = $this->accountService->findByUserId($user->getUID()); + $exportedAccounts = []; + + foreach ($accounts as $account) { + $mailAccount = $account->getMailAccount(); + + $isProvisionedAccount = $mailAccount->getProvisioningId() !== null; + if ($isProvisionedAccount) { + $output->writeln("Skipping provisioned account {$account->getId()}"); + continue; + } + + $accountData = $account->jsonSerialize(); + + $this->getDecryptedPasswords($mailAccount, $accountData, $output); + $this->getDecryptedOauthToken($mailAccount, $accountData, $output); + $this->getDecryptedSievePassword($mailAccount, $accountData, $output); + $this->getMailboxes($account, $accountData, $output); + $this->getAliases($account, $accountData, $output); + + $accountFilePath = str_replace(MailAccountMigrator::FILENAME_PLACEHOLDER, (string)$account->getId(), self::ACCOUNT_FILES); + $exportDestination->addFileContents($accountFilePath, json_encode($accountData)); + $exportedAccounts[$account->getId()] = $accountFilePath; + } + + $exportDestination->addFileContents(str_replace(MailAccountMigrator::FILENAME_PLACEHOLDER, 'index', self::ACCOUNT_FILES), json_encode($exportedAccounts)); + } + + /** + * Import all mail accounts for the given user existing + * on export. This includes the mailboxes (without messages), + * aliases and Sieve settings. + * + * @param IUser $user + * @param IImportSource $importSource + * @param array $certificatesMapping + * @param OutputInterface $output + * @return array + * @throws \JsonException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\DB\Exception + * @throws \OCP\UserMigration\UserMigrationException + */ + public function importAccounts(IUser $user, IImportSource $importSource, array $certificatesMapping, OutputInterface $output): array { + $accounts = $this->getAccounts($importSource, $output); + $accountAndMailboxMappings = []; + + foreach ($accounts as $accountData) { + $newAccount = new MailAccount(); + + // Set general account information + $newAccount->setUserId($user->getUID()); + $newAccount->setName($accountData['name']); + $newAccount->setEmail($accountData['emailAddress']); + + // Set general settings + $newAccount->setShowSubscribedOnly($accountData['showSubscribedOnly']); + + $oldCertificateId = $accountData['smimeCertificateId']; + $newAccount->setSmimeCertificateId($certificatesMapping[$oldCertificateId] ?? null); + $newAccount->setEditorMode($accountData['editorMode'] ?? 'plaintext'); + $newAccount->setTrashRetentionDays($accountData['trashRetentionDays']); + $newAccount->setOooFollowsSystem($accountData['ooFollowsSystem']); + $newAccount->setImipCreate($accountData['imipCreate']); + $newAccount->setClassificationEnabled($accountData['classificationEnabled']); + $newAccount->setSearchBody($accountData['searchBody']); + + // Set signature options + $newAccount->setSignature($accountData['signature']); + $newAccount->setSignatureAboveQuote($accountData['signatureAboveQuote']); + + // Set inbound connection + $newAccount->setInboundHost($accountData['imapHost']); + $newAccount->setInboundPort($accountData['imapPort']); + $newAccount->setInboundSslMode($accountData['imapSslMode']); + + // Set outbound connection + $newAccount->setOutboundHost($accountData['smtpHost']); + $newAccount->setOutboundPort($accountData['smtpPort']); + $newAccount->setOutboundSslMode($accountData['smtpSslMode']); + + // Set authentication settings for IMAP and SMTP + $newAccount->setAuthMethod($accountData['authMethod']); + $this->setPasswords($newAccount, $accountData, $output); + $this->setOauthToken($newAccount, $accountData, $output); + + // Set sieve settings + $this->setSieveSettings($newAccount, $accountData, $output); + + $mailAccount = $this->accountService->save( + $newAccount, false + ); + + $oldAccountId = $accountData['accountId']; + $accountAndMailboxMappings['accounts'][$oldAccountId] = $mailAccount->getId(); + + $this->setAliases($mailAccount, $accountData, $certificatesMapping, $output); + + $mailboxesMapping = $this->setMailboxes($mailAccount, $accountData, $output); + $accountAndMailboxMappings['mailboxes'] = $mailboxesMapping; + } + + return $accountAndMailboxMappings; + } + + /** + * Schedule background jobs for the added accounts. + * Necessary to do after all data is being imported as we + * could run into race conditions when doing directly after + * saving each mail account into database. + * + * @param IUser $user + * @param OutputInterface $output + * @return void + */ + public function scheduleBackgroundJobs(IUser $user, OutputInterface $output): void { + $accounts = $this->accountService->findByUserId($user->getUID()); + + foreach ($accounts as $account) { + $mailAccount = $account->getMailAccount(); + $this->accountService->scheduleBackgroundJobs($mailAccount->getId()); + } + } + + /** + * Gets the decrypted IMAP and SMTP passwords and + * stores them in `$accountData`. Only happens when + * the mail account is configured to use password + * authentication. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return void + * @throws \Exception + */ + private function getDecryptedPasswords(MailAccount $mailAccount, array &$accountData, OutputInterface $output): void { + if ($mailAccount->getAuthMethod() === 'password') { + $encryptedInboundPassword = $mailAccount->getInboundPassword(); + $accountData['inboundPassword'] = $this->crypto->decrypt($encryptedInboundPassword); + + $encryptedOutboundPassword = $mailAccount->getOutboundPassword(); + $accountData['outboundPassword'] = $this->crypto->decrypt($encryptedOutboundPassword); + } + } + + /** + * Gets the decrypted oauth2 access and refresh tokens and + * stores them in `$accountData` together with the TTL. + * Only happens when the mail account is configured to + * use oauth2 authentication. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return void + * @throws \Exception + */ + private function getDecryptedOauthToken(MailAccount $mailAccount, array &$accountData, OutputInterface $output): void { + if ($mailAccount->getAuthMethod() === 'xoauth2') { + $encryptedRefreshToken = $mailAccount->getOauthRefreshToken(); + $accountData['oauthRefreshToken'] = $this->crypto->decrypt($encryptedRefreshToken); + + $encryptedAccessToken = $mailAccount->getOauthAccessToken(); + $accountData['oauthAccessToken'] = $this->crypto->decrypt($encryptedAccessToken); + + $accountData['oauthTokenTtl'] = $mailAccount->getOauthTokenTtl(); + } + } + + /** + * Decrypts the password to connect to the sieve + * server and stores it in `$accountData`. Only + * happens when the mail account has a sieve + * connection configured and a password set. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return void + * @throws \Exception + */ + private function getDecryptedSievePassword(MailAccount $mailAccount, array &$accountData, OutputInterface $output): void { + if ($mailAccount->isSieveEnabled()) { + $encryptedSievePassword = $mailAccount->getSievePassword(); + + if ($encryptedSievePassword !== null) { + $accountData['sievePassword'] = $this->crypto->decrypt($encryptedSievePassword); + } + } + } + + /** + * Gets all mailboxes for the given account and + * saves it to `$accountData`. + * + * @param Account $account + * @param array $accountData + * @param OutputInterface $output + * @return void + */ + private function getMailboxes(Account $account, array &$accountData, OutputInterface $output): void { + $mailboxes = $this->mailboxMapper->findAll($account); + $accountData['mailboxes'] = array_map(function (Mailbox $mailbox) { + return $mailbox->jsonSerialize(); + }, $mailboxes); + } + + /** + * Gets all aliases for the given account and + * saves it to `$accountData`. + * + * @param Account $account + * @param array $accountData + * @param OutputInterface $output + * @return void + */ + private function getAliases(Account $account, array &$accountData, OutputInterface $output): void { + $aliases = $this->aliasesService->findAll( + $account->getId(), + $account->getUserId(), // perf: this adds overhead - add dedicated method to fetch by account id only + ); + $accountData['aliases'] = array_map(function (Alias $alias) { + return $alias->jsonSerialize(); + }, $aliases); + } + + + /** + * Gets all existing mail accounts on export. + * + * @param IImportSource $importSource + * @param OutputInterface $output + * @return array + * @throws UserMigrationException + * @throws \JsonException + */ + private function getAccounts(IImportSource $importSource, OutputInterface $output): array { + $accountFilePaths = json_decode($importSource->getFileContents(str_replace(MailAccountMigrator::FILENAME_PLACEHOLDER, 'index', self::ACCOUNT_FILES)), true, flags: JSON_THROW_ON_ERROR); + + return array_map(function (string $accountFilePath) use ($importSource, $output) { + return json_decode($importSource->getFileContents($accountFilePath), true, flags: JSON_THROW_ON_ERROR); + }, $accountFilePaths); + } + + /** + * Encrypts the IMAP and SMTP password and saves + * them to the mail account. Only happens when the mail + * account is configured to use password authentication. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return void + */ + private function setPasswords(MailAccount $mailAccount, array $accountData, OutputInterface $output): void { + if ($mailAccount->getAuthMethod() === 'password') { + $mailAccount->setInboundUser($accountData['imapUser']); + $mailAccount->setInboundPassword($this->crypto->encrypt($accountData['inboundPassword'])); + + $mailAccount->setOutboundUser($accountData['smtpUser']); + $mailAccount->setOutboundPassword($this->crypto->encrypt($accountData['outboundPassword'])); + } + } + + /** + * Encrypts the Oauth2 access and refresh tokens and + * saves them to the mail account together with the TTL. + * This only happens when the mail account is configured + * to use oauth2 authentication. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return void + */ + private function setOauthToken(MailAccount $mailAccount, array $accountData, OutputInterface $output): void { + if ($mailAccount->getAuthMethod() === 'xoauth2') { + $mailAccount->setOauthRefreshToken($this->crypto->encrypt($accountData['oauthRefreshToken'])); + $mailAccount->setOauthAccessToken($this->crypto->encrypt($accountData['oauthAccessToken'])); + $mailAccount->setOauthTokenTtl($accountData['oauthTokenTtl']); + } + } + + /** + * S + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return void + */ + private function setSieveSettings(MailAccount $mailAccount, array $accountData, OutputInterface $output): void { + $sieveEnabled = (bool)$accountData['sieveEnabled']; + $mailAccount->setSieveEnabled($sieveEnabled); + + if ($sieveEnabled) { + $mailAccount->setSieveHost($accountData['sieveHost']); + $mailAccount->setSievePort($accountData['sievePort']); + $mailAccount->setSieveSslMode($accountData['sieveSslMode']); + + // Sieve can use the IMAP credentials, which + // is indicated by empty username and password. + $useCustomCredentials = isset($accountData['sieveUser']) && isset($accountData['sievePassword']); + if ($useCustomCredentials) { + $mailAccount->setSieveUser($accountData['sieveUser']); + $mailAccount->setSievePassword($this->crypto->encrypt($accountData['sievePassword'])); + } + } + } + + /** + * Imports all aliases for the given mail account + * on export. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param array $certificatesMapping + * @param OutputInterface $output + * @return void + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\DB\Exception + */ + private function setAliases(MailAccount $mailAccount, array $accountData, array $certificatesMapping, OutputInterface $output): void { + foreach ($accountData['aliases'] as $alias) { + $userId = $mailAccount->getUserId(); + + $newAlias = $this->aliasesService->create( + $userId, + $mailAccount->getId(), + $alias['alias'], + $alias['name'], + ); + + $this->aliasesService->updateSignature($userId, $newAlias->getId(), (string)$alias['signature']); + + $oldCertificateId = (int)$alias['smimeCertificateId']; + $this->aliasesService->updateSmimeCertificateId($userId, $newAlias->getId(), $certificatesMapping[$oldCertificateId]); + } + } + + /** + * Imports all mailboxes for the given mail account. + * + * @param MailAccount $mailAccount + * @param array $accountData + * @param OutputInterface $output + * @return array Contains the old mailbox id as key and the + * new mailbox id as value. Example: `'2' => '4'` + * @throws \OCP\DB\Exception + */ + private function setMailboxes(MailAccount $mailAccount, array $accountData, OutputInterface $output): array { + $mailboxMapping = []; + + foreach ($accountData['mailboxes'] as $oldMailbox) { + $newMailbox = new Mailbox(); + + $newMailbox->setName($oldMailbox['name']); + $newMailbox->setNameHash(md5($oldMailbox['name'])); + $newMailbox->setAccountId($mailAccount->getId()); + $newMailbox->setAttributes($oldMailbox['attributes']); + $newMailbox->setDelimiter($oldMailbox['delimiter']); + $newMailbox->setMessages(0); + $newMailbox->setUnseen(0); + $newMailbox->setSelectable($oldMailbox['selectable']); + $newMailbox->setSyncInBackground($oldMailbox['syncInBackground']); + $newMailbox->setMyAcls($oldMailbox['myAcls']); + $newMailbox->setShared($oldMailbox['shared']); + + /** @var Mailbox $mailbox */ + $mailbox = $this->mailboxMapper->insert($newMailbox); + + $oldMailboxId = $oldMailbox['databaseId']; + $mailboxMapping[$oldMailboxId] = $mailbox->getId(); + + // Check if the current mailbox was used as + // special mailbox and modify the mail + // account if so. + switch ($oldMailboxId) { + case $accountData['draftsMailboxId']: + $mailAccount->setDraftsMailboxId($mailbox->getId()); + break; + case $accountData['sentMailboxId']: + $mailAccount->setSentMailboxId($mailbox->getId()); + break; + case $accountData['trashMailboxId']: + $mailAccount->setTrashMailboxId($mailbox->getId()); + break; + case $accountData['archiveMailboxId']: + $mailAccount->setArchiveMailboxId($mailbox->getId()); + break; + case $accountData['junkMailboxId']: + $mailAccount->setJunkMailboxId($mailbox->getId()); + break; + case $accountData['snoozeMailboxId']: + $mailAccount->setSnoozeMailboxId($mailbox->getId()); + break; + } + } + + $this->accountService->update($mailAccount); + + return $mailboxMapping; + } +} diff --git a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php index 66a35d937b..46fe943fdb 100644 --- a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php +++ b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php @@ -77,5 +77,4 @@ public function testMigrate(): void { $destinationAccoutns = $this->accountService->findByUserId($destinationUser->getUID()); self::assertCount(1, $destinationAccoutns); } - } diff --git a/tests/Unit/UserMigration/AccountMigrationServiceTest.php b/tests/Unit/UserMigration/AccountMigrationServiceTest.php new file mode 100644 index 0000000000..d21a4d8935 --- /dev/null +++ b/tests/Unit/UserMigration/AccountMigrationServiceTest.php @@ -0,0 +1,181 @@ + */ + private ServiceMockObject $serviceMock; + private OutputInterface|MockObject $output; + + protected function setUp(): void { + parent::setUp(); + + $this->serviceMock = $this->createServiceMock(AccountMigrationService::class); + $this->serviceMock->getParameter('crypto') + ->method('encrypt') + ->willReturnCallback(function (string $value) { + return $value . '_encrypted'; + }); + $this->serviceMock->getParameter('crypto') + ->method('decrypt') + ->willReturnCallback(function (string $encryptedValue) { + if (!str_ends_with($encryptedValue, '_encrypted')) { + throw new Exception('Invalid encrypted value'); + } + return substr($encryptedValue, 0, strlen($encryptedValue) - strlen('_encrypted')); + }); + $this->migrator = $this->serviceMock->getService(); + + $this->output = $this->createMock(OutputInterface::class); + } + + public function testExportBasicAccountInfo(): void { + $user = $this->createStub(IUser::class); + $user->method('getUID')->willReturn('user_export'); + $mailAccount1 = new MailAccount([]); + $account1 = $this->createStub(Account::class); + $account1->method('getId')->willReturn(101); + $account1->method('getUserId')->willReturn('user_export'); + $account1->method('getMailAccount')->willReturn($mailAccount1); + // $account1->method('jsonSerialize')->willReturnSelf(); + $mailAccount1->setAuthMethod('password'); + $mailAccount1->setInboundPassword('imap_pass_encrypted'); + $mailAccount1->setOutboundPassword('smtp_pass_encrypted'); + $account1->method('jsonSerialize')->willReturn([ + 'id' => 101, + 'emailAddress' => 'jane@doe.org', + ]); + $mailAccount2 = new MailAccount(); + $mailAccount2->setUserId('user_export'); + $mailAccount2->setId(102); + $mailAccount2->setAuthMethod('password'); + $mailAccount2->setInboundPassword('imap_pass_encrypted'); + $mailAccount2->setOutboundPassword('smtp_pass_encrypted'); + $account2 = new Account($mailAccount2); + // $account2->method('jsonSerialize')->willReturn([ + // 'id' => 102, + // 'emailAddress' => 'jane@doe.com', + // ]); + /** @var AccountService|MockObject $accountService */ + $accountService = $this->serviceMock->getParameter('accountService'); + $accountService->expects(self::once()) + ->method('findByUserId') + ->with('user_export') + ->willReturn([ + $account1, + $account2, + ]); + $exportDestination = $this->createMock(IExportDestination::class); + $exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $content) { + if ($path === 'mail/accounts/index.json') { + self::assertSame( + [ + 101 => 'mail/accounts/101.json', + 102 => 'mail/accounts/102.json', + ], + json_decode($content, true) + ); + } elseif ($path === 'mail/accounts/101.json') { + $accountData = json_decode($content, true); + self::assertArrayHasKey('id', $accountData); + self::assertSame(101, $accountData['id']); + self::assertArrayHasKey('inboundPassword', $accountData); + self::assertSame('imap_423pass', $accountData['inboundPassword']); + } elseif ($path === 'mail/accounts/102.json') { + $accountData = json_decode($content, true); + self::assertArrayHasKey('id', $accountData); + self::assertSame(102, $accountData['id']); + self::assertArrayHasKey('inboundPassword', $accountData); + self::assertSame('imap_pass', $accountData['inboundPassword']); + } else { + $this->fail('Invalid file content path ' . $path); + } + }); + + $this->migrator->exportAccounts( + $user, + $exportDestination, + $this->output, + ); + } + + public function testImportInvalidIndex(): void { + $this->expectException(UserMigrationException::class); + $user = $this->createMock(IUser::class); + + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getFileContents') + ->with('mail/accounts/index.json') + ->willReturn('fail'); + + $this->migrator->importAccounts( + $user, + $importSource, + [], + $this->output, + ); + } + + public function testImportBasicAccountInfo(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user_import'); + $accountData = [ + 'id' => 101, + 'userId' => 'user_export', + 'name' => 'Jane Doe', + 'emailAddress' => 'jane@doe.org', + 'authMethod' => 'password', + 'showSubscribedOnly' => null, + 'smimeCertificateId' => null, + 'aliases' => [], + ]; + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getFileContents') + ->willReturnMap([ + ['mail/accounts/index.json', json_encode([101 => 'mail/accounts/101.json'])], + ['mail/accounts/101.json', json_encode($accountData)], + ]); + $newAccount = new MailAccount([]); + $newAccount->setUserId('user_import'); + $newAccount->setName('Jane Doe'); + $newAccount->setAuthMethod('password'); + $newAccount->setEditorMode('plain'); + $newAccount->setClassificationEnabled(false); + /** @var AccountService|MockObject $accountService */ + $accountService = $this->serviceMock->getParameter('accountService'); + $accountService->expects(self::once()) + ->method('save') + ->with(self::equalTo($newAccount)) + ->willReturnArgument(0); + + $this->migrator->importAccounts( + $user, + $importSource, + [], + $this->output, + ); + } + +} diff --git a/tests/Unit/UserMigration/MailAccountMigratorTest.php b/tests/Unit/UserMigration/MailAccountMigratorTest.php index 623e84c779..b225077fb2 100644 --- a/tests/Unit/UserMigration/MailAccountMigratorTest.php +++ b/tests/Unit/UserMigration/MailAccountMigratorTest.php @@ -12,18 +12,10 @@ use ChristophWurst\Nextcloud\Testing\ServiceMockObject; use ChristophWurst\Nextcloud\Testing\TestCase; use Exception; -use OCA\Mail\Account; -use OCA\Mail\Db\MailAccount; -use OCA\Mail\Service\AccountService; use OCA\Mail\UserMigration\MailAccountMigrator; -use OCP\IUser; -use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; -use OCP\UserMigration\UserMigrationException; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Output\OutputInterface; -use function json_decode; -use function json_encode; use function substr; class MailAccountMigratorTest extends TestCase { @@ -116,126 +108,6 @@ public function testCanImportOlder(): void { self::assertTrue($canImport); } - public function testExportBasicAccountInfo(): void { - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('user_export'); - $mailAccount1 = new MailAccount([]); - $account1 = $this->createMock(Account::class); - $account1->method('getId')->willReturn(101); - $account1->method('getUserId')->willReturn('user_export'); - $account1->method('getMailAccount')->willReturn($mailAccount1); - $mailAccount1->setAuthMethod('password'); - $mailAccount1->setInboundPassword('imap_pass_encrypted'); - $account1->method('jsonSerialize')->willReturn([ - 'id' => 101, - 'email' => 'jane@doe.org', - ]); - $mailAccount2 = new MailAccount([]); - $account2 = $this->createMock(Account::class); - $account2->method('getId')->willReturn(102); - $account2->method('getUserId')->willReturn('user_export'); - $account2->method('getMailAccount')->willReturn($mailAccount2); - $mailAccount2->setAuthMethod('password'); - $mailAccount2->setInboundPassword('imap_pass_encrypted'); - $account2->method('jsonSerialize')->willReturn([ - 'id' => 102, - 'email' => 'jane@doe.com', - ]); - /** @var AccountService|MockObject $accountService */ - $accountService = $this->serviceMock->getParameter('accountService'); - $accountService->expects(self::once()) - ->method('findByUserId') - ->with('user_export') - ->willReturn([ - $account1, - $account2, - ]); - $exportDestination = $this->createMock(IExportDestination::class); - $exportDestination->method('addFileContents') - ->willReturnCallback(function (string $path, string $content) { - if ($path === 'mail/accounts/index.json') { - self::assertSame( - [ - 101 => 'mail/accounts/101.json', - 102 => 'mail/accounts/102.json', - ], - json_decode($content, true) - ); - } elseif ($path === 'mail/accounts/101.json') { - $accountData = json_decode($content, true); - self::assertArrayHasKey('id', $accountData); - self::assertSame(101, $accountData['id']); - self::assertArrayHasKey('inboundPassword', $accountData); - self::assertSame('imap_pass', $accountData['inboundPassword']); - } elseif ($path === 'mail/accounts/102.json') { - $accountData = json_decode($content, true); - self::assertArrayHasKey('id', $accountData); - self::assertSame(102, $accountData['id']); - self::assertArrayHasKey('inboundPassword', $accountData); - self::assertSame('imap_pass', $accountData['inboundPassword']); - } else { - $this->fail('Invalid file content path ' . $path); - } - }); - - $this->migrator->export( - $user, - $exportDestination, - $this->output, - ); - } - - public function testImportInvalidIndex(): void { - $this->expectException(UserMigrationException::class); - $user = $this->createMock(IUser::class); - - $importSource = $this->createMock(IImportSource::class); - $importSource->method('getFileContents') - ->with('mail/accounts/index.json') - ->willReturn('fail'); - - $this->migrator->import( - $user, - $importSource, - $this->output, - ); - } - - public function testImportBasicAccountInfo(): void { - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('user_import'); - $accountData = [ - 'id' => 101, - 'userId' => 'user_export', - 'name' => 'Jane Doe', - 'email' => 'jane@doe.org', - 'authMethod' => 'password', - 'aliases' => [], - ]; - $importSource = $this->createMock(IImportSource::class); - $importSource->method('getFileContents') - ->willReturnMap([ - ['mail/accounts/index.json', json_encode([101 => 'mail/accounts/101.json'])], - ['mail/accounts/101.json', json_encode($accountData)], - ]); - $newAccount = new MailAccount([]); - $newAccount->setUserId('user_import'); - $newAccount->setName('Jane Doe'); - $newAccount->setAuthMethod('password'); - $newAccount->setEditorMode('plain'); - $newAccount->setClassificationEnabled(false); - /** @var AccountService|MockObject $accountService */ - $accountService = $this->serviceMock->getParameter('accountService'); - $accountService->expects(self::once()) - ->method('save') - ->with(self::equalTo($newAccount)) - ->willReturnArgument(0); - $this->migrator->import( - $user, - $importSource, - $this->output, - ); - } }