Files

268 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Box\Mod\Support\Entity\Helpdesk;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use FOSSBilling\InformationException;
use Pimple\Container;
class Settings
{
public const DEFAULTS = [
'enabled' => 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);
}
}