From cacf92efe5194550863ba429725ed7379e76137c Mon Sep 17 00:00:00 2001 From: Alexandre Roux Date: Sat, 11 Apr 2026 02:20:37 +0200 Subject: [PATCH 1/4] add whitelist logic + whitelist command for admins --- lib/Command/AdminWhitelistedDomains.php | 101 ++++++++++++++++++++++++ lib/Service/DomainService.php | 65 ++++++++++++--- lib/Service/RecoveryEmailService.php | 7 +- 3 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 lib/Command/AdminWhitelistedDomains.php diff --git a/lib/Command/AdminWhitelistedDomains.php b/lib/Command/AdminWhitelistedDomains.php new file mode 100644 index 0000000..9553e76 --- /dev/null +++ b/lib/Command/AdminWhitelistedDomains.php @@ -0,0 +1,101 @@ +setName(Application::APP_ID.':admin-whitelisted-domains') + ->setDescription('Manage admin whitelisted domains') + ->addArgument( + 'domain', + InputArgument::OPTIONAL, + 'Domain name to add or delete', + '' + ) + ->addOption( + 'add', + null, + InputOption::VALUE_NONE, + 'Specify this option to add the domain' + ) + ->addOption( + 'list', + null, + InputOption::VALUE_NONE, + 'Specify this option to list all whitelisted domain' + ) + ->addOption( + 'delete', + null, + InputOption::VALUE_NONE, + 'Specify this option to delete the domain' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + if ($input->getOption('list')) { + $this->listDomains($output); + return 0; + } + } catch (\Throwable $e) { + $output->writeln('Error listing admin whitelisted domains: ' . $e->getMessage()); + return 1; + } + try { + if ($input->getOption('add')) { + $this->addDomain($input, $output); + return 0; + } + } catch (\Throwable $e) { + $output->writeln("Error adding admin whitelisted domain: " . $e->getMessage()); + return 1; + } + + try { + if ($input->getOption('delete')) { + $this->deleteDomain($input, $output); + return 0; + } + } catch (\Throwable $e) { + $output->writeln("Error deleting admin whitelisted domain:" . $e->getMessage()); + return 1; + } + } + + private function listDomains(OutputInterface $output): void { + $domains = $this->domainService->getAdminWhitelistedDomains(); + $output->write($domains, true); + } + + private function addDomain(InputInterface $input, OutputInterface $output): void { + if (!$input->getArgument('domain')) { + throw new \Exception('Domain argument is required!'); + } + $domain = $input->getArgument('domain'); + $this->domainService->addAdminWhitelistedDomain($domain); + } + + private function deleteDomain(InputInterface $input, OutputInterface $output): void { + if (!$input->getArgument('domain')) { + throw new \Exception('Domain argument is required!'); + } + $domain = $input->getArgument('domain'); + $this->domainService->deleteAdminWhitelistedDomain($domain); + } +} diff --git a/lib/Service/DomainService.php b/lib/Service/DomainService.php index 53ec8bb..9106995 100644 --- a/lib/Service/DomainService.php +++ b/lib/Service/DomainService.php @@ -25,6 +25,7 @@ class DomainService { private const BLACKLISTED_DOMAINS_FILE = 'blacklisted_domains.json'; private const DISPOSABLE_DOMAINS_FILE = 'disposable_domains.json'; private const ADMIN_BLACKLISTED_DOMAINS_FILE = 'admin_blacklisted_domains.json'; + private const ADMIN_WHITELISTED_DOMAINS_FILE = 'admin_whitelisted_domains.json'; public function __construct(string $appName, LoggerInterface $logger, IAppData $appData, Client $httpClient, IL10N $l) { $this->logger = $logger; @@ -54,6 +55,35 @@ class DomainService { return $this->isDomainInList($email, $domains); } + /** + * Check if an email belongs to a whitelisted domain. + */ + public function isAdminWhitelistedDomain(string $email, IL10N $l): bool { + $domains = $this->getDomainsFromFile(self::ADMIN_WHITELISTED_DOMAINS_FILE, $l); + return $this->isDomainInList($email, $domains); + } + + /** + * Add domain to admin blacklist + */ + public function addAdminWhitelistedDomain(string $domain): void { + $this->addDomainToFile($domain, self::ADMIN_WHITELISTED_DOMAINS_FILE); + } + + /** + * Delete domain from admin blacklist + */ + public function deleteAdminWhitelistedDomain(string $domain): void { + $this->deleteDomainFromFile($domain, self::ADMIN_WHITELISTED_DOMAINS_FILE); + } + + /** + * Return all admin blacklist domains + */ + public function getAdminWhitelistedDomains(): array { + return $this->getDomainsFromFile(self::ADMIN_WHITELISTED_DOMAINS_FILE); + } + /** * Check if an email belongs to a custom blacklist domain. */ @@ -74,19 +104,28 @@ class DomainService { * Add domain to admin blacklist */ public function addAdminBlacklistedDomain(string $domain): void { - $domains = $this->getDomainsFromFile(self::ADMIN_BLACKLISTED_DOMAINS_FILE); + $this->addDomainToFile($domain, self::ADMIN_BLACKLISTED_DOMAINS_FILE); + } - $cleanDomain = preg_replace('/[\x00-\x1F\x7F]/', '', $domain); - $domains = array_unique(array_merge($domains, [$cleanDomain])); + /** + * Delete domain from admin blacklist + */ + public function deleteAdminBlacklistedDomain(string $domain): void { + $this->deleteDomainFromFile($domain, self::ADMIN_BLACKLISTED_DOMAINS_FILE); + } - $this->saveDomainsToFile(self::ADMIN_BLACKLISTED_DOMAINS_FILE, $domains); + /** + * Return all admin blacklist domains + */ + public function getAdminBlacklistedDomains(): array { + return $this->getDomainsFromFile(self::ADMIN_BLACKLISTED_DOMAINS_FILE); } /** * Delete domain from admin blacklist */ - public function deleteAdminBlacklistedDomain(string $domain): void { - $domains = $this->getDomainsFromFile(self::ADMIN_BLACKLISTED_DOMAINS_FILE); + public function deleteDomainFromFile(string $domain, string $file): void { + $domains = $this->getDomainsFromFile($file); $cleanDomain = preg_replace('/[\x00-\x1F\x7F]/', '', $domain); $domains = array_unique($domains); @@ -96,15 +135,21 @@ class DomainService { unset($domains[$key]); } - $this->saveDomainsToFile(self::ADMIN_BLACKLISTED_DOMAINS_FILE, $domains); + $this->saveDomainsToFile($file, $domains); } /** - * Return all admin blacklist domains + * Add domain to file */ - public function getAdminBlacklistedDomains(): array { - return $this->getDomainsFromFile(self::ADMIN_BLACKLISTED_DOMAINS_FILE); + public function addDomainToFile(string $domain, string $file): void { + $domains = $this->getDomainsFromFile($file); + + $cleanDomain = preg_replace('/[\x00-\x1F\x7F]/', '', $domain); + $domains = array_unique(array_merge($domains, [$cleanDomain])); + + $this->saveDomainsToFile($file, $domains); } + /** * Update blacklisted domains by fetching from the external source. */ diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index b3072f0..336a5bf 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -154,7 +154,12 @@ class RecoveryEmailService { //throw new \Exception($l->t('The provided email domain is a disposable domain and cannot be used.')); throw new BlacklistedEmailException($l->t('The email address is disposable. Please provide another recovery address.')); } - + + // Check if domain should bypass all verification logic + if ($this->domainService->isAdminWhitelistedDomain($recoveryEmail, $l)) { + return true; + } + // Check if the domain is a popular domain if ($this->domainService->isPopularDomain($recoveryEmail, $l)) { // Skip domain verification and directly validate the email -- GitLab From 624fcd0fbfc40ea6a7715f91194d4bf864c62134 Mon Sep 17 00:00:00 2001 From: Alexandre Roux Date: Tue, 14 Apr 2026 17:03:23 +0200 Subject: [PATCH 2/4] adding AdminWhitelistedDomains to command list --- appinfo/info.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/appinfo/info.xml b/appinfo/info.xml index db8bbc0..6a62bcb 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -25,5 +25,6 @@ OCA\EmailRecovery\Command\AdminBlacklistedDomains OCA\EmailRecovery\Command\RecoveryWarningNotificationCommand OCA\EmailRecovery\Command\FilterLegitimateDomainsFromSpamReport + OCA\EmailRecovery\Command\AdminWhitelistedDomains -- GitLab From c98f73d940cc383b5acbec7b558cdd42cdf29e53 Mon Sep 17 00:00:00 2001 From: Alexandre Roux Date: Tue, 14 Apr 2026 17:12:27 +0200 Subject: [PATCH 3/4] fix command + logic --- lib/Command/AdminWhitelistedDomains.php | 1 + lib/Service/RecoveryEmailService.php | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/Command/AdminWhitelistedDomains.php b/lib/Command/AdminWhitelistedDomains.php index 9553e76..c5496f6 100644 --- a/lib/Command/AdminWhitelistedDomains.php +++ b/lib/Command/AdminWhitelistedDomains.php @@ -76,6 +76,7 @@ class AdminWhitelistedDomains extends Command { $output->writeln("Error deleting admin whitelisted domain:" . $e->getMessage()); return 1; } + return 1; } private function listDomains(OutputInterface $output): void { diff --git a/lib/Service/RecoveryEmailService.php b/lib/Service/RecoveryEmailService.php index 336a5bf..466b34e 100644 --- a/lib/Service/RecoveryEmailService.php +++ b/lib/Service/RecoveryEmailService.php @@ -149,17 +149,17 @@ class RecoveryEmailService { if (empty($apiKey)) { $this->logger->info('VerifyMail API Key is not configured.'); } - - if ($this->domainService->isDomainInCustomBlacklist($recoveryEmail, $l)) { - //throw new \Exception($l->t('The provided email domain is a disposable domain and cannot be used.')); - throw new BlacklistedEmailException($l->t('The email address is disposable. Please provide another recovery address.')); - } // Check if domain should bypass all verification logic if ($this->domainService->isAdminWhitelistedDomain($recoveryEmail, $l)) { return true; } + if ($this->domainService->isDomainInCustomBlacklist($recoveryEmail, $l)) { + //throw new \Exception($l->t('The provided email domain is a disposable domain and cannot be used.')); + throw new BlacklistedEmailException($l->t('The email address is disposable. Please provide another recovery address.')); + } + // Check if the domain is a popular domain if ($this->domainService->isPopularDomain($recoveryEmail, $l)) { // Skip domain verification and directly validate the email -- GitLab From 726977a407c068ab22ca6bbd556e1453599e30a7 Mon Sep 17 00:00:00 2001 From: Alexandre Roux Date: Tue, 14 Apr 2026 17:19:42 +0200 Subject: [PATCH 4/4] doc --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index a9c1081..800576e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ To delete a config value: occ config:system:delete recovery_warning_configs ``` +## Whitelisting domains for recovery email + +This is used to enforce a valid domain if it is blacklisted by mistake by any of our automated sources +- The command `occ email-recovery:admin-whitelisted-domains` can be used by an admin to manage manual whitelisting of domains + - `occ email-recovery:admin-whitelisted-domains --list` lists all the domains whitelisted by admin + - `occ email-recovery:admin-whitelisted-domains --add domain.xyz` can be used to add the domain "domain.xyz" to the admin whitelist + - `occ email-recovery:admin-whitelisted-domains --delete domain.xyz` can be used to delete the domain "domain.xyz" from the admin whitelist + ## Blacklisting domains for recovery email - There is inbuilt automated blacklisting of domains in this apps based on multiple sources -- GitLab