diff --git a/appinfo/info.xml b/appinfo/info.xml index d839897c1427f613db984d0e83f31932f3b95199..6741863a57dd87d912d76327e309f60f357870a9 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 0000000000000000000000000000000000000000..82eeb166407692b0f2a0351c2149e1ff2088faac --- /dev/null +++ b/lib/Command/SpamAccountDetection.php @@ -0,0 +1,49 @@ +spamEmailService = $spamEmailService; + $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 { + $spamUserNames = $this->spamEmailService->getAllSpamEmails(); + + $output->writeln('Spam user list:'); + foreach ($spamUserNames as $username) { + $output->writeln($username); + } + } catch (\Throwable $th) { + $this->logger->error('Error while fetching domains. ' . $th->getMessage()); + $output->writeln('Error while fetching domains: ' . $th->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/lib/Db/ConfigMapper.php b/lib/Db/ConfigMapper.php index 1820cd2ac776e4cb9506447438aa42e67b4c44b2..b7f3484064392ccc0b3a90126127dd2757a7cffb 100644 --- a/lib/Db/ConfigMapper.php +++ b/lib/Db/ConfigMapper.php @@ -29,4 +29,19 @@ 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')) + ->andWhere($qb->expr()->neq('configvalue', $qb->createNamedParameter(''))) + ->andWhere($qb->expr()->neq('userid', $qb->createNamedParameter(''))); + + $result = $qb->execute(); + + return $result->fetchAll(); + } } diff --git a/lib/Service/SpamEmailService.php b/lib/Service/SpamEmailService.php new file mode 100644 index 0000000000000000000000000000000000000000..edaaa7c8a4e7317e043a9f7e15f9c1ea24dbb5ae --- /dev/null +++ b/lib/Service/SpamEmailService.php @@ -0,0 +1,198 @@ +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')); + } + } +}