diff --git a/lib/Command/SpamAccountDetection.php b/lib/Command/SpamAccountDetection.php index 82eeb166407692b0f2a0351c2149e1ff2088faac..72e18e6bd90c4cf61bd1999b946ef2e2b4f6de9f 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\SpamEmailService; +use OCA\EmailRecovery\Service\RecoveryEmailService; 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 SpamEmailService $spamEmailService; + private RecoveryEmailService $recoveryEmailService; private ILogger $logger; private IAppData $appData; - public function __construct(SpamEmailService $spamEmailService, ILogger $logger, IAppData $appData) { + public function __construct(RecoveryEmailService $recoveryEmailService, ILogger $logger, IAppData $appData) { parent::__construct(); - $this->spamEmailService = $spamEmailService; + $this->recoveryEmailService = $recoveryEmailService; $this->logger = $logger; $this->appData = $appData; } @@ -32,11 +32,12 @@ class SpamAccountDetection extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { try { - $spamUserNames = $this->spamEmailService->getAllSpamEmails(); + $spamUsers = $this->recoveryEmailService->getAllSpamEmails(); $output->writeln('Spam user list:'); - foreach ($spamUserNames as $username) { - $output->writeln($username); + foreach ($spamUsers as $user) { + $output->writeln($user['userId']); + //$output->writeln("User ID: {$user['userId']}, Recovery Email: {$user['recoveryEmail']}"); } } catch (\Throwable $th) { $this->logger->error('Error while fetching domains. ' . $th->getMessage()); diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index d4b7c7f6bb9adabcb8989e7784786b81ad5407dd..0053096f811985b94df69eaab63be1dd64d48c55 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -560,4 +560,57 @@ class RecoveryEmailService { $requests[] = $now; $this->cache->set($key, $requests, 2); } + + /** + * Scans all verified recovery email addresses and returns a list of spam accounts. + * + * This method validates each user's recovery email using a series of checks. + * Users are considered spam if their recovery email is: + * - A disposable address + * - A blacklisted or disallowed domain + * - Invalid in format + * - The same as their account email + * - Already taken by another user + * - Unverifiable or unreachable (e.g., failed MX or domain lookup) + * + * Returns an array of spam account entries, each including: + * - userId: the username + * - recoveryEmail: the flagged recovery email address + * + * @return array + */ + public function getAllSpamEmails(): array { + $verifiedEmails = $this->configMapper->getAllVerifiedRecoveryEmails(); + $spamAccounts = []; + foreach ($verifiedEmails as $entry) { + $recoveryEmail = strtolower(trim($entry['configvalue'])); + $userId = strtolower(trim($entry['userid'])); + if ($recoveryEmail !== '' && $userId !== '') { + try { + if (!$this->validateRecoveryEmail($recoveryEmail, $userId)) { + $spamAccounts[] = [ + 'userId' => $userId, + 'recoveryEmail' => $recoveryEmail, + ]; + } + } catch ( + BlacklistedEmailException | + InvalidRecoveryEmailException | + SameRecoveryEmailAsEmailException | + RecoveryEmailAlreadyFoundException | + MurenaDomainDisallowedException + $e) { + $this->logger->info("Validation failed (spam) for $userId <$recoveryEmail>: " . $e->getMessage()); + $spamAccounts[] = [ + 'userId' => $userId, + 'recoveryEmail' => $recoveryEmail, + ]; + } catch (\Throwable $e) { + // Catch all other exceptions + $this->logger->info("Error while checking $userId <$recoveryEmail>: " . $e->getMessage()); + } + } + } + return $spamAccounts; + } } diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php deleted file mode 100644 index edaaa7c8a4e7317e043a9f7e15f9c1ea24dbb5ae..0000000000000000000000000000000000000000 --- a/lib/Service/SpamEmailService.php +++ /dev/null @@ -1,198 +0,0 @@ -logger = $logger; - $this->config = $config; - $this->userManager = $userManager; - $this->l10nFactory = $l10nFactory; - $this->httpClientService = $httpClientService; - $this->cacheFactory = $cacheFactory; - $this->domainService = $domainService; - $this->l = $l; - $this->configMapper = $configMapper; - $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY); - } - - 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()); - } - } - } - return $spamEmails; - } - - public function validateRecoveryEmail(string $recoveryEmail, string $username = '', string $language = 'en'): bool { - if (empty($recoveryEmail)) { - return true; - } - - $email = $this->getUserEmail($username); - $l = $this->l10nFactory->get(Application::APP_ID, $language); - if (!$this->enforceBasicRecoveryEmailRules($recoveryEmail, $username, $email, $l)) { - return false; - } - - $apiKey = $this->config->getSystemValue('verify_mail_api_key', ''); - if ($this->domainService->isDomainInCustomBlacklist($recoveryEmail, $l)) { - return false; - } - - $domainCheckPassed = true; - $emailCheckPassed = true; - - if ($this->domainService->isPopularDomain($recoveryEmail, $l)) { - $emailCheckPassed = $this->ensureRealTimeRateLimit(self::RATE_LIMIT_EMAIL, 2, $l); - } else { - $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 (!$emailCheckPassed) { - return false; - } - - 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 (!filter_var($recoveryEmail, FILTER_VALIDATE_EMAIL)) { - return false; - } - if (!empty($email) && strcmp($recoveryEmail, $email) === 0) { - return false; - } - if ($this->domainService->isBlacklistedDomain($recoveryEmail, $l)) { - return false; - } - 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); - $attempts = 0; - $requests = $this->cache->get($key) ?? []; - $requests = array_filter($requests, fn ($t) => ($now - $t) <= 1); - - while (count($requests) >= $rateLimit) { - usleep((int)((1 - ($now - min($requests))) * 1000000)); - $now = microtime(true); - $requests = array_filter($requests, fn ($t) => ($now - $t) <= 1); - if (++$attempts >= $maxRetries) { - return false; - } - } - - $requests[] = $now; - $this->cache->set($key, $requests, 2); - return true; - } - - 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')); - } - } -}