From d10dcbe6ade44bab7335cac1fc9d2c789bbccbb8 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Thu, 5 Jun 2025 11:39:09 +0530 Subject: [PATCH 01/12] detect spam account --- appinfo/info.xml | 1 + lib/Command/SpamAccountDetection.php | 52 ++++++++++++++++++++++++++++ lib/Db/ConfigMapper.php | 14 ++++++++ lib/Service/RecoveryEmailService.php | 15 ++++++++ 4 files changed, 82 insertions(+) create mode 100644 lib/Command/SpamAccountDetection.php diff --git a/appinfo/info.xml b/appinfo/info.xml index d839897..6741863 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -23,5 +23,6 @@ OCA\EmailRecovery\Command\UpdateBlacklistedDomains OCA\EmailRecovery\Command\CreatePopularDomain + OCA\EmailRecovery\Command\SpamAccountDetection diff --git a/lib/Command/SpamAccountDetection.php b/lib/Command/SpamAccountDetection.php new file mode 100644 index 0000000..3e3c966 --- /dev/null +++ b/lib/Command/SpamAccountDetection.php @@ -0,0 +1,52 @@ +recoveryEmailService = $recoveryEmailService; + $this->logger = $logger; + $this->appData = $appData; + } + + protected function configure() { + $this + ->setName(Application::APP_ID.':spam-account-detection') + ->setDescription('Fetch Spam accounts and print on console'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $spamEmails = $this->recoveryEmailService->getAllSpamEmails(); + + $output->writeln('Spam email list:'); + foreach ($spamEmails as $email) { + $output->writeln('- ' . $email); + } + + $output->writeln('Popular domains list created successfully.'); + } catch (\Throwable $th) { + $this->logger->error('Error while fetching popular domains. ' . $th->getMessage()); + $output->writeln('Error while fetching popular domains: ' . $th->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + +} diff --git a/lib/Db/ConfigMapper.php b/lib/Db/ConfigMapper.php index 1820cd2..746d472 100644 --- a/lib/Db/ConfigMapper.php +++ b/lib/Db/ConfigMapper.php @@ -29,4 +29,18 @@ class ConfigMapper { } return $userIDs; } + + public function getAllVerifiedRecoveryEmails(): array { + $qb = $this->connection->getQueryBuilder(); + $qb->select('userid', 'configvalue') + ->from('preferences') + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($this->appName))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('recovery-email'))) + ->andWhere($qb->expr()->isNotNull('configvalue')); + + $result = $qb->execute(); + + return $result->fetchAll(); + } + } diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index d4b7c7f..fac76c2 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -560,4 +560,19 @@ class RecoveryEmailService { $requests[] = $now; $this->cache->set($key, $requests, 2); } + public function getAllSpamEmails(): array { + $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); + $spamEmails = []; + foreach ($verifiedEmails as $entry) { + //'userid', 'configvalue' + $recoveryEmail = strtolower(trim($entry['configvalue'])); + $userId = strtolower(trim($entry['configvalue'])); + if (!$this->validateRecoveryEmail($recoveryEmail,$userId)) { + $spamAccounts[] = $userId; + } + } + + return $spamEmails; + } + } -- GitLab From 4156a221713b282a460879fc09306005145277ea Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Thu, 5 Jun 2025 12:04:38 +0530 Subject: [PATCH 02/12] lint fix --- lib/Command/SpamAccountDetection.php | 1 - lib/Db/ConfigMapper.php | 3 +-- lib/Service/RecoveryEmailService.php | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/Command/SpamAccountDetection.php b/lib/Command/SpamAccountDetection.php index 3e3c966..3072b11 100644 --- a/lib/Command/SpamAccountDetection.php +++ b/lib/Command/SpamAccountDetection.php @@ -48,5 +48,4 @@ class SpamAccountDetection extends Command { return Command::SUCCESS; } - } diff --git a/lib/Db/ConfigMapper.php b/lib/Db/ConfigMapper.php index 746d472..22165b2 100644 --- a/lib/Db/ConfigMapper.php +++ b/lib/Db/ConfigMapper.php @@ -41,6 +41,5 @@ class ConfigMapper { $result = $qb->execute(); return $result->fetchAll(); - } - + } } diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index fac76c2..9465d37 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -567,12 +567,11 @@ class RecoveryEmailService { //'userid', 'configvalue' $recoveryEmail = strtolower(trim($entry['configvalue'])); $userId = strtolower(trim($entry['configvalue'])); - if (!$this->validateRecoveryEmail($recoveryEmail,$userId)) { + if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { $spamAccounts[] = $userId; } } return $spamEmails; } - } -- GitLab From 79114b0330cda8cf379a035e297155b546e607ad Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Thu, 5 Jun 2025 14:55:30 +0530 Subject: [PATCH 03/12] add check --- lib/Command/SpamAccountDetection.php | 6 ++---- lib/Service/RecoveryEmailService.php | 6 ++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Command/SpamAccountDetection.php b/lib/Command/SpamAccountDetection.php index 3072b11..4678847 100644 --- a/lib/Command/SpamAccountDetection.php +++ b/lib/Command/SpamAccountDetection.php @@ -38,11 +38,9 @@ class SpamAccountDetection extends Command { foreach ($spamEmails as $email) { $output->writeln('- ' . $email); } - - $output->writeln('Popular domains list created successfully.'); } catch (\Throwable $th) { - $this->logger->error('Error while fetching popular domains. ' . $th->getMessage()); - $output->writeln('Error while fetching popular domains: ' . $th->getMessage()); + $this->logger->error('Error while fetching domains. ' . $th->getMessage()); + $output->writeln('Error while fetching domains: ' . $th->getMessage()); return Command::FAILURE; } diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 9465d37..37e1891 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -567,8 +567,10 @@ class RecoveryEmailService { //'userid', 'configvalue' $recoveryEmail = strtolower(trim($entry['configvalue'])); $userId = strtolower(trim($entry['configvalue'])); - if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { - $spamAccounts[] = $userId; + if ($recoveryEmail !== '' && $userId !== '') { + if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { + $spamAccounts[] = $userId; + } } } -- GitLab From c8400dced6e05b3c25f86fa062d0d86879d1067e Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Fri, 6 Jun 2025 11:18:57 +0530 Subject: [PATCH 04/12] get spammed emai; --- lib/Db/ConfigMapper.php | 4 +++- lib/Service/RecoveryEmailService.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Db/ConfigMapper.php b/lib/Db/ConfigMapper.php index 22165b2..b7f3484 100644 --- a/lib/Db/ConfigMapper.php +++ b/lib/Db/ConfigMapper.php @@ -36,7 +36,9 @@ class ConfigMapper { ->from('preferences') ->where($qb->expr()->eq('appid', $qb->createNamedParameter($this->appName))) ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('recovery-email'))) - ->andWhere($qb->expr()->isNotNull('configvalue')); + ->andWhere($qb->expr()->isNotNull('configvalue')) + ->andWhere($qb->expr()->neq('configvalue', $qb->createNamedParameter(''))) + ->andWhere($qb->expr()->neq('userid', $qb->createNamedParameter(''))); $result = $qb->execute(); diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 37e1891..31106f6 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -566,7 +566,7 @@ class RecoveryEmailService { foreach ($verifiedEmails as $entry) { //'userid', 'configvalue' $recoveryEmail = strtolower(trim($entry['configvalue'])); - $userId = strtolower(trim($entry['configvalue'])); + $userId = strtolower(trim($entry['userid'])); if ($recoveryEmail !== '' && $userId !== '') { if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { $spamAccounts[] = $userId; -- GitLab From 8ffed23314de62e61dbb6e7654522030c225ee75 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Fri, 6 Jun 2025 11:37:04 +0530 Subject: [PATCH 05/12] handle error --- lib/Service/RecoveryEmailService.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 31106f6..85717d2 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -564,16 +564,20 @@ class RecoveryEmailService { $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); $spamEmails = []; foreach ($verifiedEmails as $entry) { - //'userid', 'configvalue' $recoveryEmail = strtolower(trim($entry['configvalue'])); $userId = strtolower(trim($entry['userid'])); if ($recoveryEmail !== '' && $userId !== '') { - if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { - $spamAccounts[] = $userId; + try { + if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { + $spamEmails[] = $userId; + } + } catch (\Throwable $e) { + // Log the exception and continue + $this->logger->info("Validation failed for $userId <$recoveryEmail>: " . $e->getMessage()); + $spamEmails[] = $userId; } } } - return $spamEmails; } } -- GitLab From 67af324b17dd1935a06d8a6517453b7b23ab0278 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Mon, 9 Jun 2025 13:27:40 +0530 Subject: [PATCH 06/12] removed - --- lib/Command/SpamAccountDetection.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/Command/SpamAccountDetection.php b/lib/Command/SpamAccountDetection.php index 4678847..b82929c 100644 --- a/lib/Command/SpamAccountDetection.php +++ b/lib/Command/SpamAccountDetection.php @@ -32,11 +32,11 @@ class SpamAccountDetection extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { try { - $spamEmails = $this->recoveryEmailService->getAllSpamEmails(); + $spamUserNames = $this->recoveryEmailService->getAllSpamEmails(); - $output->writeln('Spam email list:'); - foreach ($spamEmails as $email) { - $output->writeln('- ' . $email); + $output->writeln('Spam user list:'); + foreach ($spamUserNames as $username) { + $output->writeln($username); } } catch (\Throwable $th) { $this->logger->error('Error while fetching domains. ' . $th->getMessage()); -- GitLab From fe5afa163c718bb0bc4fff1b1f8530f349629e96 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Tue, 10 Jun 2025 09:05:58 +0530 Subject: [PATCH 07/12] removed from catch --- lib/Service/RecoveryEmailService.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 85717d2..0df6fda 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -574,7 +574,6 @@ class RecoveryEmailService { } catch (\Throwable $e) { // Log the exception and continue $this->logger->info("Validation failed for $userId <$recoveryEmail>: " . $e->getMessage()); - $spamEmails[] = $userId; } } } -- GitLab From 31eb1f6fea9ccff52a85c0735d3d065b77b2ecf0 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Tue, 10 Jun 2025 11:21:12 +0530 Subject: [PATCH 08/12] verify recovery email --- lib/Command/SpamAccountDetection.php | 10 +- lib/Service/RecoveryEmailService.php | 19 - lib/Service/SpamEmailService.php | 550 +++++++++++++++++++++++++++ 3 files changed, 555 insertions(+), 24 deletions(-) create mode 100644 lib/Service/SpamEmailService.php diff --git a/lib/Command/SpamAccountDetection.php b/lib/Command/SpamAccountDetection.php index b82929c..82eeb16 100644 --- a/lib/Command/SpamAccountDetection.php +++ b/lib/Command/SpamAccountDetection.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace OCA\EmailRecovery\Command; use OCA\EmailRecovery\AppInfo\Application; -use OCA\EmailRecovery\Service\RecoveryEmailService; +use OCA\EmailRecovery\Service\SpamEmailService; use OCP\Files\IAppData; use OCP\ILogger; use Symfony\Component\Console\Command\Command; @@ -13,13 +13,13 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class SpamAccountDetection extends Command { - private RecoveryEmailService $recoveryEmailService; + private SpamEmailService $spamEmailService; private ILogger $logger; private IAppData $appData; - public function __construct(RecoveryEmailService $recoveryEmailService, ILogger $logger, IAppData $appData) { + public function __construct(SpamEmailService $spamEmailService, ILogger $logger, IAppData $appData) { parent::__construct(); - $this->recoveryEmailService = $recoveryEmailService; + $this->spamEmailService = $spamEmailService; $this->logger = $logger; $this->appData = $appData; } @@ -32,7 +32,7 @@ class SpamAccountDetection extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { try { - $spamUserNames = $this->recoveryEmailService->getAllSpamEmails(); + $spamUserNames = $this->spamEmailService->getAllSpamEmails(); $output->writeln('Spam user list:'); foreach ($spamUserNames as $username) { diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 0df6fda..d4b7c7f 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -560,23 +560,4 @@ class RecoveryEmailService { $requests[] = $now; $this->cache->set($key, $requests, 2); } - public function getAllSpamEmails(): array { - $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); - $spamEmails = []; - foreach ($verifiedEmails as $entry) { - $recoveryEmail = strtolower(trim($entry['configvalue'])); - $userId = strtolower(trim($entry['userid'])); - if ($recoveryEmail !== '' && $userId !== '') { - try { - if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { - $spamEmails[] = $userId; - } - } catch (\Throwable $e) { - // Log the exception and continue - $this->logger->info("Validation failed for $userId <$recoveryEmail>: " . $e->getMessage()); - } - } - } - return $spamEmails; - } } diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php new file mode 100644 index 0000000..7da702d --- /dev/null +++ b/lib/Service/SpamEmailService.php @@ -0,0 +1,550 @@ +logger = $logger; + $this->config = $config; + $this->appName = $appName; + $this->session = $session; + $this->userManager = $userManager; + $this->mailer = $mailer; + $this->l10nFactory = $l10nFactory; + $this->urlGenerator = $urlGenerator; + $this->themingDefaults = $themingDefaults; + $this->verificationToken = $verificationToken; + $this->curl = $curlService; + $this->domainService = $domainService; + $this->httpClientService = $httpClientService; + $this->l = $l; + $this->cacheFactory = $cacheFactory; // Initialize the cache factory + $this->cache = $this->cacheFactory->createDistributed(self::CACHE_KEY); // Initialize the cache + $this->configMapper = $configMapper; + $commonServiceURL = $this->config->getSystemValue('common_services_url', ''); + + if (!empty($commonServiceURL)) { + $commonServiceURL = rtrim($commonServiceURL, '/') . '/'; + } + $this->apiConfig = [ + 'commonServicesURL' => $commonServiceURL, + 'commonServicesToken' => $this->config->getSystemValue('common_services_token', ''), + 'commonApiVersion' => $this->config->getSystemValue('common_api_version', '') + ]; + } + public function setRecoveryEmail(string $username, string $value = '') : void { + $this->config->setUserValue($username, $this->appName, 'recovery-email', $value); + } + public function getRecoveryEmail(string $username) : string { + return $this->config->getUserValue($username, $this->appName, 'recovery-email', ''); + } + public function setUnverifiedRecoveryEmail(string $username, string $value = '') : void { + $this->config->setUserValue($username, $this->appName, 'unverified-recovery-email', $value); + } + public function getUnverifiedRecoveryEmail(string $username) : string { + return $this->config->getUserValue($username, $this->appName, 'unverified-recovery-email', ''); + } + public function deleteUnverifiedRecoveryEmail(string $username) : void { + $this->config->deleteUserValue($username, $this->appName, 'unverified-recovery-email'); + } + + public function validateRecoveryEmail(string $recoveryEmail, string $username = '', string $language = 'en'): bool { + if (empty($recoveryEmail)) { + return true; + } + // Fetch user email if username is provided + $email = $this->getUserEmail($username); + + $l = $this->l10nFactory->get($this->appName, $language); + if (!$this->enforceBasicRecoveryEmailRules($recoveryEmail, $username, $email, $l)) { + $this->logger->info("Basic recovery email rule check failed for $username <$recoveryEmail>"); + return false; + } + + $apiKey = $this->config->getSystemValue('verify_mail_api_key', ''); + + if (empty($apiKey)) { + $this->logger->info('VerifyMail API Key is not configured.'); + } + + if ($this->domainService->isDomainInCustomBlacklist($recoveryEmail, $l)) { + return false; + } + + $domainCheckPassed = true; + $emailCheckPassed = true; + + if ($this->domainService->isPopularDomain($recoveryEmail, $l)) { + // Skip domain verification and directly validate the email + $emailCheckPassed = $this->ensureRealTimeRateLimit(self::RATE_LIMIT_EMAIL, 2, $l); + } else { + // Verify the domain using the API + $domainCheckPassed = $this->ensureRealTimeRateLimit(self::RATE_LIMIT_DOMAIN, 15, $l); + if ($domainCheckPassed) { + $domain = substr(strrchr($recoveryEmail, "@"), 1); + try { + $this->verifyDomainWithApi($domain, $username, $apiKey, $l); + } catch (\Throwable $e) { + $this->logger->info("Domain verification failed for $username <$recoveryEmail>: " . $e->getMessage()); + return false; + } + $emailCheckPassed = $this->ensureRealTimeRateLimit(self::RATE_LIMIT_EMAIL, 2, $l); + } + } + + // If either rate limit check failed, don't proceed + if (!$emailCheckPassed) { + return false; + } + + // Final step: validate the email itself + return $this->ensureEmailIsValid($recoveryEmail, $username, $apiKey, $l); + } + private function getUserEmail(string $username): string { + if (empty($username)) { + return ''; + } + $user = $this->userManager->get($username); + return $user->getEMailAddress(); + } + + private function enforceBasicRecoveryEmailRules(string $recoveryEmail, string $username, string $email, IL10N $l): bool { + if ($this->isRecoveryEmailDomainDisallowed($recoveryEmail)) { + $this->logger->info("User ID $username's requested recovery email uses a disallowed domain"); + return false; + } + + if ($this->domainService->isBlacklistedDomain($recoveryEmail, $l)) { + $this->logger->info("User ID $username's requested recovery email domain is blacklisted"); + return false; + } + + return true; + } + + + private function retryApiCall(callable $callback, IL10N $l, int $maxRetries = 10, int $initialInterval = 1000): void { + $retryInterval = $initialInterval; // Initial retry interval in milliseconds + $retries = 0; + + while ($retries < $maxRetries) { + try { + // Execute the API call + $result = $callback(); + + // If successful, return immediately + return; + } catch (\Exception $e) { + // Check for rate-limiting (HTTP 429) + if ($e instanceof \RuntimeException && $e->getCode() === 429) { + $retries++; + + if ($retries >= $maxRetries) { + throw new \RuntimeException($l->t('The email could not be verified. Please try again later.')); + } + + $this->logger->warning("Received 429 status code, retrying in $retryInterval ms..."); + usleep($retryInterval * 1000); // Convert to microseconds + $retryInterval *= 2; // Exponential backoff + continue; // Retry only on 429 errors + } + + // For other exceptions, log and rethrow immediately without retrying + $this->logger->error("API call failed on the first attempt. Error: " . $e->getMessage()); + throw $e; + } + } + + // Shouldn't reach here since retries are handled above + throw new \RuntimeException("API call failed unexpectedly after maximum retries."); + } + + private function ensureEmailIsValid(string $recoveryEmail, string $username, string $apiKey, IL10N $l): bool { + $url = sprintf(self::VERIFYMAIL_API_URL, $recoveryEmail, $apiKey); + $isValid = true; + + try { + $this->retryApiCall(function () use ($url, $username, $l, &$isValid) { + $httpClient = $this->httpClientService->newClient(); + $response = $httpClient->get($url, ['timeout' => 15]); + $data = json_decode($response->getBody(), true); + + if (!is_array($data)) { + $isValid = false; + return; + } + + if ($data['disposable'] ?? false) { + $this->logger->info("Disposable email for $username <$url>"); + $isValid = false; + } + + if (!($data['deliverable_email'] ?? true)) { + $this->logger->info("Undeliverable email for $username <$url>"); + $isValid = false; + } + }, $l, 10, 1000); + } catch (\Throwable $e) { + $this->logger->error("Email validation failed for $username <$recoveryEmail>: " . $e->getMessage()); + // Treat as valid to avoid false positives due to network/API issues + return true; + } + + return $isValid; + } + + + + + private function verifyDomainWithApi(string $domain, string $username, string $apiKey, IL10N $l): void { + $url = sprintf(self::VERIFYMAIL_API_URL, $domain, $apiKey); + + $this->retryApiCall(function () use ($url, $username, $domain, $l) { + $httpClient = $this->httpClientService->newClient(); + // Make the API request + $response = $httpClient->get($url, [ + 'timeout' => 15, // Timeout for the API call + ]); + + // Process response, handle errors (e.g., disposable email, non-deliverable email) + $responseBody = $response->getBody(); // Get the response body as a string + $data = json_decode($responseBody, true); + + + // Check if the data is properly structured + if (!$data || !is_array($data)) { + throw new \RuntimeException("Invalid response received while verifying domain: " . $response); + } + + // Handle response data + if ($data['disposable'] ?? false) { + $this->logger->info("User ID $username's requested recovery email address is from a disposable domain."); + $this->domainService->addCustomDisposableDomain($domain, $l, $data['related_domains'] ?? []); + throw new BlacklistedEmailException($l->t('The email address is disposable. Please provide another recovery address.')); + } + + if (!$data['mx'] ?? true) { + $this->logger->info("User ID $username's requested recovery email address domain is not valid."); + $this->domainService->addCustomDisposableDomain($domain, $l, $data['related_domains'] ?? []); + throw new BlacklistedEmailException($l->t('The email address is not deliverable. Please provide another recovery address.')); + } + + $this->logger->info("User ID $username's requested recovery email address domain is valid."); + }, $l, // Pass the IL10N object + 10, // Optional: Max retries (default is 10, override if necessary) + 1000); + } + + + + public function isRecoveryEmailDomainDisallowed(string $recoveryEmail): bool { + $recoveryEmail = strtolower($recoveryEmail); + $emailParts = explode('@', $recoveryEmail); + $domain = $emailParts[1] ?? ''; + + $legacyDomain = $this->config->getSystemValue('legacy_domain', ''); + + $mainDomain = $this->config->getSystemValue('main_domain', ''); + + $restrictedDomains = [$legacyDomain, $mainDomain]; + + return in_array($domain, $restrictedDomains); + } + public function isRecoveryEmailTaken(string $username, string $recoveryEmail): bool { + $recoveryEmail = strtolower($recoveryEmail); + + $currentRecoveryEmail = $this->getRecoveryEmail($username); + $currentUnverifiedRecoveryEmail = $this->getUnverifiedRecoveryEmail($username); + + if ($currentRecoveryEmail === $recoveryEmail || $currentUnverifiedRecoveryEmail === $recoveryEmail) { + return false; + } + + $usersWithEmailRecovery = $this->config->getUsersForUserValueCaseInsensitive($this->appName, 'recovery-email', $recoveryEmail); + if (count($usersWithEmailRecovery)) { + return true; + } + + $usersWithUnverifiedRecovery = $this->config->getUsersForUserValueCaseInsensitive($this->appName, 'unverified-recovery-email', $recoveryEmail); + if (count($usersWithUnverifiedRecovery)) { + return true; + } + + return false; + } + + private function getUsernameAndDomain(string $email) : ?array { + if ($email === null || empty($email)) { + return null; + } + $email = strtolower($email); + $emailParts = explode('@', $email); + $mailUsernameParts = explode('+', $emailParts[0]); + $mailUsername = $mailUsernameParts[0]; + $mailDomain = $emailParts[1]; + return [$mailUsername, $mailDomain]; + } + + public function isAliasedRecoveryEmailValid(string $username, string $recoveryEmail): bool { + if (!str_contains($recoveryEmail, '+')) { + return true; + } + $recoveryEmailParts = $this->getUsernameAndDomain($recoveryEmail); + $emailAliasLimit = (int) $this->config->getSystemValue('recovery_email_alias_limit', 5); + if ($emailAliasLimit === -1) { + return true; + } + $recoveryEmailregex = $recoveryEmailParts[0]."+%@".$recoveryEmailParts[1]; + $currentRecoveryEmail = $this->getRecoveryEmail($username); + $currentUnverifiedRecoveryEmail = $this->getUnverifiedRecoveryEmail($username); + $currentRecoveryEmailParts = $this->getUsernameAndDomain($currentRecoveryEmail); + $currentUnverifiedRecoveryEmailParts = $this->getUsernameAndDomain($currentUnverifiedRecoveryEmail); + + if ($currentRecoveryEmailParts !== null && $currentRecoveryEmailParts[0] === $recoveryEmailParts[0] && $currentRecoveryEmailParts[1] === $recoveryEmailParts[1] + || $currentUnverifiedRecoveryEmailParts !== null && $currentUnverifiedRecoveryEmailParts[0] === $recoveryEmailParts[0] && $currentUnverifiedRecoveryEmailParts[1] === $recoveryEmailParts[1]) { + return true; + } + + $usersWithEmailRecovery = $this->configMapper->getUsersByRecoveryEmail($recoveryEmailregex); + if (count($usersWithEmailRecovery) > $emailAliasLimit) { + return false; + } + + return true; + } + + public function updateRecoveryEmail(string $username, string $recoveryEmail) : void { + $this->setUnverifiedRecoveryEmail($username, $recoveryEmail); + $this->setRecoveryEmail($username, ''); + } + + public function sendVerificationEmail(string $uid, string $recoveryEmailAddress) : void { + try { + $user = $this->userManager->get($uid); + $emailTemplate = $this->generateVerificationEmailTemplate($user, $recoveryEmailAddress); + + $email = $this->mailer->createMessage(); + $email->useTemplate($emailTemplate); + $email->setTo([$recoveryEmailAddress]); + $email->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->themingDefaults->getName()]); + $this->mailer->send($email); + } catch (\Exception $e) { + $this->logger->error('Error sending notification email to user ' . $uid, ['exception' => $e]); + } + } + /** + * @param IUser $user + * @param string $recoveryEmailAddress + * @return IEMailTemplate + */ + public function generateVerificationEmailTemplate(IUser $user, string $recoveryEmailAddress) { + $userId = $user->getUID(); + + $lang = $this->config->getUserValue($userId, 'core', 'lang', null); + $l10n = $this->l10nFactory->get('settings', $lang); + + $token = $this->createToken($user, $recoveryEmailAddress); + $link = $this->urlGenerator->linkToRouteAbsolute($this->appName .'.email_recovery.verify_recovery_email', ['token' => $token,'userId' => $user->getUID()]); + $this->logger->debug('RECOVERY EMAIL VERIFICATION URL LINK: ' . $link); + $displayName = $user->getDisplayName(); + + $emailTemplate = $this->mailer->createEMailTemplate('recovery-email.confirmation', [ + 'link' => $link, + 'displayname' => $displayName, + 'userid' => $userId, + 'instancename' => $this->themingDefaults->getName(), + 'resetTokenGenerated' => true, + ]); + + $emailTemplate->setSubject($l10n->t('Recovery Email Update in Your %s Account', [$this->themingDefaults->getName()])); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($l10n->t('Hello %s', [$displayName])); + $emailTemplate->addBodyText($l10n->t('This is to inform you that the recovery email for your %s account has been successfully updated.', [$this->themingDefaults->getName()])); + $emailTemplate->addBodyText($l10n->t('To verify your new recovery email, please click on the following button.')); + $leftButtonText = $l10n->t('Verify recovery email'); + $emailTemplate->addBodyButton( + $leftButtonText, + $link + ); + $emailTemplate->addBodyText($l10n->t('Please note that this link will be valid for the next 30 minutes.')); + $emailTemplate->addBodyText($l10n->t('If you did not initiate this change, please contact our support team immediately.')); + $emailTemplate->addBodyText($l10n->t('Thank you for choosing %s.', [$this->themingDefaults->getName()])); + $emailTemplate->addFooter('', $lang); + + return $emailTemplate; + } + private function createToken(IUser $user, string $recoveryEmail = ''): string { + $ref = \substr(hash('sha256', $recoveryEmail), 0, 8); + return $this->verificationToken->create($user, 'verifyRecoveryMail' . $ref, $recoveryEmail, self::TOKEN_LIFETIME); + } + public function verifyToken(string $token, IUser $user, string $verificationKey, string $email): void { + $this->verificationToken->check($token, $user, $verificationKey, $email); + } + public function deleteVerificationToken(string $token, IUser $user, string $verificationKey): void { + $this->verificationToken->delete($token, $user, $verificationKey); + } + public function makeRecoveryEmailVerified(string $userId): void { + $newRecoveryEmailAddress = $this->getUnverifiedRecoveryEmail($userId); + if ($newRecoveryEmailAddress !== '') { + $this->setRecoveryEmail($userId, $newRecoveryEmailAddress); + $this->deleteUnverifiedRecoveryEmail($userId); + } + } + private function manageEmailRestriction(string $email, string $method, string $url) : void { + $params = []; + + $token = $this->apiConfig['commonServicesToken']; + $headers = [ + "Authorization: Bearer $token" + ]; + + if ($method === 'POST') { + $this->curl->post($url, $params, $headers); + } elseif ($method === 'DELETE') { + $this->curl->delete($url, $params, $headers); + } + + if ($this->curl->getLastStatusCode() !== 200) { + throw new Exception('Error ' . strtolower($method) . 'ing email ' . $email . ' in restricted list. Status Code: ' . $this->curl->getLastStatusCode()); + } + } + + public function restrictEmail(string $email) : void { + $commonServicesURL = $this->apiConfig['commonServicesURL']; + $commonApiVersion = $this->apiConfig['commonApiVersion']; + + if (!isset($commonServicesURL) || empty($commonServicesURL)) { + return; + } + + $endpoint = $commonApiVersion . '/emails/restricted/' . $email; + $url = $commonServicesURL . $endpoint; // POST /v2/emails/restricted/@email + + $this->manageEmailRestriction($email, 'POST', $url); + } + + public function unrestrictEmail(string $email) : void { + $commonServicesURL = $this->apiConfig['commonServicesURL']; + $commonApiVersion = $this->apiConfig['commonApiVersion']; + + if (!isset($commonServicesURL) || empty($commonServicesURL)) { + return; + } + + $endpoint = $commonApiVersion . '/emails/restricted/' . $email; + $url = $commonServicesURL . $endpoint; // DELETE /v2/emails/restricted/@email + + $this->manageEmailRestriction($email, 'DELETE', $url); + } + /** + * Check if a recovery email address is in valid format + * + * @param string $recoveryEmail The recovery email address to check. + * + * @return bool True if the recovery email address is valid, false otherwise. + */ + public function isValidEmailFormat(string $recoveryEmail): bool { + return filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL) !== false; + } + + private function ensureRealTimeRateLimit(string $key, int $rateLimit, IL10N $l, int $maxRetries = 10): bool { + $now = microtime(true); + $attempts = 0; + $requests = $this->cache->get($key) ?? []; + + // Filter out old requests + $requests = array_filter($requests, function ($timestamp) use ($now) { + return ($now - $timestamp) <= 1; + }); + + while (count($requests) >= $rateLimit) { + $oldestRequest = min($requests); + $delay = 1 - ($now - $oldestRequest); + + if ($delay > 0) { + usleep((int)($delay * 1000000)); + } + + $now = microtime(true); + $requests = array_filter($requests, function ($timestamp) use ($now) { + return ($now - $timestamp) <= 1; + }); + + if (++$attempts >= $maxRetries) { + $this->logger->info("Rate limit exceeded after $maxRetries attempts. Skipping validation."); + return false; // move on instead of throwing + } + } + + $requests[] = $now; + $this->cache->set($key, $requests, 2); + return true; + } + + public function getAllSpamEmails(): array { + $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); + $spamEmails = []; + foreach ($verifiedEmails as $entry) { + $recoveryEmail = strtolower(trim($entry['configvalue'])); + $userId = strtolower(trim($entry['userid'])); + if ($recoveryEmail !== '' && $userId !== '') { + try { + if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { + $spamEmails[] = $userId; + } + } catch (\Throwable $e) { + // Log the exception and continue + $this->logger->info("Validation failed for $userId <$recoveryEmail>: " . $e->getMessage()); + } + } + } + return $spamEmails; + } +} -- GitLab From d3fc389c76beaab37df03c089975a165d12d2377 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Tue, 10 Jun 2025 11:26:05 +0530 Subject: [PATCH 09/12] verify recovery email --- lib/Service/SpamEmailService.php | 521 +++++-------------------------- 1 file changed, 74 insertions(+), 447 deletions(-) diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php index 7da702d..0c0995c 100644 --- a/lib/Service/SpamEmailService.php +++ b/lib/Service/SpamEmailService.php @@ -1,131 +1,87 @@ logger = $logger; $this->config = $config; - $this->appName = $appName; - $this->session = $session; $this->userManager = $userManager; - $this->mailer = $mailer; $this->l10nFactory = $l10nFactory; - $this->urlGenerator = $urlGenerator; - $this->themingDefaults = $themingDefaults; - $this->verificationToken = $verificationToken; - $this->curl = $curlService; - $this->domainService = $domainService; $this->httpClientService = $httpClientService; + $this->cacheFactory = $cacheFactory; + $this->domainService = $domainService; $this->l = $l; - $this->cacheFactory = $cacheFactory; // Initialize the cache factory - $this->cache = $this->cacheFactory->createDistributed(self::CACHE_KEY); // Initialize the cache $this->configMapper = $configMapper; - $commonServiceURL = $this->config->getSystemValue('common_services_url', ''); + $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY); + } - if (!empty($commonServiceURL)) { - $commonServiceURL = rtrim($commonServiceURL, '/') . '/'; + public function getAllSpamEmails(): array { + $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); + $spamEmails = []; + foreach ($verifiedEmails as $entry) { + $recoveryEmail = strtolower(trim($entry['configvalue'])); + $userId = strtolower(trim($entry['userid'])); + if ($recoveryEmail !== '' && $userId !== '') { + try { + if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { + $spamEmails[] = $userId; + } + } catch (\Throwable $e) { + $this->logger->info("Validation failed for $userId <$recoveryEmail>: " . $e->getMessage()); + } + } } - $this->apiConfig = [ - 'commonServicesURL' => $commonServiceURL, - 'commonServicesToken' => $this->config->getSystemValue('common_services_token', ''), - 'commonApiVersion' => $this->config->getSystemValue('common_api_version', '') - ]; - } - public function setRecoveryEmail(string $username, string $value = '') : void { - $this->config->setUserValue($username, $this->appName, 'recovery-email', $value); - } - public function getRecoveryEmail(string $username) : string { - return $this->config->getUserValue($username, $this->appName, 'recovery-email', ''); - } - public function setUnverifiedRecoveryEmail(string $username, string $value = '') : void { - $this->config->setUserValue($username, $this->appName, 'unverified-recovery-email', $value); - } - public function getUnverifiedRecoveryEmail(string $username) : string { - return $this->config->getUserValue($username, $this->appName, 'unverified-recovery-email', ''); - } - public function deleteUnverifiedRecoveryEmail(string $username) : void { - $this->config->deleteUserValue($username, $this->appName, 'unverified-recovery-email'); + return $spamEmails; } public function validateRecoveryEmail(string $recoveryEmail, string $username = '', string $language = 'en'): bool { if (empty($recoveryEmail)) { return true; } - // Fetch user email if username is provided $email = $this->getUserEmail($username); - - $l = $this->l10nFactory->get($this->appName, $language); + $l = $this->l10nFactory->get('emailrecovery', $language); if (!$this->enforceBasicRecoveryEmailRules($recoveryEmail, $username, $email, $l)) { - $this->logger->info("Basic recovery email rule check failed for $username <$recoveryEmail>"); return false; } $apiKey = $this->config->getSystemValue('verify_mail_api_key', ''); - - if (empty($apiKey)) { - $this->logger->info('VerifyMail API Key is not configured.'); - } - if ($this->domainService->isDomainInCustomBlacklist($recoveryEmail, $l)) { return false; } - + $domainCheckPassed = true; $emailCheckPassed = true; if ($this->domainService->isPopularDomain($recoveryEmail, $l)) { - // Skip domain verification and directly validate the email $emailCheckPassed = $this->ensureRealTimeRateLimit(self::RATE_LIMIT_EMAIL, 2, $l); } else { - // Verify the domain using the API $domainCheckPassed = $this->ensureRealTimeRateLimit(self::RATE_LIMIT_DOMAIN, 15, $l); if ($domainCheckPassed) { $domain = substr(strrchr($recoveryEmail, "@"), 1); @@ -139,14 +95,13 @@ class SpamEmailService { } } - // If either rate limit check failed, don't proceed if (!$emailCheckPassed) { return false; } - // Final step: validate the email itself return $this->ensureEmailIsValid($recoveryEmail, $username, $apiKey, $l); } + private function getUserEmail(string $username): string { if (empty($username)) { return ''; @@ -156,395 +111,67 @@ class SpamEmailService { } private function enforceBasicRecoveryEmailRules(string $recoveryEmail, string $username, string $email, IL10N $l): bool { - if ($this->isRecoveryEmailDomainDisallowed($recoveryEmail)) { - $this->logger->info("User ID $username's requested recovery email uses a disallowed domain"); + if (!filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL)) { return false; } - - if ($this->domainService->isBlacklistedDomain($recoveryEmail, $l)) { - $this->logger->info("User ID $username's requested recovery email domain is blacklisted"); + if (!empty($email) && strcmp($recoveryEmail, $email) === 0) { return false; } - - return true; - } - - - private function retryApiCall(callable $callback, IL10N $l, int $maxRetries = 10, int $initialInterval = 1000): void { - $retryInterval = $initialInterval; // Initial retry interval in milliseconds - $retries = 0; - - while ($retries < $maxRetries) { - try { - // Execute the API call - $result = $callback(); - - // If successful, return immediately - return; - } catch (\Exception $e) { - // Check for rate-limiting (HTTP 429) - if ($e instanceof \RuntimeException && $e->getCode() === 429) { - $retries++; - - if ($retries >= $maxRetries) { - throw new \RuntimeException($l->t('The email could not be verified. Please try again later.')); - } - - $this->logger->warning("Received 429 status code, retrying in $retryInterval ms..."); - usleep($retryInterval * 1000); // Convert to microseconds - $retryInterval *= 2; // Exponential backoff - continue; // Retry only on 429 errors - } - - // For other exceptions, log and rethrow immediately without retrying - $this->logger->error("API call failed on the first attempt. Error: " . $e->getMessage()); - throw $e; - } - } - - // Shouldn't reach here since retries are handled above - throw new \RuntimeException("API call failed unexpectedly after maximum retries."); - } - - private function ensureEmailIsValid(string $recoveryEmail, string $username, string $apiKey, IL10N $l): bool { - $url = sprintf(self::VERIFYMAIL_API_URL, $recoveryEmail, $apiKey); - $isValid = true; - - try { - $this->retryApiCall(function () use ($url, $username, $l, &$isValid) { - $httpClient = $this->httpClientService->newClient(); - $response = $httpClient->get($url, ['timeout' => 15]); - $data = json_decode($response->getBody(), true); - - if (!is_array($data)) { - $isValid = false; - return; - } - - if ($data['disposable'] ?? false) { - $this->logger->info("Disposable email for $username <$url>"); - $isValid = false; - } - - if (!($data['deliverable_email'] ?? true)) { - $this->logger->info("Undeliverable email for $username <$url>"); - $isValid = false; - } - }, $l, 10, 1000); - } catch (\Throwable $e) { - $this->logger->error("Email validation failed for $username <$recoveryEmail>: " . $e->getMessage()); - // Treat as valid to avoid false positives due to network/API issues - return true; - } - - return $isValid; - } - - - - - private function verifyDomainWithApi(string $domain, string $username, string $apiKey, IL10N $l): void { - $url = sprintf(self::VERIFYMAIL_API_URL, $domain, $apiKey); - - $this->retryApiCall(function () use ($url, $username, $domain, $l) { - $httpClient = $this->httpClientService->newClient(); - // Make the API request - $response = $httpClient->get($url, [ - 'timeout' => 15, // Timeout for the API call - ]); - - // Process response, handle errors (e.g., disposable email, non-deliverable email) - $responseBody = $response->getBody(); // Get the response body as a string - $data = json_decode($responseBody, true); - - - // Check if the data is properly structured - if (!$data || !is_array($data)) { - throw new \RuntimeException("Invalid response received while verifying domain: " . $response); - } - - // Handle response data - if ($data['disposable'] ?? false) { - $this->logger->info("User ID $username's requested recovery email address is from a disposable domain."); - $this->domainService->addCustomDisposableDomain($domain, $l, $data['related_domains'] ?? []); - throw new BlacklistedEmailException($l->t('The email address is disposable. Please provide another recovery address.')); - } - - if (!$data['mx'] ?? true) { - $this->logger->info("User ID $username's requested recovery email address domain is not valid."); - $this->domainService->addCustomDisposableDomain($domain, $l, $data['related_domains'] ?? []); - throw new BlacklistedEmailException($l->t('The email address is not deliverable. Please provide another recovery address.')); - } - - $this->logger->info("User ID $username's requested recovery email address domain is valid."); - }, $l, // Pass the IL10N object - 10, // Optional: Max retries (default is 10, override if necessary) - 1000); - } - - - - public function isRecoveryEmailDomainDisallowed(string $recoveryEmail): bool { - $recoveryEmail = strtolower($recoveryEmail); - $emailParts = explode('@', $recoveryEmail); - $domain = $emailParts[1] ?? ''; - - $legacyDomain = $this->config->getSystemValue('legacy_domain', ''); - - $mainDomain = $this->config->getSystemValue('main_domain', ''); - - $restrictedDomains = [$legacyDomain, $mainDomain]; - - return in_array($domain, $restrictedDomains); - } - public function isRecoveryEmailTaken(string $username, string $recoveryEmail): bool { - $recoveryEmail = strtolower($recoveryEmail); - - $currentRecoveryEmail = $this->getRecoveryEmail($username); - $currentUnverifiedRecoveryEmail = $this->getUnverifiedRecoveryEmail($username); - - if ($currentRecoveryEmail === $recoveryEmail || $currentUnverifiedRecoveryEmail === $recoveryEmail) { - return false; - } - - $usersWithEmailRecovery = $this->config->getUsersForUserValueCaseInsensitive($this->appName, 'recovery-email', $recoveryEmail); - if (count($usersWithEmailRecovery)) { - return true; - } - - $usersWithUnverifiedRecovery = $this->config->getUsersForUserValueCaseInsensitive($this->appName, 'unverified-recovery-email', $recoveryEmail); - if (count($usersWithUnverifiedRecovery)) { - return true; - } - - return false; - } - - private function getUsernameAndDomain(string $email) : ?array { - if ($email === null || empty($email)) { - return null; - } - $email = strtolower($email); - $emailParts = explode('@', $email); - $mailUsernameParts = explode('+', $emailParts[0]); - $mailUsername = $mailUsernameParts[0]; - $mailDomain = $emailParts[1]; - return [$mailUsername, $mailDomain]; - } - - public function isAliasedRecoveryEmailValid(string $username, string $recoveryEmail): bool { - if (!str_contains($recoveryEmail, '+')) { - return true; - } - $recoveryEmailParts = $this->getUsernameAndDomain($recoveryEmail); - $emailAliasLimit = (int) $this->config->getSystemValue('recovery_email_alias_limit', 5); - if ($emailAliasLimit === -1) { - return true; - } - $recoveryEmailregex = $recoveryEmailParts[0]."+%@".$recoveryEmailParts[1]; - $currentRecoveryEmail = $this->getRecoveryEmail($username); - $currentUnverifiedRecoveryEmail = $this->getUnverifiedRecoveryEmail($username); - $currentRecoveryEmailParts = $this->getUsernameAndDomain($currentRecoveryEmail); - $currentUnverifiedRecoveryEmailParts = $this->getUsernameAndDomain($currentUnverifiedRecoveryEmail); - - if ($currentRecoveryEmailParts !== null && $currentRecoveryEmailParts[0] === $recoveryEmailParts[0] && $currentRecoveryEmailParts[1] === $recoveryEmailParts[1] - || $currentUnverifiedRecoveryEmailParts !== null && $currentUnverifiedRecoveryEmailParts[0] === $recoveryEmailParts[0] && $currentUnverifiedRecoveryEmailParts[1] === $recoveryEmailParts[1]) { - return true; - } - - $usersWithEmailRecovery = $this->configMapper->getUsersByRecoveryEmail($recoveryEmailregex); - if (count($usersWithEmailRecovery) > $emailAliasLimit) { + if ($this->domainService->isBlacklistedDomain($recoveryEmail, $l)) { return false; } - return true; } - public function updateRecoveryEmail(string $username, string $recoveryEmail) : void { - $this->setUnverifiedRecoveryEmail($username, $recoveryEmail); - $this->setRecoveryEmail($username, ''); - } - - public function sendVerificationEmail(string $uid, string $recoveryEmailAddress) : void { - try { - $user = $this->userManager->get($uid); - $emailTemplate = $this->generateVerificationEmailTemplate($user, $recoveryEmailAddress); - - $email = $this->mailer->createMessage(); - $email->useTemplate($emailTemplate); - $email->setTo([$recoveryEmailAddress]); - $email->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->themingDefaults->getName()]); - $this->mailer->send($email); - } catch (\Exception $e) { - $this->logger->error('Error sending notification email to user ' . $uid, ['exception' => $e]); - } - } - /** - * @param IUser $user - * @param string $recoveryEmailAddress - * @return IEMailTemplate - */ - public function generateVerificationEmailTemplate(IUser $user, string $recoveryEmailAddress) { - $userId = $user->getUID(); - - $lang = $this->config->getUserValue($userId, 'core', 'lang', null); - $l10n = $this->l10nFactory->get('settings', $lang); - - $token = $this->createToken($user, $recoveryEmailAddress); - $link = $this->urlGenerator->linkToRouteAbsolute($this->appName .'.email_recovery.verify_recovery_email', ['token' => $token,'userId' => $user->getUID()]); - $this->logger->debug('RECOVERY EMAIL VERIFICATION URL LINK: ' . $link); - $displayName = $user->getDisplayName(); - - $emailTemplate = $this->mailer->createEMailTemplate('recovery-email.confirmation', [ - 'link' => $link, - 'displayname' => $displayName, - 'userid' => $userId, - 'instancename' => $this->themingDefaults->getName(), - 'resetTokenGenerated' => true, - ]); - - $emailTemplate->setSubject($l10n->t('Recovery Email Update in Your %s Account', [$this->themingDefaults->getName()])); - $emailTemplate->addHeader(); - $emailTemplate->addHeading($l10n->t('Hello %s', [$displayName])); - $emailTemplate->addBodyText($l10n->t('This is to inform you that the recovery email for your %s account has been successfully updated.', [$this->themingDefaults->getName()])); - $emailTemplate->addBodyText($l10n->t('To verify your new recovery email, please click on the following button.')); - $leftButtonText = $l10n->t('Verify recovery email'); - $emailTemplate->addBodyButton( - $leftButtonText, - $link - ); - $emailTemplate->addBodyText($l10n->t('Please note that this link will be valid for the next 30 minutes.')); - $emailTemplate->addBodyText($l10n->t('If you did not initiate this change, please contact our support team immediately.')); - $emailTemplate->addBodyText($l10n->t('Thank you for choosing %s.', [$this->themingDefaults->getName()])); - $emailTemplate->addFooter('', $lang); - - return $emailTemplate; - } - private function createToken(IUser $user, string $recoveryEmail = ''): string { - $ref = \substr(hash('sha256', $recoveryEmail), 0, 8); - return $this->verificationToken->create($user, 'verifyRecoveryMail' . $ref, $recoveryEmail, self::TOKEN_LIFETIME); - } - public function verifyToken(string $token, IUser $user, string $verificationKey, string $email): void { - $this->verificationToken->check($token, $user, $verificationKey, $email); - } - public function deleteVerificationToken(string $token, IUser $user, string $verificationKey): void { - $this->verificationToken->delete($token, $user, $verificationKey); - } - public function makeRecoveryEmailVerified(string $userId): void { - $newRecoveryEmailAddress = $this->getUnverifiedRecoveryEmail($userId); - if ($newRecoveryEmailAddress !== '') { - $this->setRecoveryEmail($userId, $newRecoveryEmailAddress); - $this->deleteUnverifiedRecoveryEmail($userId); - } - } - private function manageEmailRestriction(string $email, string $method, string $url) : void { - $params = []; - - $token = $this->apiConfig['commonServicesToken']; - $headers = [ - "Authorization: Bearer $token" - ]; - - if ($method === 'POST') { - $this->curl->post($url, $params, $headers); - } elseif ($method === 'DELETE') { - $this->curl->delete($url, $params, $headers); - } - - if ($this->curl->getLastStatusCode() !== 200) { - throw new Exception('Error ' . strtolower($method) . 'ing email ' . $email . ' in restricted list. Status Code: ' . $this->curl->getLastStatusCode()); - } - } - - public function restrictEmail(string $email) : void { - $commonServicesURL = $this->apiConfig['commonServicesURL']; - $commonApiVersion = $this->apiConfig['commonApiVersion']; - - if (!isset($commonServicesURL) || empty($commonServicesURL)) { - return; - } - - $endpoint = $commonApiVersion . '/emails/restricted/' . $email; - $url = $commonServicesURL . $endpoint; // POST /v2/emails/restricted/@email - - $this->manageEmailRestriction($email, 'POST', $url); - } - - public function unrestrictEmail(string $email) : void { - $commonServicesURL = $this->apiConfig['commonServicesURL']; - $commonApiVersion = $this->apiConfig['commonApiVersion']; - - if (!isset($commonServicesURL) || empty($commonServicesURL)) { - return; - } - - $endpoint = $commonApiVersion . '/emails/restricted/' . $email; - $url = $commonServicesURL . $endpoint; // DELETE /v2/emails/restricted/@email - - $this->manageEmailRestriction($email, 'DELETE', $url); - } - /** - * Check if a recovery email address is in valid format - * - * @param string $recoveryEmail The recovery email address to check. - * - * @return bool True if the recovery email address is valid, false otherwise. - */ - public function isValidEmailFormat(string $recoveryEmail): bool { - return filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL) !== false; - } - private function ensureRealTimeRateLimit(string $key, int $rateLimit, IL10N $l, int $maxRetries = 10): bool { $now = microtime(true); $attempts = 0; $requests = $this->cache->get($key) ?? []; - - // Filter out old requests - $requests = array_filter($requests, function ($timestamp) use ($now) { - return ($now - $timestamp) <= 1; - }); - + $requests = array_filter($requests, fn ($t) => ($now - $t) <= 1); + while (count($requests) >= $rateLimit) { - $oldestRequest = min($requests); - $delay = 1 - ($now - $oldestRequest); - - if ($delay > 0) { - usleep((int)($delay * 1000000)); - } - + usleep((int)((1 - ($now - min($requests))) * 1000000)); $now = microtime(true); - $requests = array_filter($requests, function ($timestamp) use ($now) { - return ($now - $timestamp) <= 1; - }); - + $requests = array_filter($requests, fn ($t) => ($now - $t) <= 1); if (++$attempts >= $maxRetries) { - $this->logger->info("Rate limit exceeded after $maxRetries attempts. Skipping validation."); - return false; // move on instead of throwing + return false; } } - + $requests[] = $now; $this->cache->set($key, $requests, 2); return true; } - - public function getAllSpamEmails(): array { - $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); - $spamEmails = []; - foreach ($verifiedEmails as $entry) { - $recoveryEmail = strtolower(trim($entry['configvalue'])); - $userId = strtolower(trim($entry['userid'])); - if ($recoveryEmail !== '' && $userId !== '') { - try { - if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { - $spamEmails[] = $userId; - } - } catch (\Throwable $e) { - // Log the exception and continue - $this->logger->info("Validation failed for $userId <$recoveryEmail>: " . $e->getMessage()); - } + + private function ensureEmailIsValid(string $recoveryEmail, string $username, string $apiKey, IL10N $l): bool { + $url = sprintf(self::VERIFYMAIL_API_URL, $recoveryEmail, $apiKey); + try { + $httpClient = $this->httpClientService->newClient(); + $response = $httpClient->get($url, ['timeout' => 15]); + $data = json_decode($response->getBody(), true); + if (!is_array($data)) { + return false; + } + if ($data['disposable'] ?? false || !($data['deliverable_email'] ?? true)) { + return false; } + } catch (\Throwable $e) { + $this->logger->error("Email validation failed for $username <$recoveryEmail>: " . $e->getMessage()); + return true; // fallback to avoid false positive + } + return true; + } + + private function verifyDomainWithApi(string $domain, string $username, string $apiKey, IL10N $l): void { + $url = sprintf(self::VERIFYMAIL_API_URL, $domain, $apiKey); + $httpClient = $this->httpClientService->newClient(); + $response = $httpClient->get($url, ['timeout' => 15]); + $data = json_decode($response->getBody(), true); + if (!$data || !is_array($data)) { + throw new \RuntimeException("Invalid response"); + } + if ($data['disposable'] ?? false || !$data['mx'] ?? true) { + throw new BlacklistedEmailException($l->t('Domain is not valid')); } - return $spamEmails; } } -- GitLab From 8cbec49523e5970d5025158db5276212b40c1814 Mon Sep 17 00:00:00 2001 From: AVINASH GUSAIN Date: Tue, 10 Jun 2025 14:38:17 +0530 Subject: [PATCH 10/12] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Fahim Salam Chowdhury --- lib/Service/SpamEmailService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php index 0c0995c..9e00f7b 100644 --- a/lib/Service/SpamEmailService.php +++ b/lib/Service/SpamEmailService.php @@ -65,6 +65,7 @@ class SpamEmailService { if (empty($recoveryEmail)) { return true; } + $email = $this->getUserEmail($username); $l = $this->l10nFactory->get('emailrecovery', $language); if (!$this->enforceBasicRecoveryEmailRules($recoveryEmail, $username, $email, $l)) { -- GitLab From 62d3febdd680ffe96cc528b5c8e2131e7ac00a9b Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Tue, 10 Jun 2025 14:43:31 +0530 Subject: [PATCH 11/12] added phpdoc --- lib/Service/SpamEmailService.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php index 9e00f7b..c99833b 100644 --- a/lib/Service/SpamEmailService.php +++ b/lib/Service/SpamEmailService.php @@ -123,6 +123,25 @@ class SpamEmailService { } return true; } + /** + * Ensures that a real-time rate limit is not exceeded for a given key. + * + * This function implements a sliding window rate limiter that allows up to `$rateLimit` + * operations per second for a specific cache key. If the limit is reached, it waits + * until a slot becomes available (based on the oldest timestamp), retrying up to `$maxRetries` times. + * + * The function stores timestamps of each request in a cache and filters them to keep + * only those within the last 1 second. If the number of valid timestamps exceeds the + * allowed rate, the function sleeps for the remaining time until the oldest timestamp + * expires. After retrying for `$maxRetries` times, it gives up and returns `false`. + * + * @param string $key The unique cache key used to track rate-limited actions. + * @param int $rateLimit The maximum number of allowed operations per second. + * @param IL10N $l Localization object + * @param int $maxRetries The maximum number of retry attempts while waiting for a rate slot to become available (default is 10). + * + * @return bool Returns `true` if the rate limit was not exceeded (and the action can proceed), or `false` if retries were exhausted. + */ private function ensureRealTimeRateLimit(string $key, int $rateLimit, IL10N $l, int $maxRetries = 10): bool { $now = microtime(true); -- GitLab From c27a21e8dfffabb93b2a1532d6a03f3d126030f9 Mon Sep 17 00:00:00 2001 From: Avinash Gusain Date: Wed, 11 Jun 2025 11:11:27 +0530 Subject: [PATCH 12/12] used APP_ID --- lib/Service/SpamEmailService.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php index c99833b..edaaa7c 100644 --- a/lib/Service/SpamEmailService.php +++ b/lib/Service/SpamEmailService.php @@ -11,6 +11,7 @@ use OCP\Http\Client\IClientService; use OCP\ICacheFactory; use OCA\EmailRecovery\Db\ConfigMapper; use OCA\EmailRecovery\Exception\BlacklistedEmailException; +use OCA\EmailRecovery\AppInfo\Application; class SpamEmailService { private ILogger $logger; @@ -67,7 +68,7 @@ class SpamEmailService { } $email = $this->getUserEmail($username); - $l = $this->l10nFactory->get('emailrecovery', $language); + $l = $this->l10nFactory->get(Application::APP_ID, $language); if (!$this->enforceBasicRecoveryEmailRules($recoveryEmail, $username, $email, $l)) { return false; } -- GitLab