false, 'host' => '', 'port' => 993, 'encryption' => 'ssl', 'validate_cert' => true, 'username' => '', 'password' => '', 'mailbox' => '', 'inbox_folder' => 'INBOX', 'processed_folder' => 'Processed', 'ignored_folder' => 'Ignored', 'failed_folder' => 'Failed', 'support_helpdesk_id' => null, 'tested_config_hash' => null, 'test_result' => null, ]; public function __construct(private Container $di) {} private function all(): array { try { $encrypted = $this->di['dbal']->fetchOne('SELECT config_encrypted FROM mailgateway_settings WHERE id = 1'); } catch (\Doctrine\DBAL\Exception\TableNotFoundException) { // Keep Advanced setup reachable if the module tables need creating. return ['default' => self::DEFAULTS]; } if ($encrypted === false) { return ['default' => self::DEFAULTS]; } $plain = $this->di['crypt']->decrypt($encrypted); if (!is_string($plain)) { throw new InformationException('Mail Gateway settings could not be decrypted'); } $decoded = json_decode($plain, true, 512, JSON_THROW_ON_ERROR); $mailboxes = $decoded['mailboxes'] ?? ['default' => $decoded]; return array_map(function ($settings) { // Older configurations stored this unused operational value per mailbox. unset($settings['poll_interval_minutes']); $settings = array_replace(self::DEFAULTS, $settings); if (!$this->verified($settings)) { $settings['enabled'] = false; } return $settings; }, $mailboxes); } /** Internal polling configuration, including encrypted-at-rest credentials. */ public function getAll(): array { return $this->all(); } public function get(?string $id = null): array { $mailboxes = $this->all(); if ($id === null) { return $mailboxes['default'] ?? (reset($mailboxes) ?: self::DEFAULTS); } return $mailboxes[$id] ?? throw new InformationException('Mailbox not found'); } public function getAllPublic(): array { return array_map(fn ($settings) => $this->publicSettings($settings), $this->all()); } public function delete(string $id): bool { return $this->di['dbal']->transactional(function ($connection) use ($id): bool { if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { $connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE'); } $mailboxes = $this->all(); if (!isset($mailboxes[$id])) { throw new InformationException('Mailbox not found'); } unset($mailboxes[$id]); $encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR)); $connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]); return true; }); } private function publicSettings(array $settings): array { $settings['password_configured'] = $settings['password'] !== ''; $settings['test_passed'] = $this->verified($settings); unset($settings['password'], $settings['tested_config_hash']); return $settings; } private function fingerprint(array $settings): string { $connection = array_intersect_key($settings, array_flip(['host', 'port', 'encryption', 'username', 'password', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'])); ksort($connection); return hash('sha256', json_encode($connection, JSON_THROW_ON_ERROR)); } private function verified(array $settings): bool { return is_string($settings['tested_config_hash']) && hash_equals($settings['tested_config_hash'], $this->fingerprint($settings)); } public function recordTest(string $id, array $testedSettings, array $result): bool { return $this->di['dbal']->transactional(function ($connection) use ($id, $testedSettings, $result): bool { if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { $connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE'); } $mailboxes = $this->all(); if (!isset($mailboxes[$id]) || $this->fingerprint($mailboxes[$id]) !== $this->fingerprint($testedSettings)) { throw new InformationException('Mailbox settings changed during the test. Save and test again.'); } $mailboxes[$id]['tested_config_hash'] = $result['success'] ? $this->fingerprint($testedSettings) : null; $mailboxes[$id]['test_result'] = $result; if (!$result['success']) { $mailboxes[$id]['enabled'] = false; } $encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR)); $connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]); return true; }); } public function forAddress(string $address): array { foreach ($this->all() as $settings) { if (strcasecmp($settings['mailbox'], $address) === 0) { return $settings; } } throw new InformationException('Receiving mailbox is not configured'); } public function getPublic(): array { return $this->publicSettings($this->get()); } public function save(array $data): bool { return $this->di['dbal']->transactional(function ($connection) use ($data): bool { // Lock before reading so concurrent saves cannot overwrite another mailbox. if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { $connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE'); } return $this->saveMailbox($data); }); } private function saveMailbox(array $data): bool { $mailboxes = $this->all(); $id = $data['mailbox_id'] ?? 'default'; if (!is_string($id) || ($id !== 'new' && !isset($mailboxes[$id]))) { throw new InformationException('Mailbox not found'); } $old = $id === 'new' ? self::DEFAULTS : $mailboxes[$id]; $settings = self::DEFAULTS; $settings['enabled'] = $this->boolean($data['enabled'] ?? false); if ($settings['enabled'] && !($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->status()['installed']) { throw new InformationException('Install Mail Gateway dependencies before enabling mailbox importing'); } foreach (['host', 'username', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) { if (!isset($data[$field]) || !is_string($data[$field])) { throw new InformationException($field . ' is required'); } $value = trim($data[$field]); if ($value === '' || mb_strlen($value, 'UTF-8') > 255 || !mb_check_encoding($value, 'UTF-8') || preg_match('/[\x00-\x1F\x7F]/', $value)) { throw new InformationException($field . ' must be a non-empty value of at most 255 characters, without control characters'); } $settings[$field] = $value; } if (!filter_var($settings['host'], FILTER_VALIDATE_IP) && !filter_var($settings['host'], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { throw new InformationException('IMAP host must be a hostname or IP address'); } if (!filter_var($settings['mailbox'], FILTER_VALIDATE_EMAIL) || strlen($settings['mailbox']) > 255) { throw new InformationException('A valid mailbox email address is required'); } $settings['mailbox'] = strtolower($settings['mailbox']); $settings['port'] = $this->integer($data['port'] ?? null, 1, 65535, 'port'); $settings['encryption'] = $data['encryption'] ?? null; if (!in_array($settings['encryption'], ['ssl', 'tls'], true)) { throw new InformationException('Choose implicit TLS or STARTTLS'); } $folders = array_map(fn ($key) => mb_strtolower($settings[$key], 'UTF-8'), ['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder']); if (count(array_unique($folders)) !== 4) { throw new InformationException('Inbox, processed, ignored, and failed folders must be different'); } $settings['support_helpdesk_id'] = $this->integer($data['support_helpdesk_id'] ?? null, 1, PHP_INT_MAX, 'support_helpdesk_id'); if (!$this->di['mod_service']('support')->getHelpdeskRepository()->find($settings['support_helpdesk_id']) instanceof Helpdesk) { throw new InformationException('Choose an existing helpdesk'); } $password = $data['password'] ?? ''; if (!is_string($password) || strlen($password) > 4096 || str_contains($password, "\0")) { throw new InformationException('Invalid mailbox password'); } $clear = $this->boolean($data['clear_password'] ?? false); if ($clear && $password !== '') { throw new InformationException('Choose either a new password or clear the saved password'); } $settings['password'] = $clear ? '' : ($password !== '' ? $password : $old['password']); if ($settings['enabled'] && $settings['password'] === '') { throw new InformationException('Save a mailbox password before enabling imports'); } $changed = $this->fingerprint($settings) !== $this->fingerprint($old); $settings['tested_config_hash'] = $changed ? null : $old['tested_config_hash']; $settings['test_result'] = $changed ? null : $old['test_result']; if ($settings['enabled'] && !$this->verified($settings)) { if ($changed && $this->verified($old)) { // Saving changed connection details disables imports until retested. $settings['enabled'] = false; } else { throw new InformationException('Save this mailbox and successfully run Test Connection and Create Folders before enabling importing'); } } foreach ($mailboxes as $otherId => $other) { if ($otherId !== $id && strcasecmp($other['mailbox'], $settings['mailbox']) === 0) { throw new InformationException('This mailbox email address is already configured'); } } if ($id === 'new') { $id = bin2hex(random_bytes(16)); } $mailboxes[$id] = $settings; $encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR)); $this->di['dbal']->transactional(function ($connection) use ($encrypted): void { // MySQL serializes concurrent settings saves on this singleton row. try { $connection->insert('mailgateway_settings', ['id' => 1, 'config_encrypted' => $encrypted]); } catch (UniqueConstraintViolationException) { // Existing singleton configuration; update under its row lock. } $connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]); }); return true; } private function integer(mixed $value, int $min, int $max, string $field): int { if ((!is_string($value) && !is_int($value)) || filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]) === false) { throw new InformationException($field . ' is invalid'); } return (int) $value; } private function boolean(mixed $value): bool { if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) { throw new InformationException('Invalid checkbox value'); } return in_array($value, [true, 1, '1'], true); } }