diff --git a/lib/Service/AliasesService.php b/lib/Service/AliasesService.php index 62f4538fd6..4bc2fd8149 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 an 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/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index f4b3866373..e39b45636f 100644 --- a/lib/UserMigration/MailAccountMigrator.php +++ b/lib/UserMigration/MailAccountMigrator.php @@ -42,7 +42,7 @@ public function __construct( private readonly TrustedSendersMigrationService $trustedSendersMigrationService, private readonly TextBlocksMigrationService $textBlocksMigrationService, private readonly TagsMigrationService $tagsMigrationService, - private readonly SMIMEMigrationService $smimeMigrationService, + private readonly SMIMEMigrationService $sMimeMigrationService, ) { } @@ -58,7 +58,8 @@ public function export(IUser $user, $this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output); $this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output); $this->tagsMigrationService->exportTags($user, $exportDestination, $output); - $this->smimeMigrationService->exportCertificates($user, $exportDestination, $output); + $this->sMimeMigrationService->exportCertificates($user, $exportDestination, $output); + $this->accountMigrationService->exportAccounts($user, $exportDestination, $output); } #[\Override] @@ -71,8 +72,9 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $this->internalAddressesMigrationService->importInternalAddresses($user, $importSource, $output); $this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output); $this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output); + $newCertificateIds = $this->sMimeMigrationService->importCertificates($user, $importSource, $output); + $newAccountIds = $this->accountMigrationService->importAccounts($user, $importSource, $output, $newCertificateIds); $newTagIds = $this->tagsMigrationService->importTags($user, $importSource, $output); - $newCertificateIds = $this->smimeMigrationService->importCertificates($user, $importSource, $output); $this->accountMigrationService->scheduleBackgroundJobs($user, $output); } @@ -90,13 +92,14 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface private function deleteExistingData(IUser $user, OutputInterface $output): void { $output->writeln($this->l10n->t("Deleting existing mail data for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE); + $this->accountMigrationService->deleteAllAccounts($user, $output); $this->appConfigMigrationService->deleteAppConfiguration($user, $output); $this->internalAddressesMigrationService->removeInternalAddresses($user, $output); $this->trustedSendersMigrationService->removeAllTrustedSenders($user, $output); $this->textBlocksMigrationService->deleteAllTextBlocks($user, $output); $this->tagsMigrationService->deleteAllTags($user, $output); $this->accountMigrationService->deleteAllAccounts($user, $output); - $this->smimeMigrationService->deleteAllUserCertificates($user, $output); + $this->sMimeMigrationService->deleteAllUserCertificates($user, $output); } #[\Override] diff --git a/lib/UserMigration/Service/AccountMigrationService.php b/lib/UserMigration/Service/AccountMigrationService.php index 5445bfd582..debfc322f0 100644 --- a/lib/UserMigration/Service/AccountMigrationService.php +++ b/lib/UserMigration/Service/AccountMigrationService.php @@ -9,18 +9,34 @@ namespace OCA\Mail\UserMigration\Service; +use JsonException; +use OCA\Mail\Account; +use OCA\Mail\Db\Alias; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\AliasesService; +use OCA\Mail\UserMigration\MailAccountMigrator; use OCP\IL10N; use OCP\IUser; use OCP\Security\ICrypto; +use OCP\UserMigration\IExportDestination; +use OCP\UserMigration\IImportSource; +use OCP\UserMigration\UserMigrationException; use Symfony\Component\Console\Output\OutputInterface; class AccountMigrationService { + public const ACCOUNT_FOLDER = MailAccountMigrator::EXPORT_ROOT . '/accounts/'; + public const ACCOUNT_FILES = self::ACCOUNT_FOLDER . MailAccountMigrator::FILENAME_PLACEHOLDER . '.json'; + public function __construct( - private readonly IL10N $l10n, - private readonly ICrypto $crypto, private readonly AccountService $accountService, + private readonly AliasesService $aliasesService, + private readonly MailboxMapper $mailboxMapper, + private readonly ICrypto $crypto, + private readonly IL10N $l10n, ) { } @@ -33,25 +49,165 @@ public function __construct( * @throws ClientException */ public function deleteAllAccounts(IUser $user, OutputInterface $output): void { - $allAccounts = $this->accountService->findByUserId($user->getUID()); - $accountCount = count($allAccounts); - $uid = $user->getUID(); + $output->writeln( + $this->l10n->t('Deleting mail accounts for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); - $output->writeln($this->l10n->t("Deleting {$accountCount} mail account(s) for user {$uid}"), OutputInterface::VERBOSITY_VERBOSE); + $allAccounts = $this->accountService->findByUserId($user->getUID()); foreach ($allAccounts as $account) { - $accountId = $account->getId(); - if ($account->getMailAccount()->getProvisioningId() !== null) { - $output->writeln($this->l10n->t("Skipping deletion of provisioned account {$account->getId()}"), OutputInterface::VERBOSITY_VERBOSE); + $output->writeln("Skipping deletion of provisioned account {$account->getId()}"); continue; } + $this->accountService->deleteByAccountId($account->getId()); + } + } - $this->accountService->deleteByAccountId($accountId); - $output->writeln($this->l10n->t("Deleted mail account {$accountId}"), OutputInterface::VERBOSITY_VERBOSE); + /** + * 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 + */ + public function exportAccounts(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln( + $this->l10n->t('Exporting mail accounts for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $accounts = $this->accountService->findByUserId($user->getUID()); + + foreach ($accounts as $account) { + $mailAccount = $account->getMailAccount(); + + $isProvisionedAccount = $mailAccount->getProvisioningId() !== null; + if ($isProvisionedAccount) { + $output->writeln( + $this->l10n->t('Skipping provisioned account with ID %i', [$account->getId()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + $accountData = $account->jsonSerialize(); + + try { + $this->getDecryptedPasswords($mailAccount, $accountData); + $this->getDecryptedOauthToken($mailAccount, $accountData); + $this->getDecryptedSievePassword($mailAccount, $accountData); + } catch (\Exception $exception) { + throw new UserMigrationException( + "Failed to decrypt passwords for user {$user->getUID()}", + previous: $exception + ); + } + + $this->getMailboxes($account, $accountData); + $this->getAliases($account, $accountData); + + $accountFilePath = str_replace(MailAccountMigrator::FILENAME_PLACEHOLDER, (string)$account->getId(), self::ACCOUNT_FILES); + + try { + $exportDestination->addFileContents($accountFilePath, json_encode($accountData, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export mail accounts for user {$user->getUID()}", + previous: $exception + ); + } } } + /** + * 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, OutputInterface $output, array $certificatesMapping): array { + $output->writeln( + $this->l10n->t('Importing mail accounts for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $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); + $this->setOauthToken($newAccount, $accountData); + + // Set sieve settings + $this->setSieveSettings($newAccount, $accountData); + + $mailAccount = $this->accountService->save( + $newAccount, false + ); + + $oldAccountId = $accountData['accountId']; + $accountAndMailboxMappings['accounts'][$oldAccountId] = $mailAccount->getId(); + + $this->setAliases($mailAccount, $accountData, $certificatesMapping); + + $mailboxesMapping = $this->setMailboxes($mailAccount, $accountData); + $accountAndMailboxMappings['mailboxes'] = $mailboxesMapping; + } + + return $accountAndMailboxMappings; + } + /** * Schedule background jobs for the added accounts. * Necessary to do after all data is being imported as we @@ -64,15 +220,284 @@ public function deleteAllAccounts(IUser $user, OutputInterface $output): void { */ public function scheduleBackgroundJobs(IUser $user, OutputInterface $output): void { $accounts = $this->accountService->findByUserId($user->getUID()); - $accountCount = count($accounts); - - $output->writeln($this->l10n->t("Scheduling background jobs for {$accountCount} mail account(s)"), OutputInterface::VERBOSITY_VERBOSE); foreach ($accounts as $account) { $mailAccount = $account->getMailAccount(); $mailAccountId = $mailAccount->getId(); + $this->accountService->scheduleBackgroundJobs($mailAccountId); - $output->writeln($this->l10n->t("Scheduled background jobs for mail account {$mailAccountId}"), OutputInterface::VERBOSITY_VERY_VERBOSE); } } + + /** + * 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 + * @return void + * @throws \Exception + */ + private function getDecryptedPasswords(MailAccount $mailAccount, array &$accountData): 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 + * @return void + * @throws \Exception + */ + private function getDecryptedOauthToken(MailAccount $mailAccount, array &$accountData): 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 + * @return void + * @throws \Exception + */ + private function getDecryptedSievePassword(MailAccount $mailAccount, array &$accountData): 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 + * @return void + */ + private function getMailboxes(Account $account, array &$accountData): 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 + * @return void + */ + private function getAliases(Account $account, array &$accountData): 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 = $importSource->getFolderListing(self::ACCOUNT_FOLDER); + + 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 + * @return void + */ + private function setPasswords(MailAccount $mailAccount, array $accountData): 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 + * @return void + */ + private function setOauthToken(MailAccount $mailAccount, array $accountData): 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 + * @return void + */ + private function setSieveSettings(MailAccount $mailAccount, array $accountData): 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 + * @return void + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\DB\Exception + */ + private function setAliases(MailAccount $mailAccount, array $accountData, array $certificatesMapping): 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): 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; + } }